From 2d8bc686154d28ca68d67c5889a2cfc6f8ecf07c Mon Sep 17 00:00:00 2001 From: asyouplz <136051230+asyouplz@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:33:20 +0900 Subject: [PATCH] fix: address review feedback and stabilize tests (#60) * fix(review): resolve obsidian bot validation errors (metadata, lint, ui) * feat(release): prepare version 4.1.0 * fix(ci): add semantic-release configuration * fix(review): address pr feedback and stabilize tests Co-Authored-By: Claude Sonnet 4.5 * fix(types): resolve typecheck regressions Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- .eslintrc.json | 5 +- jest.config.js | 4 - src/architecture/ErrorBoundary.ts | 2 +- .../transcription/TranscriptionService.ts | 101 ++++++-- src/domain/models/Settings.ts | 17 +- src/infrastructure/api/SettingsAPI.ts | 7 +- src/infrastructure/api/SettingsMigrator.ts | 73 ++++-- src/infrastructure/api/SettingsValidator.ts | 6 +- .../api/TranscriberFactoryRefactored.ts | 4 +- src/infrastructure/api/WhisperService.ts | 90 ++++++- .../api/providers/ITranscriber.ts | 5 +- .../api/providers/common/RateLimiter.ts | 2 +- .../api/providers/common/RetryStrategy.ts | 10 +- .../api/providers/deepgram/DeepgramAdapter.ts | 14 +- .../api/providers/deepgram/DeepgramService.ts | 50 ++-- .../deepgram/DeepgramServiceRefactored.ts | 20 +- .../deepgram/ModelMigrationService.ts | 132 +++++++--- .../api/providers/deepgram/audioUtils.ts | 10 +- .../api/providers/deepgram/errorHandler.ts | 26 +- .../providers/deepgram/reliabilityUtils.ts | 13 +- .../api/providers/deepgram/types.ts | 38 +-- .../api/providers/factory/MetricsTracker.ts | 2 +- src/infrastructure/security/Encryptor.ts | 8 +- src/infrastructure/storage/SettingsManager.ts | 24 +- src/patterns/Singleton.ts | 11 +- src/testing/TestingFramework.ts | 31 ++- src/types/resources.ts | 2 +- src/ui/components/DragDropZone.ts | 36 +-- src/ui/components/EventHandlers.ts | 47 ++-- src/ui/components/FileValidator.ts | 24 +- src/ui/formatting/FormatOptions.ts | 2 +- src/ui/managers/SettingsTabManager.ts | 11 +- src/ui/managers/StatusBarManager.ts | 3 +- src/ui/modals/FilePickerModal.ts | 23 +- src/ui/modals/FilePickerModalRefactored.ts | 25 +- src/ui/notifications/NotificationManager.ts | 72 ++++-- src/ui/progress/ProgressBar.ts | 13 +- src/ui/progress/ProgressTracker.ts | 25 +- src/ui/progress/StatusMessage.ts | 21 +- src/ui/settings/EnhancedSettingsTab.ts | 18 +- src/ui/settings/SettingsTab.ts | 23 +- src/ui/settings/SettingsTabOptimized.ts | 100 ++++---- src/ui/settings/SettingsTabRefactored.ts | 231 +++++++++--------- src/ui/settings/SimpleSettingsTab.ts | 18 +- src/ui/settings/base/BaseSettingsComponent.ts | 2 +- src/ui/settings/base/CommonUIComponents.ts | 2 +- .../settings/components/AdvancedSettings.ts | 16 +- src/ui/settings/components/ApiKeyValidator.ts | 13 +- src/ui/settings/components/AudioSettings.ts | 2 +- .../settings/components/ProviderSettings.ts | 59 ++--- .../settings/components/ShortcutSettings.ts | 7 +- src/ui/settings/error/ErrorHandling.ts | 25 +- .../provider/ProviderSettingsContainer.ts | 20 +- .../ProviderSettingsContainerRefactored.ts | 14 +- .../provider/components/APIKeyManager.ts | 12 +- .../components/APIKeyManagerRefactored.ts | 6 +- .../components/AdvancedSettingsPanel.ts | 15 +- src/utils/async/AsyncManager.ts | 35 +-- src/utils/common/formatters.ts | 2 +- src/utils/memory/ObjectPool.ts | 14 +- src/utils/performance/PerformanceBenchmark.ts | 34 +-- tests/helpers/e2e.setup.ts | 94 +++++++ tests/helpers/testSetup.js | 90 ++++++- 63 files changed, 1223 insertions(+), 638 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index d12fcc4..d6c605c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -28,7 +28,8 @@ "esbuild.config.mjs", "node_modules/**", "dist/**", - "build/**" + "build/**", + "tests/**" ], "rules": { "unused-imports/no-unused-imports": "error", @@ -79,4 +80,4 @@ "no-var": "error", "no-case-declarations": "off" } -} \ No newline at end of file +} diff --git a/jest.config.js b/jest.config.js index 1f93b3d..173d840 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,6 @@ const isDebug = process.env.DEBUG === 'true'; const tsJestTransform = [ 'ts-jest', { - isolatedModules: true, tsconfig: { esModuleInterop: true, allowSyntheticDefaultImports: true, @@ -81,7 +80,6 @@ module.exports = { testEnvironment: 'node', testMatch: ['/tests/unit/**/*.test.ts'], setupFilesAfterEnv: ['/tests/helpers/testSetup.js'], - testTimeout: 10000, coverageThreshold: { global: { branches: 50, @@ -96,7 +94,6 @@ module.exports = { testEnvironment: 'node', testMatch: ['/tests/integration/**/*.test.ts'], setupFilesAfterEnv: ['/tests/helpers/testSetup.js'], - testTimeout: 15000, coverageThreshold: { global: { branches: 50, @@ -112,7 +109,6 @@ module.exports = { testMatch: ['/tests/e2e/**/*.e2e.test.ts'], setupFilesAfterEnv: ['/tests/helpers/e2e.setup.ts'], maxWorkers: 1, - testTimeout: 30000, coverageThreshold: { global: { branches: 50, diff --git a/src/architecture/ErrorBoundary.ts b/src/architecture/ErrorBoundary.ts index c05cd79..1e7d1d4 100644 --- a/src/architecture/ErrorBoundary.ts +++ b/src/architecture/ErrorBoundary.ts @@ -350,7 +350,7 @@ export class ErrorBoundary { context, }); - new Notice('Speech-to-Text plugin is experiencing issues. Please restart Obsidian.', 15000); + new Notice('Speech-to-text plugin is experiencing issues. Please restart Obsidian.', 15000); // 플러그인 비활성화 고려 // this.plugin.unload(); diff --git a/src/core/transcription/TranscriptionService.ts b/src/core/transcription/TranscriptionService.ts index fb4e4a2..5020ef7 100644 --- a/src/core/transcription/TranscriptionService.ts +++ b/src/core/transcription/TranscriptionService.ts @@ -108,8 +108,8 @@ export class TranscriptionService implements ITranscriptionService { } const text = response.text; - if (!text || typeof text !== 'string' || text.trim() === '') { - throw new Error('Transcription service returned empty or invalid text'); + if (typeof text !== 'string') { + throw new Error('Transcription service returned invalid text'); } const _language = response.language || languagePreference; @@ -127,7 +127,7 @@ export class TranscriptionService implements ITranscriptionService { const result: TranscriptionResult = { text: formattedText, language: _language, - segments: (response.segments || []).map((s, i: number) => ({ + segments: response.segments?.map((s, i: number) => ({ id: s.id ?? i, start: s.start, end: s.end, @@ -230,15 +230,15 @@ export class TranscriptionService implements ITranscriptionService { private createNoopAudioProcessor(): IAudioProcessor { return { - // eslint-disable-next-line @typescript-eslint/require-await + // eslint-disable-next-line @typescript-eslint/require-await -- interface expects a Promise-returning validator validate: async () => { throw new Error('Audio processor not configured'); }, - // eslint-disable-next-line @typescript-eslint/require-await + // eslint-disable-next-line @typescript-eslint/require-await -- interface expects a Promise-returning processor process: async () => { throw new Error('Audio processor not configured'); }, - // eslint-disable-next-line @typescript-eslint/require-await + // eslint-disable-next-line @typescript-eslint/require-await -- interface expects a Promise-returning metadata extractor extractMetadata: async () => { throw new Error('Audio processor not configured'); }, @@ -247,12 +247,12 @@ export class TranscriptionService implements ITranscriptionService { private createNoopWhisperService(): IWhisperService { return { - // eslint-disable-next-line @typescript-eslint/require-await + // eslint-disable-next-line @typescript-eslint/require-await -- interface expects a Promise-returning transcribe method transcribe: async () => { throw new Error('Whisper service not configured'); }, cancel: () => undefined, - // eslint-disable-next-line @typescript-eslint/require-await + // eslint-disable-next-line @typescript-eslint/require-await -- interface expects a Promise-returning validator validateApiKey: async () => false, }; } @@ -305,7 +305,7 @@ export class TranscriptionService implements ITranscriptionService { if (fallbackUrl) { try { return await this.fetchOnce(apiUrl, file, buffer, options); - } catch (error) { + } catch { return await this.fetchOnce(fallbackUrl, file, buffer, options); } } @@ -393,19 +393,76 @@ export class TranscriptionService implements ITranscriptionService { headers['Authorization'] = `Bearer ${apiKey}`; } - const responsePromise = requestUrl({ - url, - method: 'POST', - headers, - body: body as string | ArrayBuffer, - throw: false, - }).then((response) => ({ - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: String(response.status), - json: response.json as unknown, - text: response.text, - })); + const requestResult = + typeof requestUrl === 'function' + ? requestUrl({ + url, + method: 'POST', + headers, + body: body as string | ArrayBuffer, + throw: false, + }) + : undefined; + + const responsePromise = + requestResult && typeof (requestResult as PromiseLike).then === 'function' + ? ( + requestResult as PromiseLike<{ + status: number; + json?: unknown; + text?: string; + }> + ).then((response) => ({ + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: String(response.status), + json: response.json, + text: response.text, + })) + : (async () => { + if (typeof fetch !== 'function') { + throw new Error('Fetch API not available'); + } + const fetchResponse = await fetch(url, { + method: 'POST', + headers, + body: body as BodyInit, + signal, + }); + let json: unknown; + let text: string | undefined; + if (typeof fetchResponse.json === 'function') { + try { + json = await fetchResponse.json(); + } catch { + json = undefined; + } + } + if (json === undefined && typeof fetchResponse.text === 'function') { + try { + text = await fetchResponse.text(); + } catch { + text = undefined; + } + } + const status = + typeof fetchResponse.status === 'number' + ? fetchResponse.status + : fetchResponse.ok + ? 200 + : 500; + const ok = + typeof fetchResponse.ok === 'boolean' + ? fetchResponse.ok + : status >= 200 && status < 300; + return { + ok, + status, + statusText: fetchResponse.statusText ?? String(status), + json, + text, + }; + })(); const effectiveFetchPromise = this.isTestEnvironment() && delayForAbort diff --git a/src/domain/models/Settings.ts b/src/domain/models/Settings.ts index 18fc592..c9f1bc7 100644 --- a/src/domain/models/Settings.ts +++ b/src/domain/models/Settings.ts @@ -4,8 +4,7 @@ import type { DeepgramFeatures } from '../../types/DeepgramTypes'; export interface SpeechToTextSettings { apiKey: string; apiEndpoint?: string; // Added for custom API endpoint support - // eslint-disable-next-line @typescript-eslint/ban-types - model: WhisperModel | (string & {}); // Allow string for compatibility + model: WhisperModel | CustomWhisperModel; // Allow string for compatibility language: LanguageCode; autoInsert: boolean; insertPosition: InsertPosition; @@ -124,8 +123,18 @@ export interface SpeechToTextSettings { } export type WhisperModel = 'whisper-1'; -// eslint-disable-next-line @typescript-eslint/ban-types -export type LanguageCode = 'auto' | 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | (string & {}); +export type CustomWhisperModel = string & { readonly __customWhisperModel?: unique symbol }; +export type CustomLanguageCode = string & { readonly __customLanguageCode?: unique symbol }; +export type LanguageCode = + | 'auto' + | 'en' + | 'ko' + | 'ja' + | 'zh' + | 'es' + | 'fr' + | 'de' + | CustomLanguageCode; export type InsertPosition = 'cursor' | 'end' | 'beginning'; export type TimestampFormat = 'none' | 'inline' | 'sidebar'; diff --git a/src/infrastructure/api/SettingsAPI.ts b/src/infrastructure/api/SettingsAPI.ts index 4d8cda8..5a369d3 100644 --- a/src/infrastructure/api/SettingsAPI.ts +++ b/src/infrastructure/api/SettingsAPI.ts @@ -226,10 +226,10 @@ export class SettingsAPI implements ISettingsAPI { const encoder = new TextEncoder(); const data = encoder.encode(json); const compressed = await this.compress(data); - return new Blob([compressed as any], { type: 'application/gzip' }); + return new Blob([compressed], { type: 'application/gzip' }); } - return new Blob([json as any], { type: 'application/json' }); + return new Blob([json], { type: 'application/json' }); } /** @@ -281,7 +281,8 @@ export class SettingsAPI implements ISettingsAPI { this.settings = importedSettings as SettingsSchema; } else { // 기본: 안전한 병합 (API 키 제외) - const { api: _api, ...safeSettings } = importedSettings; + const safeSettings = { ...importedSettings }; + delete safeSettings.api; Object.assign(this.settings, safeSettings); } diff --git a/src/infrastructure/api/SettingsMigrator.ts b/src/infrastructure/api/SettingsMigrator.ts index 0932ae3..fe28d1a 100644 --- a/src/infrastructure/api/SettingsMigrator.ts +++ b/src/infrastructure/api/SettingsMigrator.ts @@ -4,8 +4,41 @@ import type { App } from 'obsidian'; import type { SettingsSchema, LanguageCode } from '../../types/phase3-api'; +import { isPlainRecord } from '../../types/guards'; type Migration = (settings: SettingsSchema) => Promise; +type BackupEntry = { key: string; timestamp: number }; +type SettingsBackup = { + timestamp: string; + version: string; + settings: SettingsSchema; +}; + +const isBackupEntry = (value: unknown): value is BackupEntry => + isPlainRecord(value) && typeof value.key === 'string' && typeof value.timestamp === 'number'; + +const isSettingsBackup = (value: unknown): value is SettingsBackup => + isPlainRecord(value) && + typeof value.timestamp === 'string' && + typeof value.version === 'string' && + isPlainRecord(value.settings); + +const parseJson = (value: string): unknown => JSON.parse(value) as unknown; + +const parseBackupList = (value: string | null): BackupEntry[] => { + if (!value) return []; + try { + const parsed = parseJson(value); + return Array.isArray(parsed) ? parsed.filter(isBackupEntry) : []; + } catch { + return []; + } +}; + +const cloneSettings = (settings: SettingsSchema): SettingsSchema => { + const cloned = JSON.parse(JSON.stringify(settings)) as unknown; + return isPlainRecord(cloned) ? (cloned as SettingsSchema) : settings; +}; /** * 설정 마이그레이터 @@ -310,7 +343,6 @@ export class SettingsMigrator { // 2.2.0 -> 3.0.0 (메이저 업데이트) this.migrations.set('2.2.0->3.0.0', (settings) => { // API 키 분리 및 암호화 준비 - const apiKey = settings.api?.apiKey; const legacyGeneralLanguage = settings.general?.language ?? settings.language; const legacyAudioLanguage = getString(settings.audio?.language ?? settings.language); const newSettings: SettingsSchema = { @@ -386,7 +418,7 @@ export class SettingsMigrator { const backup = { timestamp: new Date(timestamp).toISOString(), version: settings.version || 'unknown', - settings: JSON.parse(JSON.stringify(settings)), // Deep clone + settings: cloneSettings(settings), // Deep clone }; const key = `settings_backup_${timestamp}`; @@ -405,13 +437,16 @@ export class SettingsMigrator { * 백업 복원 */ restoreBackup(backupKey: string): Promise { - const backupData = this.app.loadLocalStorage(backupKey); - if (!backupData) { + const backupDataRaw = this.app.loadLocalStorage(backupKey) as unknown; + if (typeof backupDataRaw !== 'string' || backupDataRaw.length === 0) { throw new Error('Backup not found'); } - const backup = JSON.parse(backupData); - return Promise.resolve(backup.settings); + const parsed = parseJson(backupDataRaw); + if (!isSettingsBackup(parsed)) { + throw new Error('Invalid backup data'); + } + return Promise.resolve(parsed.settings); } /** @@ -421,16 +456,9 @@ export class SettingsMigrator { */ private cleanupOldBackups(): void { const backupListKey = 'settings_backup_list'; - const backupListData = this.app.loadLocalStorage(backupListKey); - let backupKeys: Array<{ key: string; timestamp: number }> = []; - - if (backupListData) { - try { - backupKeys = JSON.parse(backupListData); - } catch { - backupKeys = []; - } - } + const backupListDataRaw = this.app.loadLocalStorage(backupListKey) as unknown; + const backupListData = typeof backupListDataRaw === 'string' ? backupListDataRaw : null; + let backupKeys = parseBackupList(backupListData); // 현재 백업 키 추가 (이미 createBackup에서 저장된 경우) const latestBackupKey = `settings_backup_${Date.now()}`; @@ -460,16 +488,9 @@ export class SettingsMigrator { */ addBackupToList(key: string, timestamp: number): void { const backupListKey = 'settings_backup_list'; - const backupListData = this.app.loadLocalStorage(backupListKey); - let backupKeys: Array<{ key: string; timestamp: number }> = []; - - if (backupListData) { - try { - backupKeys = JSON.parse(backupListData); - } catch { - backupKeys = []; - } - } + const backupListDataRaw = this.app.loadLocalStorage(backupListKey) as unknown; + const backupListData = typeof backupListDataRaw === 'string' ? backupListDataRaw : null; + const backupKeys = parseBackupList(backupListData); backupKeys.push({ key, timestamp }); this.app.saveLocalStorage(backupListKey, JSON.stringify(backupKeys)); diff --git a/src/infrastructure/api/SettingsValidator.ts b/src/infrastructure/api/SettingsValidator.ts index 3754ffa..855cced 100644 --- a/src/infrastructure/api/SettingsValidator.ts +++ b/src/infrastructure/api/SettingsValidator.ts @@ -282,9 +282,13 @@ export class SettingsValidator { typeof audio.sampleRate !== 'number' || !validRates.includes(audio.sampleRate) ) { + const sampleRateLabel = + typeof audio.sampleRate === 'number' + ? audio.sampleRate + : JSON.stringify(audio.sampleRate) ?? String(audio.sampleRate); errors.push({ field: 'audio.sampleRate', - message: `Invalid sample rate: ${audio.sampleRate}`, + message: `Invalid sample rate: ${sampleRateLabel}`, code: 'INVALID_SAMPLE_RATE', }); } diff --git a/src/infrastructure/api/TranscriberFactoryRefactored.ts b/src/infrastructure/api/TranscriberFactoryRefactored.ts index 6bb9cff..30ffe25 100644 --- a/src/infrastructure/api/TranscriberFactoryRefactored.ts +++ b/src/infrastructure/api/TranscriberFactoryRefactored.ts @@ -825,9 +825,7 @@ export class TranscriberFactory extends TranscriberFactoryRefactored { return undefined; } - private validateProviderAvailability( - provider?: TranscriptionProvider | 'auto' | undefined - ): void { + private validateProviderAvailability(provider?: TranscriptionProvider | 'auto'): void { if (provider === 'deepgram' && !this.config.deepgram?.enabled) { throw new Error('Deepgram API key is missing'); } diff --git a/src/infrastructure/api/WhisperService.ts b/src/infrastructure/api/WhisperService.ts index 70d3e89..ca132d3 100644 --- a/src/infrastructure/api/WhisperService.ts +++ b/src/infrastructure/api/WhisperService.ts @@ -11,7 +11,7 @@ export class WhisperAPIError extends Error { message: string, public readonly code: string, public readonly status?: number, - public readonly isRetryable: boolean = false + public readonly isRetryable = false ) { super(message); this.name = 'WhisperAPIError'; @@ -405,7 +405,7 @@ export class WhisperService implements IWhisperService { }) ); - const response = await requestUrl(requestParams); + const response = await this.executeRequest(requestParams, formData); const processingTime = Date.now() - startTime; this.logger.info(`Transcription completed in ${processingTime}ms`, { @@ -486,6 +486,90 @@ export class WhisperService implements IWhisperService { return requestParams; } + private async executeRequest( + requestParams: RequestUrlParam & { timeout?: number }, + formData: FormData + ): Promise<{ + status: number; + headers?: Record; + json?: unknown; + text?: string; + }> { + const requestResult = + typeof requestUrl === 'function' ? requestUrl(requestParams) : undefined; + + if (requestResult && typeof (requestResult as PromiseLike).then === 'function') { + const response = await requestResult; + return { + status: response.status, + headers: response.headers, + json: response.json as unknown, + text: response.text, + }; + } + + if (typeof fetch !== 'function') { + throw new Error('Fetch API not available'); + } + + const controller = this.abortController; + const timeoutMs = + typeof requestParams.timeout === 'number' ? requestParams.timeout : this.timeoutMs; + const timeoutId = + timeoutMs > 0 && controller + ? window.setTimeout(() => controller.abort(), timeoutMs) + : undefined; + + try { + const fetchResponse = await fetch(requestParams.url, { + method: requestParams.method, + headers: requestParams.headers as HeadersInit, + body: (requestParams.body as BodyInit) ?? (formData as BodyInit), + signal: controller?.signal, + }); + + let json: unknown; + let text: string | undefined; + if (typeof fetchResponse.json === 'function') { + try { + json = await fetchResponse.json(); + } catch { + json = undefined; + } + } + if (json === undefined && typeof fetchResponse.text === 'function') { + try { + text = await fetchResponse.text(); + } catch { + text = undefined; + } + } + + const headers: Record = {}; + if (fetchResponse.headers) { + fetchResponse.headers.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } + + return { + status: + typeof fetchResponse.status === 'number' + ? fetchResponse.status + : fetchResponse.ok + ? 200 + : 500, + headers, + json, + text, + }; + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } + private parseResponse(json: unknown, processingTime: number): WhisperResponse { if (json === undefined || json === null) { const duration = Math.max(processingTime / 1000, 0.001); @@ -588,7 +672,7 @@ export class WhisperService implements IWhisperService { const result = await request(); resolve(result); } catch (error) { - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); } }; diff --git a/src/infrastructure/api/providers/ITranscriber.ts b/src/infrastructure/api/providers/ITranscriber.ts index 23c58fe..5b114da 100644 --- a/src/infrastructure/api/providers/ITranscriber.ts +++ b/src/infrastructure/api/providers/ITranscriber.ts @@ -79,6 +79,9 @@ export interface TranscriptionResponse { chunksProcessed?: number; chunksSuccessful?: number; isPartial?: boolean; + speakerCount?: number; + diarizationEnabled?: boolean; + diarizationStats?: unknown; }; } @@ -215,7 +218,7 @@ export class TranscriptionError extends Error { message: string, public readonly code: string, public readonly provider: TranscriptionProvider, - public readonly isRetryable: boolean = false, + public readonly isRetryable = false, public readonly statusCode?: number ) { super(message); diff --git a/src/infrastructure/api/providers/common/RateLimiter.ts b/src/infrastructure/api/providers/common/RateLimiter.ts index c23c563..4fcfd9e 100644 --- a/src/infrastructure/api/providers/common/RateLimiter.ts +++ b/src/infrastructure/api/providers/common/RateLimiter.ts @@ -65,7 +65,7 @@ export class RateLimiter { ); resolve(); } catch (error) { - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); } }); } diff --git a/src/infrastructure/api/providers/common/RetryStrategy.ts b/src/infrastructure/api/providers/common/RetryStrategy.ts index cd43002..1d1aa33 100644 --- a/src/infrastructure/api/providers/common/RetryStrategy.ts +++ b/src/infrastructure/api/providers/common/RetryStrategy.ts @@ -107,7 +107,7 @@ export class RetryHandler { /** * Default retry condition */ - private defaultRetryCondition(error: unknown): boolean { + private defaultRetryCondition(this: void, error: unknown): boolean { // Retry on network errors if (error instanceof Error && error.message?.toLowerCase().includes('network')) { return true; @@ -120,8 +120,8 @@ export class RetryHandler { // Retry on specific HTTP status codes const statusCode = - typeof error === 'object' && error !== null - ? Reflect.get(error, 'statusCode') + typeof error === 'object' && error !== null && 'statusCode' in error + ? (error as { statusCode?: unknown }).statusCode : undefined; if (typeof statusCode === 'number') { const retryableCodes = [408, 429, 500, 502, 503, 504]; @@ -130,8 +130,8 @@ export class RetryHandler { // Don't retry on explicit non-retryable errors const isRetryable = - typeof error === 'object' && error !== null - ? Reflect.get(error, 'isRetryable') + typeof error === 'object' && error !== null && 'isRetryable' in error + ? (error as { isRetryable?: unknown }).isRetryable : undefined; if (isRetryable === false) { return false; diff --git a/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts b/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts index afba532..df2878a 100644 --- a/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts +++ b/src/infrastructure/api/providers/deepgram/DeepgramAdapter.ts @@ -1,4 +1,5 @@ import type { ILogger, ISettingsManager } from '../../../../types'; +import { isPlainRecord } from '../../../../types/guards'; import type { TranscriptionSettings as SettingsStoreTranscriptionSettings } from '../../../../types/DeepgramTypes'; import { ITranscriber, @@ -426,10 +427,17 @@ export class DeepgramAdapter implements ITranscriber { } // 설정에서 사용자 화자 분리 설정 가져오기 - let userDiarizationConfig = null; + let userDiarizationConfig: Partial | null = null; if (this.settingsManager) { - const transcriptionSettings = this.settingsManager.get('transcription') as any; - userDiarizationConfig = transcriptionSettings?.deepgram?.diarizationConfig; + const rawSettings = this.settingsManager.get('transcription'); + const transcriptionSettings = isPlainRecord(rawSettings) + ? (rawSettings as SettingsStoreTranscriptionSettings) + : undefined; + const deepgramSettings = transcriptionSettings?.deepgram; + const diarizationConfig = deepgramSettings?.diarizationConfig; + if (isPlainRecord(diarizationConfig)) { + userDiarizationConfig = diarizationConfig as Partial; + } } // 기본 설정을 베이스로 사용자 설정 덮어쓰기 diff --git a/src/infrastructure/api/providers/deepgram/DeepgramService.ts b/src/infrastructure/api/providers/deepgram/DeepgramService.ts index 87d508f..8f3eb87 100644 --- a/src/infrastructure/api/providers/deepgram/DeepgramService.ts +++ b/src/infrastructure/api/providers/deepgram/DeepgramService.ts @@ -1,5 +1,6 @@ import { requestUrl, RequestUrlParam } from 'obsidian'; import type { ILogger } from '../../../../types'; +import { isPlainRecord } from '../../../../types/guards'; import { DeepgramSpecificOptions, TranscriptionResponse, @@ -215,6 +216,10 @@ interface DeepgramAPIResponse { }; } +type DeepgramWord = NonNullable< + DeepgramAPIResponse['results']['channels'][number]['alternatives'][number]['words'] +>[number]; + // Rate Limiter 구현 class RateLimiter { private queue: Array<() => void> = []; @@ -345,13 +350,13 @@ class ExponentialBackoffRetry { constructor(private logger: ILogger) {} async execute(operation: () => Promise): Promise { - let lastError: Error; + let lastError = new Error('Unknown error'); for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { return await operation(); } catch (error) { - lastError = error as Error; + lastError = error instanceof Error ? error : new Error(String(error)); if (!this.isRetryable(error)) { throw error; @@ -370,19 +375,22 @@ class ExponentialBackoffRetry { } throw new TranscriptionError( - `Deepgram operation failed after ${this.maxRetries} attempts: ${lastError!.message}`, + `Deepgram operation failed after ${this.maxRetries} attempts: ${lastError.message}`, 'MAX_RETRIES_EXCEEDED', 'deepgram', false ); } - private isRetryable(error: any): boolean { + private isRetryable(error: unknown): boolean { if (error instanceof TranscriptionError) { return error.isRetryable; } // 네트워크 에러는 재시도 가능 - return error.message?.toLowerCase().includes('network'); + if (error instanceof Error) { + return error.message.toLowerCase().includes('network'); + } + return false; } private calculateDelay(attempt: number): number { @@ -654,7 +662,7 @@ export class DeepgramService { return jsonResponse; } else { - throw await this.handleAPIError(response); + throw this.handleAPIError(response); } } catch (error) { if ((error as Error).name === 'AbortError') { @@ -713,7 +721,7 @@ export class DeepgramService { } // Utterance segmentation improves speaker grouping with diarization - if ((options as any)?.utterances) { + if (options?.utterances) { params.append('utterances', 'true'); } @@ -761,10 +769,17 @@ export class DeepgramService { }; } - private handleAPIError(response: any): never { - const errorBody = response.json; - const rawText = response.text; - const messageFromBody = errorBody?.message || errorBody?.error; + private handleAPIError(response: { + status: number; + json?: unknown; + text?: unknown; + headers?: Record; + }): never { + const errorBody = isPlainRecord(response.json) ? response.json : undefined; + const rawText = typeof response.text === 'string' ? response.text : undefined; + const messageFromBody = + (typeof errorBody?.message === 'string' ? errorBody.message : undefined) ?? + (typeof errorBody?.error === 'string' ? errorBody.error : undefined); const errorMessage = messageFromBody || (typeof rawText === 'string' && rawText.length > 0 ? rawText : 'Unknown error'); @@ -1109,7 +1124,7 @@ export class DeepgramService { diarizationEnabled: true, diarizationStats, }), - } as any, // 메타데이터 타입 확장 필요 + }, }; this.logger.info('=== DeepgramService.parseResponse COMPLETE ===', { @@ -1123,7 +1138,7 @@ export class DeepgramService { return result; } - private createSegmentsFromWords(words: any[]): TranscriptionSegment[] { + private createSegmentsFromWords(words: DeepgramWord[]): TranscriptionSegment[] { const segments: TranscriptionSegment[] = []; const wordsPerSegment = 10; // 10단어씩 세그먼트 생성 @@ -1134,11 +1149,14 @@ export class DeepgramService { id: Math.floor(i / wordsPerSegment), start: segmentWords[0].start, end: segmentWords[segmentWords.length - 1].end, - text: segmentWords.map((w: any) => w.word).join(' '), + text: segmentWords.map((w) => w.word).join(' '), confidence: - segmentWords.reduce((acc: number, w: any) => acc + w.confidence, 0) / + segmentWords.reduce((acc, w) => acc + w.confidence, 0) / segmentWords.length, - speaker: segmentWords[0].speaker, + speaker: + segmentWords[0].speaker !== undefined + ? String(segmentWords[0].speaker) + : undefined, }); } } diff --git a/src/infrastructure/api/providers/deepgram/DeepgramServiceRefactored.ts b/src/infrastructure/api/providers/deepgram/DeepgramServiceRefactored.ts index 199c20c..88f32e2 100644 --- a/src/infrastructure/api/providers/deepgram/DeepgramServiceRefactored.ts +++ b/src/infrastructure/api/providers/deepgram/DeepgramServiceRefactored.ts @@ -1,5 +1,6 @@ import { requestUrl, RequestUrlParam } from 'obsidian'; import type { ILogger } from '../../../../types'; +import { isPlainRecord } from '../../../../types/guards'; import { DeepgramSpecificOptions, TranscriptionResponse, @@ -225,7 +226,7 @@ export class DeepgramServiceRefactored { return response.json as DeepgramAPIResponse; } - throw await this.handleApiError(response); + throw this.handleApiError(response); } catch (error) { if ((error as Error).name === 'AbortError') { throw new TranscriptionError( @@ -338,11 +339,14 @@ export class DeepgramServiceRefactored { */ private handleApiError(response: { status: number; - json: any; + json?: unknown; headers?: Record; }): never { - const errorBody = response.json; - const errorMessage = errorBody?.message ?? errorBody?.error ?? 'Unknown error'; + const errorBody = isPlainRecord(response.json) ? response.json : undefined; + const errorMessage = + (typeof errorBody?.message === 'string' ? errorBody.message : undefined) ?? + (typeof errorBody?.error === 'string' ? errorBody.error : undefined) ?? + 'Unknown error'; this.config.logger.error(`Deepgram API Error: ${response.status}`, undefined, { status: response.status, @@ -444,13 +448,17 @@ export class DeepgramServiceRefactored { /** * Check if error is retryable */ - private isRetryableError = (error: any): boolean => { + private isRetryableError = (error: unknown): boolean => { if (error instanceof TranscriptionError) { return error.isRetryable; } + if (!(error instanceof Error)) { + return false; + } + const retryableMessages = ['network', 'timeout', 'econnreset', 'socket']; - const message = error.message?.toLowerCase() ?? ''; + const message = error.message.toLowerCase(); return retryableMessages.some((msg) => message.includes(msg)); }; diff --git a/src/infrastructure/api/providers/deepgram/ModelMigrationService.ts b/src/infrastructure/api/providers/deepgram/ModelMigrationService.ts index ada0033..d8cead4 100644 --- a/src/infrastructure/api/providers/deepgram/ModelMigrationService.ts +++ b/src/infrastructure/api/providers/deepgram/ModelMigrationService.ts @@ -9,6 +9,7 @@ */ import type { ILogger, ISettingsManager } from '../../../../types'; +import { isPlainRecord } from '../../../../types/guards'; import { ModelCapabilityManager, type CompatibilityCheck } from './ModelCapabilityManager'; // 마이그레이션 타입 정의 @@ -23,7 +24,7 @@ export interface MigrationRule { export interface MigrationCondition { type: 'feature_compatible' | 'user_consent' | 'cost_threshold' | 'custom'; - parameters: Record; + parameters: Record; required: boolean; } @@ -42,7 +43,7 @@ export interface MigrationStep { id: string; description: string; action: 'backup_settings' | 'update_model' | 'test_compatibility' | 'notify_user' | 'rollback'; - parameters: Record; + parameters: Record; critical: boolean; rollbackAction?: string; } @@ -72,6 +73,11 @@ export interface UserMigrationPreferences { testMode: boolean; // dry run without actual changes } +type SettingsBackup = { + timestamp: string; + settings: Record; +}; + /** * 기본 마이그레이션 규칙 */ @@ -184,7 +190,7 @@ export class ModelMigrationService { /** * 사용자의 현재 모델에 대한 마이그레이션 제안을 확인합니다 */ - async checkForMigrationOpportunities(currentModel?: string): Promise { + checkForMigrationOpportunities(currentModel?: string): MigrationPlan[] { const model = currentModel || this.getCurrentModel(); if (!model) { this.logger.warn('No current model found, cannot check migration opportunities'); @@ -197,9 +203,9 @@ export class ModelMigrationService { const applicableRules = this.migrationRules.filter((rule) => rule.fromModel === model); for (const rule of applicableRules) { - const compatibility = await this.checkMigrationCompatibility(rule); + const compatibility = this.checkMigrationCompatibility(rule); if (compatibility.compatible) { - const plan = await this.createMigrationPlan(rule); + const plan = this.createMigrationPlan(rule); opportunities.push(plan); } } @@ -248,11 +254,11 @@ export class ModelMigrationService { try { // 테스트 모드 확인 if (prefs.testMode) { - return await this.simulateMigration(plan, result); + return this.simulateMigration(plan, result); } // 사용자 승인 확인 - if (!(await this.getUserApproval(plan, prefs))) { + if (!this.getUserApproval(plan, prefs)) { result.errors.push('User approval required but not granted'); return result; } @@ -311,7 +317,7 @@ export class ModelMigrationService { const currentModel = this.getCurrentModel(); if (!currentModel) return false; - const opportunities = await this.checkForMigrationOpportunities(currentModel); + const opportunities = this.checkForMigrationOpportunities(currentModel); const autoMigrations = opportunities.filter((plan) => this.migrationRules.find( (rule) => @@ -355,7 +361,7 @@ export class ModelMigrationService { try { const backup = this.getBackup(migrationId); - if (backup) { + if (this.isSettingsBackup(backup)) { await this.restoreSettings(backup); this.logger.info('Migration rollback completed successfully'); return true; @@ -393,8 +399,17 @@ export class ModelMigrationService { */ private getCurrentModel(): string | null { - const transcriptionSettings = this.settingsManager.get('transcription') as any; - return transcriptionSettings?.deepgram?.model || transcriptionSettings?.model || null; + const rawSettings = this.settingsManager.get('transcription'); + const transcriptionSettings = isPlainRecord(rawSettings) ? rawSettings : undefined; + const deepgramSettings = isPlainRecord(transcriptionSettings?.deepgram) + ? transcriptionSettings?.deepgram + : undefined; + const deepgramModel = deepgramSettings?.model; + if (typeof deepgramModel === 'string') { + return deepgramModel; + } + const fallbackModel = transcriptionSettings?.model; + return typeof fallbackModel === 'string' ? fallbackModel : null; } private getUserPreferences( @@ -409,11 +424,12 @@ export class ModelMigrationService { testMode: false, }; - const saved = this.settingsManager.get('migrationPreferences') || {}; - return { ...defaults, ...saved, ...overrides }; + const saved = this.settingsManager.get('migrationPreferences'); + const savedPrefs = isPlainRecord(saved) ? (saved as Partial) : {}; + return { ...defaults, ...savedPrefs, ...overrides }; } - private async checkMigrationCompatibility(rule: MigrationRule): Promise { + private checkMigrationCompatibility(rule: MigrationRule): CompatibilityCheck { const toCapabilities = this.capabilityManager.getModelCapabilities(rule.toModel); const fromCapabilities = this.capabilityManager.getModelCapabilities(rule.fromModel); @@ -429,7 +445,7 @@ export class ModelMigrationService { // 조건 검사 for (const condition of rule.conditions) { - if (condition.required && !(await this.evaluateCondition(condition))) { + if (condition.required && !this.evaluateCondition(condition)) { return { compatible: false, missingFeatures: [], @@ -546,19 +562,53 @@ export class ModelMigrationService { switch (step.action) { case 'backup_settings': - await this.backupCurrentSettings(step.parameters.settings); + { + const settings = step.parameters.settings; + if (!Array.isArray(settings)) { + throw new Error('Invalid backup settings list'); + } + const keys = settings.filter( + (item): item is string => typeof item === 'string' + ); + await this.backupCurrentSettings(keys); + } break; case 'update_model': - await this.updateModel(step.parameters.newModel); + { + const newModel = step.parameters.newModel; + if (typeof newModel !== 'string') { + throw new Error('Invalid target model'); + } + await this.updateModel(newModel); + } break; case 'test_compatibility': - await this.testModelCompatibility(step.parameters.targetModel); + { + const targetModel = step.parameters.targetModel; + if (typeof targetModel !== 'string') { + throw new Error('Invalid target model'); + } + this.testModelCompatibility(targetModel); + } break; case 'notify_user': - this.notifyUser(step.parameters.message, step.parameters.type); + { + const message = + typeof step.parameters.message === 'string' + ? step.parameters.message + : 'Migration update'; + const type = + step.parameters.type === 'info' || + step.parameters.type === 'success' || + step.parameters.type === 'warning' || + step.parameters.type === 'error' + ? step.parameters.type + : 'info'; + this.notifyUser(message, type); + } break; case 'rollback': @@ -616,7 +666,7 @@ export class ModelMigrationService { } private async backupCurrentSettings(settingsToBackup: string[]): Promise { - const backup: Record = { + const backup: SettingsBackup = { timestamp: new Date().toISOString(), settings: {}, }; @@ -635,10 +685,16 @@ export class ModelMigrationService { } private async updateModel(newModel: string): Promise { - const transcriptionSettings = (this.settingsManager.get('transcription') as any) || {}; + const rawSettings = this.settingsManager.get('transcription'); + const transcriptionSettings = isPlainRecord(rawSettings) ? { ...rawSettings } : {}; - if (transcriptionSettings.deepgram) { - transcriptionSettings.deepgram.model = newModel; + const deepgramSettings = isPlainRecord(transcriptionSettings.deepgram) + ? { ...transcriptionSettings.deepgram } + : undefined; + + if (deepgramSettings) { + deepgramSettings.model = newModel; + transcriptionSettings.deepgram = deepgramSettings; } else { transcriptionSettings.model = newModel; } @@ -665,16 +721,26 @@ export class ModelMigrationService { this.logger.info(`User notification (${type}): ${message}`); } - private getBackup(backupId: string): any { + private getBackup(backupId: string): unknown { return this.settingsManager.get(`backup.${backupId}`); } - private getLatestBackup(): any { + private isSettingsBackup(value: unknown): value is SettingsBackup { + if (!isPlainRecord(value)) { + return false; + } + if (typeof value.timestamp !== 'string') { + return false; + } + return isPlainRecord(value.settings); + } + + private getLatestBackup(): SettingsBackup | null { // 최신 백업 찾기 로직 (ISettingsManager에 getAll이 없으므로 대안 구현) try { const backupPattern = 'backup.migration_backup_'; const currentTime = Date.now(); - let latestBackup: any = null; + let latestBackup: SettingsBackup | null = null; let latestTimestamp = 0; // 최근 24시간 내의 백업을 체크 (타임스탬프 기반) @@ -682,9 +748,15 @@ export class ModelMigrationService { const timestamp = currentTime - i * 60 * 60 * 1000; // i시간 전 const backupId = `${backupPattern}${timestamp}`; const backup = this.getBackup(backupId); - - if (backup && timestamp > latestTimestamp) { - latestBackup = backup; + if ( + isPlainRecord(backup) && + isPlainRecord(backup.settings) && + timestamp > latestTimestamp + ) { + latestBackup = { + timestamp: typeof backup.timestamp === 'string' ? backup.timestamp : '', + settings: backup.settings, + }; latestTimestamp = timestamp; } } @@ -696,7 +768,7 @@ export class ModelMigrationService { } } - private async restoreSettings(backup: any): Promise { + private async restoreSettings(backup: SettingsBackup): Promise { if (!backup || !backup.settings) { throw new Error('Invalid backup data'); } diff --git a/src/infrastructure/api/providers/deepgram/audioUtils.ts b/src/infrastructure/api/providers/deepgram/audioUtils.ts index 897e086..a009683 100644 --- a/src/infrastructure/api/providers/deepgram/audioUtils.ts +++ b/src/infrastructure/api/providers/deepgram/audioUtils.ts @@ -89,7 +89,10 @@ export class AudioValidator { /** * 기본 검증 수행 */ - private performBasicValidation(metadata: any, errors: string[]): void { + private performBasicValidation( + metadata: AudioValidationResult['metadata'], + errors: string[] + ): void { if (metadata.isEmpty) { errors.push(ERROR_MESSAGES.AUDIO_VALIDATION.EMPTY); } @@ -106,7 +109,10 @@ export class AudioValidator { /** * 크기 관련 경고 수행 */ - private performSizeWarnings(metadata: any, warnings: string[]): void { + private performSizeWarnings( + metadata: AudioValidationResult['metadata'], + warnings: string[] + ): void { if (metadata.size < AUDIO_VALIDATION.SIZE_WARNING_THRESHOLD) { warnings.push(ERROR_MESSAGES.AUDIO_VALIDATION.VERY_SMALL_WARNING); } diff --git a/src/infrastructure/api/providers/deepgram/errorHandler.ts b/src/infrastructure/api/providers/deepgram/errorHandler.ts index ca25fa9..e2b9307 100644 --- a/src/infrastructure/api/providers/deepgram/errorHandler.ts +++ b/src/infrastructure/api/providers/deepgram/errorHandler.ts @@ -23,6 +23,9 @@ interface ErrorStrategy { technicalDetails: string; } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + /** * Graceful Degradation 옵션 */ @@ -130,9 +133,16 @@ export class DeepgramErrorHandler { /** * API 응답 에러를 적절한 예외로 변환 */ - handleAPIError(response: any): never { - const errorBody = response.json; - const errorMessage = errorBody?.message || errorBody?.error || 'Unknown error'; + handleAPIError(response: { + status: number; + json?: unknown; + headers?: Record; + }): never { + const errorBody = isRecord(response.json) ? response.json : undefined; + const errorMessage = + (typeof errorBody?.message === 'string' ? errorBody.message : undefined) ?? + (typeof errorBody?.error === 'string' ? errorBody.error : undefined) ?? + 'Unknown error'; const strategy = this.errorStrategies.get(response.status); this.logger.error(`Deepgram API Error: ${response.status} - ${errorMessage}`, undefined, { @@ -254,7 +264,7 @@ export class DeepgramErrorHandler { /** * TranscriptionError 세부 분석 */ - private analyzeTranscriptionError(error: TranscriptionError, _context: any): ErrorAnalysis { + private analyzeTranscriptionError(error: TranscriptionError, _context: unknown): ErrorAnalysis { switch (error.code) { case 'SERVER_TIMEOUT': return { @@ -323,10 +333,10 @@ export class DeepgramErrorHandler { * Graceful Degradation 전략 적용 */ applyDegradation( - originalOptions: any, + originalOptions: Record, degradationOptions: DegradationOptions, error: Error - ): Promise { + ): Promise> { const degradedOptions = { ...originalOptions }; this.logger.info('Applying graceful degradation', { @@ -435,8 +445,8 @@ export class DeepgramErrorHandler { async executeRecoveryStrategy( error: Error, analysis: ErrorAnalysis, - retryFunction: () => Promise - ): Promise { + retryFunction: () => Promise + ): Promise { this.logger.info('Executing error recovery strategy', { error: error.message, category: analysis.category, diff --git a/src/infrastructure/api/providers/deepgram/reliabilityUtils.ts b/src/infrastructure/api/providers/deepgram/reliabilityUtils.ts index f634784..ab30144 100644 --- a/src/infrastructure/api/providers/deepgram/reliabilityUtils.ts +++ b/src/infrastructure/api/providers/deepgram/reliabilityUtils.ts @@ -184,13 +184,13 @@ export class ExponentialBackoffRetry { * 재시도 전략으로 작업 실행 */ async execute(operation: () => Promise): Promise { - let lastError: Error; + let lastError = new Error('Unknown error'); for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { return await operation(); } catch (error) { - lastError = error as Error; + lastError = error instanceof Error ? error : new Error(String(error)); if (!this.isRetryable(error)) { throw error; @@ -208,7 +208,7 @@ export class ExponentialBackoffRetry { throw new TranscriptionError( ERROR_MESSAGES.API.MAX_RETRIES.replace('{retries}', this.maxRetries.toString()) + - `: ${lastError!.message}`, + `: ${lastError.message}`, 'MAX_RETRIES_EXCEEDED', 'deepgram', false @@ -218,12 +218,15 @@ export class ExponentialBackoffRetry { /** * 재시도 가능한 에러인지 판단 */ - private isRetryable(error: any): boolean { + private isRetryable(error: unknown): boolean { if (error instanceof TranscriptionError) { return error.isRetryable; } // 네트워크 에러는 재시도 가능 - return error.message?.toLowerCase().includes('network'); + if (error instanceof Error) { + return error.message.toLowerCase().includes('network'); + } + return false; } /** diff --git a/src/infrastructure/api/providers/deepgram/types.ts b/src/infrastructure/api/providers/deepgram/types.ts index ada37e7..3a67653 100644 --- a/src/infrastructure/api/providers/deepgram/types.ts +++ b/src/infrastructure/api/providers/deepgram/types.ts @@ -189,6 +189,9 @@ export type RequiredKeys = T & Required>; export type OptionalKeys = Omit & Partial>; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + // Type guards export const isValidModelTier = (tier: string): tier is ModelTier => { return ['economy', 'basic', 'standard', 'premium', 'enterprise'].includes(tier); @@ -204,19 +207,20 @@ export const isValidPerformanceSpeed = (speed: string): speed is PerformanceSpee // === 런타임 타입 검증 헬퍼들 === export class TypeValidator { - static isDeepgramAPIResponse(obj: any): obj is DeepgramAPIResponse { + static isDeepgramAPIResponse(obj: unknown): obj is DeepgramAPIResponse { return ( - obj && - typeof obj === 'object' && - obj.metadata && - obj.results && + isRecord(obj) && + isRecord(obj.metadata) && + isRecord(obj.results) && Array.isArray(obj.results.channels) ); } - static hasValidWord(word: any): word is DeepgramWord { + static hasValidWord(word: unknown): word is DeepgramWord { + if (!isRecord(word)) { + return false; + } return ( - word && typeof word.word === 'string' && typeof word.start === 'number' && typeof word.end === 'number' && @@ -228,18 +232,22 @@ export class TypeValidator { ); } - static hasValidSpeakerInfo(word: any): boolean { - return word.speaker !== undefined && typeof word.speaker === 'number' && word.speaker >= 0; + static hasValidSpeakerInfo(word: unknown): boolean { + return ( + isRecord(word) && + word.speaker !== undefined && + typeof word.speaker === 'number' && + word.speaker >= 0 + ); } - static isValidDiarizationConfig(config: any): config is DiarizationConfigComplete { + static isValidDiarizationConfig(config: unknown): config is DiarizationConfigComplete { return ( - config && - typeof config === 'object' && + isRecord(config) && typeof config.enabled === 'boolean' && - config.speakerLabels && - config.merging && - config.output + isRecord(config.speakerLabels) && + isRecord(config.merging) && + isRecord(config.output) ); } } diff --git a/src/infrastructure/api/providers/factory/MetricsTracker.ts b/src/infrastructure/api/providers/factory/MetricsTracker.ts index 1cc7125..c3fd632 100644 --- a/src/infrastructure/api/providers/factory/MetricsTracker.ts +++ b/src/infrastructure/api/providers/factory/MetricsTracker.ts @@ -217,7 +217,7 @@ export class MetricsTracker { /** * Format metrics for logging */ - private formatMetrics(metric: ProviderMetrics): any { + private formatMetrics(metric: ProviderMetrics): Record { return { provider: metric.provider, requests: { diff --git a/src/infrastructure/security/Encryptor.ts b/src/infrastructure/security/Encryptor.ts index 4b391af..3ec421c 100644 --- a/src/infrastructure/security/Encryptor.ts +++ b/src/infrastructure/security/Encryptor.ts @@ -121,10 +121,10 @@ export class Encryptor implements IEncryptor { const encryptedBuffer = await crypto.subtle.encrypt( { name: this.algorithm, - iv: iv.buffer as ArrayBuffer, + iv: iv.buffer, }, key, - encodedText.buffer as ArrayBuffer + encodedText.buffer ); // Base64 인코딩 @@ -398,7 +398,7 @@ export class SettingsEncryptor { settings: Record ): Promise> { const sensitiveFields = ['apiKey', 'tokens', 'credentials']; - const encryptedSettings: Record = { ...settings }; + const encryptedSettings: Record = { ...settings }; for (const field of sensitiveFields) { if (settings[field]) { @@ -417,7 +417,7 @@ export class SettingsEncryptor { async decryptSensitiveSettings( encryptedSettings: Record ): Promise> { - const settings: Record = { ...encryptedSettings }; + const settings: Record = { ...encryptedSettings }; const sensitiveFields = ['apiKey', 'tokens', 'credentials']; for (const field of sensitiveFields) { diff --git a/src/infrastructure/storage/SettingsManager.ts b/src/infrastructure/storage/SettingsManager.ts index 4ee0bc3..689050e 100644 --- a/src/infrastructure/storage/SettingsManager.ts +++ b/src/infrastructure/storage/SettingsManager.ts @@ -1,5 +1,6 @@ import { Plugin } from 'obsidian'; import type { ISettingsManager, ILogger } from '../../types'; +import { isPlainRecord } from '../../types/guards'; // 설정 타입 정의 export interface PluginSettings { @@ -139,11 +140,14 @@ export class SettingsManager implements ISettingsManager { async load(): Promise { try { - const loadedData = await this.plugin.loadData(); + const loadedData = (await this.plugin.loadData()) as unknown; - if (loadedData) { + if (isPlainRecord(loadedData)) { // 기존 설정과 병합 - this.settings = { ...DEFAULT_SETTINGS, ...loadedData }; + this.settings = { + ...DEFAULT_SETTINGS, + ...(loadedData as Partial), + }; // 암호화된 API 키가 있으면 복호화 if (this.settings.encryptedApiKey && !this.settings.apiKey) { @@ -172,7 +176,8 @@ export class SettingsManager implements ISettingsManager { if (settings.apiKey) { settings.encryptedApiKey = this.encryption.encrypt(settings.apiKey); // 원본 API 키는 저장하지 않음 - const { apiKey: _, ...saveData } = settings; + const { apiKey: _apiKey, ...saveData } = settings; + void _apiKey; await this.plugin.saveData(saveData); } else { await this.plugin.saveData(settings); @@ -232,7 +237,8 @@ export class SettingsManager implements ISettingsManager { this.settings.encryptedApiKey = this.encryption.encrypt(apiKey); // 저장 시 API 키는 제외 - const { apiKey: _, ...saveData } = this.settings; + const { apiKey: _apiKey, ...saveData } = this.settings; + void _apiKey; await this.plugin.saveData(saveData); this.logger?.debug('API key encrypted and saved', { @@ -249,14 +255,18 @@ export class SettingsManager implements ISettingsManager { // 설정 내보내기 (민감한 정보 제외) exportSettings(): Partial { - const { apiKey: _, encryptedApiKey: __, ...exported } = this.settings; + const { apiKey: _apiKey, encryptedApiKey: _encryptedApiKey, ...exported } = this.settings; + void _apiKey; + void _encryptedApiKey; return exported; } // 설정 가져오기 async importSettings(imported: Partial): Promise { // API 키는 가져오지 않음 - const { apiKey: _, encryptedApiKey: __, ...safeImported } = imported; + const { apiKey: _apiKey, encryptedApiKey: _encryptedApiKey, ...safeImported } = imported; + void _apiKey; + void _encryptedApiKey; this.settings = { ...this.settings, ...safeImported }; await this.save(this.settings); diff --git a/src/patterns/Singleton.ts b/src/patterns/Singleton.ts index cc44d6f..fe31bda 100644 --- a/src/patterns/Singleton.ts +++ b/src/patterns/Singleton.ts @@ -65,21 +65,24 @@ export function createSingleton(factory: () => T): () => T { /** * 클래스 데코레이터를 사용한 Singleton 패턴 */ -/* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument */ -export function SingletonDecorator object>(constructor: T): T { - let instance: InstanceType; +export function SingletonDecorator< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require any[] params + T extends new (...args: any[]) => object +>(constructor: T): T { + let instance: InstanceType | undefined; return class extends constructor { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mixin constructors require any[] params constructor(...args: any[]) { if (instance) { return instance; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- args are passed through to base constructor super(...args); instance = this as InstanceType; } } as T; } -/* eslint-enable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument */ /** * 비동기 Singleton 팩토리 diff --git a/src/testing/TestingFramework.ts b/src/testing/TestingFramework.ts index bc1e4a3..05acc42 100644 --- a/src/testing/TestingFramework.ts +++ b/src/testing/TestingFramework.ts @@ -271,8 +271,11 @@ export class IntegrationTestUtils { * 플러그인 초기화 테스트 */ static async testPluginInitialization( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pluginClass: new (...args: any[]) => any, + pluginClass: new (...args: unknown[]) => { + onload: () => Promise; + isLoaded?: boolean; + app?: App; + }, options: { expectSuccess: boolean } = { expectSuccess: true } ): Promise { const env = new TestEnvironment(); @@ -295,12 +298,11 @@ export class IntegrationTestUtils { * UI 컴포넌트 테스트 */ static async testUIComponent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - componentClass: new (...args: any[]) => any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setupFn?: (component: any) => void - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): Promise { + componentClass: new (...args: unknown[]) => { + initialize: () => Promise; + }, + setupFn?: (component: { initialize: () => Promise }) => void + ): Promise<{ component: { initialize: () => Promise }; env: TestEnvironment }> { const env = new TestEnvironment(); await env.setup(); @@ -319,12 +321,9 @@ export class IntegrationTestUtils { * 서비스 테스트 */ static async testService( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - serviceClass: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dependencies: Record = {} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): Promise { + serviceToken: string | symbol | (new (...args: unknown[]) => unknown), + dependencies: Record = {} + ): Promise<{ service: unknown; env: TestEnvironment }> { const env = new TestEnvironment(); await env.setup(); @@ -333,8 +332,8 @@ export class IntegrationTestUtils { env.getContainer().registerInstance(key, value); }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const service = env.getContainer().resolve(serviceClass); + const token = serviceToken as unknown as string | symbol; + const service = env.getContainer().resolve(token); return { service, env }; } diff --git a/src/types/resources.ts b/src/types/resources.ts index d966852..de696e1 100644 --- a/src/types/resources.ts +++ b/src/types/resources.ts @@ -186,7 +186,7 @@ export class ResourcePool { async dispose(): Promise { const allResources = [...this.available, ...this.inUse]; - await Promise.all(allResources.map((r) => r.dispose())); + await Promise.all(allResources.map((r) => Promise.resolve(r.dispose()))); this.available = []; this.inUse.clear(); } diff --git a/src/ui/components/DragDropZone.ts b/src/ui/components/DragDropZone.ts index 1683ac8..e0e0db9 100644 --- a/src/ui/components/DragDropZone.ts +++ b/src/ui/components/DragDropZone.ts @@ -87,10 +87,10 @@ export class DragDropZone { if (!this.dropZone) return; // 드래그 이벤트 - this.dropZone.addEventListener('dragenter', this.handleDragEnter.bind(this)); - this.dropZone.addEventListener('dragleave', this.handleDragLeave.bind(this)); - this.dropZone.addEventListener('dragover', this.handleDragOver.bind(this)); - this.dropZone.addEventListener('drop', this.handleDrop.bind(this)); + this.dropZone.addEventListener('dragenter', this.handleDragEnter); + this.dropZone.addEventListener('dragleave', this.handleDragLeave); + this.dropZone.addEventListener('dragover', this.handleDragOver); + this.dropZone.addEventListener('drop', this.handleDrop); // 전체 문서에 대한 드래그 방지 document.addEventListener('dragover', this.preventDefaultDrag); @@ -103,10 +103,10 @@ export class DragDropZone { private detachEventListeners() { if (!this.dropZone) return; - this.dropZone.removeEventListener('dragenter', this.handleDragEnter.bind(this)); - this.dropZone.removeEventListener('dragleave', this.handleDragLeave.bind(this)); - this.dropZone.removeEventListener('dragover', this.handleDragOver.bind(this)); - this.dropZone.removeEventListener('drop', this.handleDrop.bind(this)); + this.dropZone.removeEventListener('dragenter', this.handleDragEnter); + this.dropZone.removeEventListener('dragleave', this.handleDragLeave); + this.dropZone.removeEventListener('dragover', this.handleDragOver); + this.dropZone.removeEventListener('drop', this.handleDrop); document.removeEventListener('dragover', this.preventDefaultDrag); document.removeEventListener('drop', this.preventDefaultDrag); @@ -115,7 +115,7 @@ export class DragDropZone { /** * 드래그 진입 처리 */ - private handleDragEnter(e: DragEvent) { + private readonly handleDragEnter = (e: DragEvent): void => { e.preventDefault(); e.stopPropagation(); @@ -127,12 +127,12 @@ export class DragDropZone { this.setDragging(true); } } - } + }; /** * 드래그 떠남 처리 */ - private handleDragLeave(e: DragEvent) { + private readonly handleDragLeave = (e: DragEvent): void => { e.preventDefault(); e.stopPropagation(); @@ -141,24 +141,24 @@ export class DragDropZone { if (this.dragCounter === 0) { this.setDragging(false); } - } + }; /** * 드래그 오버 처리 */ - private handleDragOver(e: DragEvent) { + private readonly handleDragOver = (e: DragEvent): void => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; } - } + }; /** * 드롭 처리 */ - private handleDrop(e: DragEvent) { + private readonly handleDrop = (e: DragEvent): void => { e.preventDefault(); e.stopPropagation(); @@ -169,14 +169,14 @@ export class DragDropZone { if (files.length > 0) { this.handleFiles(files); } - } + }; /** * 기본 드래그 동작 방지 */ - private preventDefaultDrag(e: Event) { + private readonly preventDefaultDrag = (e: Event): void => { e.preventDefault(); - } + }; /** * 드래그 상태 설정 diff --git a/src/ui/components/EventHandlers.ts b/src/ui/components/EventHandlers.ts index 8dfecce..af098a4 100644 --- a/src/ui/components/EventHandlers.ts +++ b/src/ui/components/EventHandlers.ts @@ -5,8 +5,10 @@ * - 검증 실패 이벤트 * - 진행 상태 이벤트 */ +type EventCallback = (data?: unknown) => void; + export class EventHandlers { - private eventListeners: Map> = new Map(); + private eventListeners: Map> = new Map(); private eventHistory: EventHistoryEntry[] = []; private readonly MAX_HISTORY_SIZE = 100; @@ -44,12 +46,15 @@ export class EventHandlers { /** * 이벤트 리스너 등록 */ - on(eventType: string, callback: Function): () => void { + on(eventType: string, callback: EventCallback): () => void { if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, new Set()); } - this.eventListeners.get(eventType)!.add(callback); + const listeners = this.eventListeners.get(eventType); + if (listeners) { + listeners.add(callback); + } // 언등록 함수 반환 return () => { @@ -60,7 +65,7 @@ export class EventHandlers { /** * 이벤트 리스너 제거 */ - off(eventType: string, callback: Function) { + off(eventType: string, callback: EventCallback) { const listeners = this.eventListeners.get(eventType); if (listeners) { listeners.delete(callback); @@ -70,9 +75,9 @@ export class EventHandlers { /** * 한 번만 실행되는 이벤트 리스너 */ - once(eventType: string, callback: Function): () => void { - const wrapper = (...args: any[]) => { - callback(...args); + once(eventType: string, callback: EventCallback): () => void { + const wrapper: EventCallback = (data) => { + callback(data); this.off(eventType, wrapper); }; @@ -82,7 +87,7 @@ export class EventHandlers { /** * 이벤트 발생 */ - emit(eventType: string, data?: any) { + emit(eventType: string, data?: unknown) { // 이벤트 기록 this.recordEvent(eventType, data); @@ -106,7 +111,7 @@ export class EventHandlers { /** * 이벤트 기록 */ - private recordEvent(eventType: string, data: any) { + private recordEvent(eventType: string, data: unknown) { this.eventHistory.push({ type: eventType, data: data, @@ -122,7 +127,7 @@ export class EventHandlers { /** * 파일 선택 이벤트 처리 */ - handleFileSelection(file: any) { + handleFileSelection(file: unknown) { this.emit('file:selected', { file: file, timestamp: Date.now(), @@ -132,7 +137,7 @@ export class EventHandlers { /** * 파일 제거 이벤트 처리 */ - handleFileRemoval(file: any) { + handleFileRemoval(file: unknown) { this.emit('file:removed', { file: file, timestamp: Date.now(), @@ -142,7 +147,7 @@ export class EventHandlers { /** * 파일 검증 성공 이벤트 */ - handleValidationSuccess(file: any, validation: any) { + handleValidationSuccess(file: unknown, validation: unknown) { this.emit('file:validated', { file: file, validation: validation, @@ -153,7 +158,7 @@ export class EventHandlers { /** * 파일 검증 실패 이벤트 */ - handleValidationFailure(file: any, errors: string[]) { + handleValidationFailure(file: unknown, errors: string[]) { this.emit('file:validation-failed', { file: file, errors: errors, @@ -268,7 +273,7 @@ export class EventHandlers { /** * 진행 중 에러 */ - errorProgress(error: any, message?: string) { + errorProgress(error: unknown, message?: string) { this.emit('progress:error', { error: error, message: message, @@ -337,7 +342,7 @@ export class EventHandlers { /** * 디바운스된 이벤트 핸들러 생성 */ - debounce void>( + debounce void>( func: T, wait: number ): (...args: Parameters) => void { @@ -354,7 +359,7 @@ export class EventHandlers { /** * 쓰로틀된 이벤트 핸들러 생성 */ - throttle void>( + throttle void>( func: T, limit: number ): (...args: Parameters) => void { @@ -373,7 +378,7 @@ export class EventHandlers { // 타입 정의 interface EventHistoryEntry { type: string; - data: any; + data: unknown; timestamp: number; } @@ -390,13 +395,13 @@ interface KeyboardShortcuts { // 이벤트 타입 정의 export type FileEvent = { - file: any; + file: unknown; timestamp: number; }; export type ValidationEvent = { - file: any; - validation?: any; + file: unknown; + validation?: unknown; errors?: string[]; timestamp: number; }; @@ -404,7 +409,7 @@ export type ValidationEvent = { export type ProgressEvent = { progress?: number; message?: string; - error?: any; + error?: unknown; timestamp: number; }; diff --git a/src/ui/components/FileValidator.ts b/src/ui/components/FileValidator.ts index 379b7e2..cb0f512 100644 --- a/src/ui/components/FileValidator.ts +++ b/src/ui/components/FileValidator.ts @@ -106,47 +106,47 @@ export class FileValidator { /** * 파일 검증 메인 메서드 */ - async validate(file: TFile, buffer?: ArrayBuffer): Promise { + validate(file: TFile, buffer?: ArrayBuffer): Promise { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; // 1. 확장자 검증 const extensionResult = this.validateExtension(file); - if (!extensionResult.valid) { - errors.push(extensionResult.error!); + if (!extensionResult.valid && extensionResult.error) { + errors.push(extensionResult.error); } // 2. 파일 크기 검증 const sizeResult = this.validateFileSize(file.stat.size); - if (!sizeResult.valid) { - errors.push(sizeResult.error!); + if (!sizeResult.valid && sizeResult.error) { + errors.push(sizeResult.error); } else if (sizeResult.warning) { warnings.push(sizeResult.warning); } // 3. 파일명 검증 const nameResult = this.validateFileName(file.name); - if (!nameResult.valid) { - warnings.push(nameResult.warning!); + if (!nameResult.valid && nameResult.warning) { + warnings.push(nameResult.warning); } // 4. 매직 바이트 검증 (버퍼가 제공된 경우) if (buffer && extensionResult.valid) { const magicResult = this.validateMagicBytes(file.extension, buffer); - if (!magicResult.valid) { - errors.push(magicResult.error!); + if (!magicResult.valid && magicResult.error) { + errors.push(magicResult.error); } } // 5. 메타데이터 추출 - const metadata = await this.extractMetadata(file, buffer); + const metadata = this.extractMetadata(file, buffer); - return { + return Promise.resolve({ valid: errors.length === 0, errors: errors.length > 0 ? errors : undefined, warnings: warnings.length > 0 ? warnings : undefined, metadata, - }; + }); } /** diff --git a/src/ui/formatting/FormatOptions.ts b/src/ui/formatting/FormatOptions.ts index b2b3eaf..78207c5 100644 --- a/src/ui/formatting/FormatOptions.ts +++ b/src/ui/formatting/FormatOptions.ts @@ -268,7 +268,7 @@ export class FormatOptionsModal extends Modal { .setName('Language') .setDesc('Programming language for syntax highlighting') .addText((text) => { - text.setPlaceholder('javascript, python, etc.') + text.setPlaceholder('JavaScript, Python, etc.') .setValue(this.options.codeLanguage || '') .onChange((value) => { this.options.codeLanguage = value; diff --git a/src/ui/managers/SettingsTabManager.ts b/src/ui/managers/SettingsTabManager.ts index 109aaac..a0a7024 100644 --- a/src/ui/managers/SettingsTabManager.ts +++ b/src/ui/managers/SettingsTabManager.ts @@ -1,4 +1,4 @@ -import { App, PluginSettingTab } from 'obsidian'; +import { App, PluginSettingTab, Setting } from 'obsidian'; import type SpeechToTextPlugin from '../../main'; import { Logger } from '../../infrastructure/logging/Logger'; import { IDisposable } from '../../architecture/DependencyContainer'; @@ -149,13 +149,14 @@ export class SettingsTabManager implements IDisposable { /** * 초기화 에러 처리 */ - private handleInitializationError(error: any): void { + private handleInitializationError(error: unknown): void { // 에러 타입에 따른 처리 - if (error?.message?.includes('toLowerCase')) { + 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 (error?.message?.includes('undefined')) { + } else if (message.includes('undefined')) { this.logger.error('Undefined reference error, checking dependencies'); } @@ -243,7 +244,7 @@ class MinimalSettingsTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - containerEl.createEl('h2', { text: 'Speech to text settings' }); + new Setting(containerEl).setName('Transcription settings').setHeading(); containerEl.createEl('p', { text: 'Settings are temporarily unavailable. Please restart Obsidian if this persists.', cls: 'settings-error-message', diff --git a/src/ui/managers/StatusBarManager.ts b/src/ui/managers/StatusBarManager.ts index c5bc818..a9d9615 100644 --- a/src/ui/managers/StatusBarManager.ts +++ b/src/ui/managers/StatusBarManager.ts @@ -1,4 +1,5 @@ import { Plugin } from 'obsidian'; +import type { AppState } from '../../types'; import { Logger } from '../../infrastructure/logging/Logger'; import { StateManager } from '../../application/StateManager'; import { IDisposable } from '../../architecture/DependencyContainer'; @@ -107,7 +108,7 @@ export class StatusBarManager implements IDisposable { /** * 상태에 따른 StatusBar 설정 생성 */ - private getStatusConfigForState(state: any): StatusBarConfig { + private getStatusConfigForState(state: AppState): StatusBarConfig { switch (state.status) { case 'idle': return { text: '' }; diff --git a/src/ui/modals/FilePickerModal.ts b/src/ui/modals/FilePickerModal.ts index 2c11720..2378077 100644 --- a/src/ui/modals/FilePickerModal.ts +++ b/src/ui/modals/FilePickerModal.ts @@ -204,8 +204,8 @@ export class FilePickerModal extends Modal { const dropSection = container.createDiv('drag-drop-section'); this.dragDropZone.mount(dropSection); - this.dragDropZone.onFilesDropped(async (files) => { - await this.handleDroppedFiles(files); + this.dragDropZone.onFilesDropped((files) => { + void this.handleDroppedFiles(files); }); } @@ -278,7 +278,8 @@ export class FilePickerModal extends Modal { } else { statusIcon.addClass('invalid'); statusIcon.setText('✗'); - statusIcon.title = validation.errors?.join('\n') || ''; + statusIcon.title = + validation.errors?.map((error) => error.message).join('\n') || ''; } } @@ -309,8 +310,8 @@ export class FilePickerModal extends Modal { .setButtonText(`선택 (${this.selectedFiles.length})`) .setCta() .setDisabled(this.selectedFiles.length === 0) - .onClick(async () => { - await this.processSelectedFiles(); + .onClick(() => { + this.processSelectedFiles(); }) ); } @@ -333,14 +334,15 @@ export class FilePickerModal extends Modal { this.validationResults.set(file.path, validation); if (!validation.valid) { - const errors = validation.errors?.join('\n') || '알 수 없는 오류'; + const errors = + validation.errors?.map((error) => error.message).join('\n') || '알 수 없는 오류'; new Notice(`파일 검증 실패:\n${errors}`); return; } // 경고가 있는 경우 if (validation.warnings && validation.warnings.length > 0) { - const warnings = validation.warnings.join('\n'); + const warnings = validation.warnings.map((warning) => warning.message).join('\n'); new Notice(`경고:\n${warnings}`); } @@ -368,7 +370,7 @@ export class FilePickerModal extends Modal { try { // ArrayBuffer 읽기 (선택적) const buffer = await this.app.vault.readBinary(file); - return await this.validator.validate(file, buffer); + return this.validator.validate(file, buffer); } catch (error) { const normalizedError = this.normalizeError(error); this.logger?.error('File validation failed', normalizedError); @@ -406,8 +408,9 @@ export class FilePickerModal extends Modal { if (results.length > 0) { // 최근 파일 저장 - if (this.recentFiles) { - results.forEach((r) => this.recentFiles!.addRecentFile(r.file)); + const recentFiles = this.recentFiles; + if (recentFiles) { + results.forEach((r) => recentFiles.addRecentFile(r.file)); } this.onChoose(results); diff --git a/src/ui/modals/FilePickerModalRefactored.ts b/src/ui/modals/FilePickerModalRefactored.ts index 86da0b3..fe0f675 100644 --- a/src/ui/modals/FilePickerModalRefactored.ts +++ b/src/ui/modals/FilePickerModalRefactored.ts @@ -240,8 +240,8 @@ export class FilePickerModalRefactored extends Modal { private setupEventHandlers(): void { // Drag and drop if (this.components.dragDropZone) { - this.components.dragDropZone.onFilesDropped(async (files) => { - await this.handleDroppedFiles(files); + this.components.dragDropZone.onFilesDropped((files) => { + void this.handleDroppedFiles(files); }); } @@ -365,7 +365,7 @@ export class FilePickerModalRefactored extends Modal { private async validateFile(file: TFile): Promise { try { const buffer = await this.app.vault.readBinary(file); - return await this.components.validator.validate(file, buffer); + return this.components.validator.validate(file, buffer); } catch (error) { const normalizedError = this.normalizeError(error); this.logger?.error('File validation failed', normalizedError); @@ -432,7 +432,7 @@ export class FilePickerModalRefactored extends Modal { /** * 제출 처리 - 비동기 최적화 */ - private async handleSubmit(): Promise { + private handleSubmit(): void { if (this.state.isProcessing || this.state.selectedFiles.length === 0) { return; } @@ -441,10 +441,10 @@ export class FilePickerModalRefactored extends Modal { this.components.progressIndicator.show('파일 처리 중...', true); try { - const results = await this.processSelectedFiles(); + const results = this.processSelectedFiles(); if (results.length > 0) { - await this.saveRecentFiles(results); + this.saveRecentFiles(results); this.onChoose(results); this.close(); } else { @@ -524,7 +524,8 @@ export class FilePickerModalRefactored extends Modal { * 검증 에러 표시 */ private showValidationError(validation: ValidationResult): void { - const errors = validation.errors?.join('\n') || '알 수 없는 오류'; + const errors = + validation.errors?.map((error) => error.message).join('\n') || '알 수 없는 오류'; new Notice(`파일 검증 실패:\n${errors}`); } @@ -532,7 +533,7 @@ export class FilePickerModalRefactored extends Modal { * 검증 경고 표시 */ private showValidationWarnings(validation: ValidationResult): void { - const warnings = validation.warnings?.join('\n') || ''; + const warnings = validation.warnings?.map((warning) => warning.message).join('\n') || ''; if (warnings) { new Notice(`경고:\n${warnings}`); } @@ -761,7 +762,7 @@ class SelectedFilesListRenderer { } else { statusIcon.addClass('invalid'); statusIcon.setText('✗'); - statusIcon.title = validation.errors?.join('\n') || ''; + statusIcon.title = validation.errors?.map((error) => error.message).join('\n') || ''; } } @@ -787,9 +788,9 @@ class SelectedFilesListRenderer { class AutoDisposableManager { private disposables: Set<{ dispose: () => void }> = new Set(); - add(disposable: any): void { - if (disposable && typeof disposable.dispose === 'function') { - this.disposables.add(disposable); + add(disposable: unknown): void { + if (disposable && typeof (disposable as { dispose?: unknown }).dispose === 'function') { + this.disposables.add(disposable as { dispose: () => void }); } } diff --git a/src/ui/notifications/NotificationManager.ts b/src/ui/notifications/NotificationManager.ts index 397556e..21b53bb 100644 --- a/src/ui/notifications/NotificationManager.ts +++ b/src/ui/notifications/NotificationManager.ts @@ -4,7 +4,6 @@ * Toast, Modal, StatusBar, Sound 알림을 통합 관리합니다. */ -/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ @@ -33,9 +32,53 @@ interface NotificationChannel { dismissAll(): void; } +type NotificationListener = + | ((notification: NotificationOptions) => void) + | ((id: string) => void) + | ((id: string, action: string) => void); + const DOM_AVAILABLE = typeof document !== 'undefined'; const AUDIO_AVAILABLE = typeof Audio !== 'undefined'; +type CreateElOptions = { + cls?: string | string[]; + text?: string; + attr?: Record; +}; + +const createEl = ( + tag: K, + options: CreateElOptions = {} +): HTMLElementTagNameMap[K] => { + const creator = (globalThis as { createEl?: unknown }).createEl; + if (typeof creator === 'function') { + return (creator as (tagName: K, options: CreateElOptions) => HTMLElementTagNameMap[K])( + tag, + options + ); + } + if (!DOM_AVAILABLE) { + throw new Error('DOM is not available'); + } + const el = document.createElement(tag); + if (options.cls) { + if (Array.isArray(options.cls)) { + el.classList.add(...options.cls); + } else { + el.className = options.cls; + } + } + if (options.text !== undefined) { + el.textContent = options.text; + } + if (options.attr) { + Object.entries(options.attr).forEach(([key, value]) => { + el.setAttribute(key, value); + }); + } + return el; +}; + class NoopChannel implements NotificationChannel { send(): Promise { return Promise.resolve(); @@ -202,12 +245,15 @@ class ToastChannel implements NotificationChannel { } private createContainer(): void { + if (!DOM_AVAILABLE) { + return; + } const container = createEl('div', { cls: 'toast-container' }); container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-atomic', 'true'); this.container = container; this.setPosition(this.position); - document.body.appendChild(container); + document.body?.appendChild(container); } setPosition(position: NotificationPosition): void { @@ -1108,10 +1154,8 @@ export class NotificationManager implements INotificationAPI { */ setSoundFile(type: NotificationType, soundUrl: string): void { const sound = this.channels.get('sound'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (sound && (sound as any).setSoundFile) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (sound as any).setSoundFile(type, soundUrl); + if (sound instanceof SoundChannel) { + sound.setSoundFile(type, soundUrl); } } @@ -1135,11 +1179,10 @@ export class NotificationManager implements INotificationAPI { subscribe(event: 'show', listener: (notification: NotificationOptions) => void): Unsubscribe; subscribe(event: 'dismiss', listener: (id: string) => void): Unsubscribe; subscribe(event: 'action', listener: (id: string, action: string) => void): Unsubscribe; - subscribe(event: string, listener: (...args: any[]) => void): Unsubscribe { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.emitter.on(event, listener as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return () => this.emitter.off(event, listener as any); + subscribe(event: string, listener: NotificationListener): Unsubscribe { + const handler = listener as (...args: unknown[]) => void; + this.emitter.on(event, handler); + return () => this.emitter.off(event, handler); } /** @@ -1260,9 +1303,10 @@ export class NotificationManager implements INotificationAPI { on(event: 'show', listener: (notification: NotificationOptions) => void): () => void; on(event: 'dismiss', listener: (id: string) => void): () => void; on(event: 'action', listener: (id: string, action: string) => void): () => void; - on(event: string, listener: (...args: any[]) => void): () => void { - this.emitter.on(event, listener); - return () => this.emitter.off(event, listener); + on(event: string, listener: NotificationListener): () => void { + const handler = listener as (...args: unknown[]) => void; + this.emitter.on(event, handler); + return () => this.emitter.off(event, handler); } /** diff --git a/src/ui/progress/ProgressBar.ts b/src/ui/progress/ProgressBar.ts index f0fbe5a..1c30531 100644 --- a/src/ui/progress/ProgressBar.ts +++ b/src/ui/progress/ProgressBar.ts @@ -268,7 +268,7 @@ export class ProgressBar { this.startTime = 0; if (this.progressFill) { - this.progressFill.setAttribute('style', '--sn-progress-width: 0%'); + this.progressFill.setCssProps({ '--sn-progress-width': '0%' }); } if (this.percentageElement) { @@ -391,6 +391,11 @@ export class MultiStepProgressBar { // 단계 표시 this.stepsContainer = createEl('div', { cls: 'multi-step-progress__steps' }); + const stepsContainer = this.stepsContainer; + if (!stepsContainer) { + container.appendChild(this.element); + return this.element; + } this.steps.forEach((step, index) => { const stepEl = createEl('div', { cls: 'step' }); @@ -409,16 +414,16 @@ export class MultiStepProgressBar { stepEl.appendChild(stepNumber); stepEl.appendChild(stepLabel); - this.stepsContainer!.appendChild(stepEl); + stepsContainer.appendChild(stepEl); // 단계 사이 연결선 if (index < this.steps.length - 1) { const connector = createEl('div', { cls: 'step-connector' }); - this.stepsContainer!.appendChild(connector); + stepsContainer.appendChild(connector); } }); - this.element.appendChild(this.stepsContainer); + this.element.appendChild(stepsContainer); // 전체 진행률 바 const progressContainer = createEl('div', { cls: 'multi-step-progress__bar' }); diff --git a/src/ui/progress/ProgressTracker.ts b/src/ui/progress/ProgressTracker.ts index be0e5a9..c5fb144 100644 --- a/src/ui/progress/ProgressTracker.ts +++ b/src/ui/progress/ProgressTracker.ts @@ -17,6 +17,12 @@ import type { StateManager } from '../../application/StateManager'; // Unsubscribe 타입 정의 (phase3-api에 정의되어 있으면 import로 변경) type Unsubscribe = () => void; +type ProgressListener = + | ((data: ProgressData) => void) + | ((result?: unknown) => void) + | ((error: Error) => void) + | (() => void); + /** * ETA 예측 알고리즘 */ @@ -452,8 +458,7 @@ export class ProgressTracker implements IProgressTracker { /** * 완료 처리 */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Event result can be any type - complete(result?: any): void { + complete(result?: unknown): void { this.currentProgress = 100; this.status = 'completed'; this.stateManager?.setState({ progress: this.currentProgress }); @@ -463,7 +468,6 @@ export class ProgressTracker implements IProgressTracker { this.emitter.emit('complete', result); this.emitter.emit('progress', data); this.eventManager.emit('progress:update', data); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Event payload this.eventManager.emit('task:completed', { taskId: this.taskId, result }); } @@ -500,16 +504,15 @@ export class ProgressTracker implements IProgressTracker { * 이벤트 리스너 등록 (타입 안전한 구독) */ on(event: 'progress', listener: (data: ProgressData) => void): Unsubscribe; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Event system requires flexible result type - on(event: 'complete', listener: (result?: any) => void): Unsubscribe; + on(event: 'complete', listener: (result?: unknown) => void): Unsubscribe; on(event: 'error', listener: (error: Error) => void): Unsubscribe; on(event: 'pause', listener: () => void): Unsubscribe; on(event: 'resume', listener: () => void): Unsubscribe; on(event: 'cancel', listener: () => void): Unsubscribe; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Event system requires flexible args - on(event: string, listener: (...args: any[]) => void): Unsubscribe { - this.emitter.on(event, listener); - return () => this.emitter.off(event, listener); + on(event: string, listener: ProgressListener): Unsubscribe { + const handler = listener as (...args: unknown[]) => void; + this.emitter.on(event, handler); + return () => this.emitter.off(event, handler); } /** @@ -674,9 +677,7 @@ export class ProgressTrackingSystem { /** * 완료 표시 */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Event result can be any type - private showCompletion(taskId: string, result: any): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Event payload + private showCompletion(taskId: string, result: unknown): void { this.eventManager.emit('ui:task:complete', { taskId, result }); } diff --git a/src/ui/progress/StatusMessage.ts b/src/ui/progress/StatusMessage.ts index 711f5cd..b495006 100644 --- a/src/ui/progress/StatusMessage.ts +++ b/src/ui/progress/StatusMessage.ts @@ -14,7 +14,7 @@ export type MessageType = 'info' | 'success' | 'warning' | 'error' | 'progress'; export interface StatusMessage { key: string; type: MessageType; - params?: Record; + params?: Record; timestamp?: number; } @@ -264,14 +264,15 @@ export class MessageStore { /** * 메시지 가져오기 */ - static getMessage(key: string, lang: Language, params?: Record): string { + static getMessage(key: string, lang: Language, params?: Record): string { const message = this.messages[key]?.[lang] || key; if (!params) return message; // 파라미터 치환 - return message.replace(/\{(\w+)\}/g, (match, param) => { - return params[param] !== undefined ? String(params[param]) : match; + return message.replace(/\{(\w+)\}/g, (match: string, param: string) => { + const value = params[param]; + return value !== undefined ? String(value) : match; }); } @@ -606,7 +607,7 @@ export class StatusMessageManager { /** * 메시지 표시 */ - showMessage(key: string, type: MessageType = 'info', params?: Record) { + showMessage(key: string, type: MessageType = 'info', params?: Record) { if (!this.display) { console.warn('StatusMessageDisplay is not initialized'); return; @@ -623,35 +624,35 @@ export class StatusMessageManager { /** * 정보 메시지 */ - info(key: string, params?: Record) { + info(key: string, params?: Record) { this.showMessage(key, 'info', params); } /** * 성공 메시지 */ - success(key: string, params?: Record) { + success(key: string, params?: Record) { this.showMessage(key, 'success', params); } /** * 경고 메시지 */ - warning(key: string, params?: Record) { + warning(key: string, params?: Record) { this.showMessage(key, 'warning', params); } /** * 오류 메시지 */ - error(key: string, params?: Record) { + error(key: string, params?: Record) { this.showMessage(key, 'error', params); } /** * 진행 메시지 */ - progress(key: string, params?: Record) { + progress(key: string, params?: Record) { this.showMessage(key, 'progress', params); } diff --git a/src/ui/settings/EnhancedSettingsTab.ts b/src/ui/settings/EnhancedSettingsTab.ts index c11b5f3..3c42347 100644 --- a/src/ui/settings/EnhancedSettingsTab.ts +++ b/src/ui/settings/EnhancedSettingsTab.ts @@ -392,8 +392,8 @@ export class EnhancedSettingsTab extends PluginSettingTab { .setDesc('Select the transcription service provider') .addDropdown((dropdown) => { dropdown - .addOption('openai', 'OpenAI Whisper') - .addOption('azure', 'Azure Speech Services') + .addOption('openai', 'OpenAI whisper') + .addOption('azure', 'Azure speech services') .addOption('custom', 'Custom endpoint'); void this.settingsAPI.get('api').then((api) => { @@ -495,7 +495,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { new Notice(`❌ ${error}`); } } catch (error) { - new Notice('❌ Failed to validate API key'); + new Notice('❌ Failed to validate API key.'); console.error(error); } finally { validateBtn.disabled = false; @@ -671,7 +671,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { .setName('Audio language') .setDesc('Language hint for better recognition') .addText((text) => { - text.setPlaceholder('auto'); + text.setPlaceholder('Auto'); text.setValue(audio.language); text.onChange(async (value) => { audio.language = value; @@ -721,7 +721,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { }); new Setting(cacheSection) - .setName('Cache TTL') + .setName('Cache TTL (days)') .setDesc('Cache time-to-live in days') .addSlider((slider) => { slider @@ -904,7 +904,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { private showAbout(container: HTMLElement): void { const section = container.createDiv({ cls: 'settings-section about-section' }); - new Setting(section).setName('About speech to text').setHeading(); + new Setting(section).setName('About').setHeading(); // 버전 정보 const versionInfo = section.createDiv({ cls: 'version-info' }); @@ -919,7 +919,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { const links = section.createDiv({ cls: 'about-links' }); const githubLink = links.createEl('a', { - text: '📖 Documentation', + text: '📖 Documentation and guides', href: 'https://github.com/yourusername/obsidian-speech-to-text', }); githubLink.setAttribute('target', '_blank'); @@ -927,7 +927,7 @@ export class EnhancedSettingsTab extends PluginSettingTab { links.createEl('br'); const issueLink = links.createEl('a', { - text: '🐛 Report issue', + text: '🐛 Report an issue', href: 'https://github.com/yourusername/obsidian-speech-to-text/issues', }); issueLink.setAttribute('target', '_blank'); @@ -1199,7 +1199,7 @@ class HelpModal extends Modal { onOpen(): void { const { contentEl } = this; - new Setting(contentEl).setName('Speech to text help').setHeading(); + new Setting(contentEl).setName('Help and support').setHeading(); const helpContent = contentEl.createDiv({ cls: 'help-content' }); diff --git a/src/ui/settings/SettingsTab.ts b/src/ui/settings/SettingsTab.ts index 383a498..66a516a 100644 --- a/src/ui/settings/SettingsTab.ts +++ b/src/ui/settings/SettingsTab.ts @@ -152,7 +152,7 @@ export class SettingsTab extends PluginSettingTab { dropdown .addOption('auto', 'Auto (intelligent selection)') - .addOption('whisper', 'OpenAI Whisper') + .addOption('whisper', 'OpenAI whisper') .addOption('deepgram', 'Deepgram') .setValue(this.plugin.settings.provider || 'auto') .onChange(async (value) => { @@ -316,7 +316,7 @@ export class SettingsTab extends PluginSettingTab { // API Endpoint new Setting(containerEl) .setName('API endpoint') - .setDesc('OpenAI API endpoint (leave default unless using custom endpoint)') + .setDesc('OpenAI API endpoint (leave the default unless using a custom endpoint)') .addText((text) => text .setPlaceholder('https://api.openai.com/v1') @@ -354,7 +354,7 @@ export class SettingsTab extends PluginSettingTab { } catch (error) { console.error('Error rendering Deepgram settings:', error); deepgramContainer.createEl('p', { - text: 'Error loading Deepgram settings', + text: 'Error loading Deepgram settings.', cls: 'mod-warning', }); } @@ -366,7 +366,7 @@ export class SettingsTab extends PluginSettingTab { private renderWhisperApiKey(containerEl: HTMLElement): void { new Setting(containerEl) .setName('OpenAI API key') - .setDesc('Enter your OpenAI API key for Whisper transcription') + .setDesc('Enter your OpenAI API key for whisper transcription') .addText((text) => { text.setPlaceholder('sk-...') .setValue(this.maskApiKey(this.plugin.settings.apiKey || '')) @@ -377,7 +377,7 @@ export class SettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); text.setValue(this.maskApiKey(value)); - new Notice('OpenAI API key saved'); + new Notice('OpenAI API key saved.'); } }); @@ -410,7 +410,7 @@ export class SettingsTab extends PluginSettingTab { await this.plugin.saveSettings(); text.setValue(this.maskApiKey(value)); - new Notice('Deepgram API key saved'); + new Notice('Deepgram API key saved.'); } }); @@ -439,9 +439,9 @@ export class SettingsTab extends PluginSettingTab { const descriptions = { auto: '🤖 Intelligent selection between providers based on your configured strategy. Automatically chooses the best provider for each request.', whisper: - '🎯 OpenAI Whisper - High-quality transcription with support for multiple languages. Best for general-purpose transcription.', + '🎯 OpenAI whisper - high-quality transcription with support for multiple languages. Best for general-purpose transcription.', deepgram: - '⚡ Deepgram - Fast, accurate transcription with advanced AI features. Best for real-time processing and speaker diarization.', + '⚡ Deepgram - fast, accurate transcription with advanced AI features. Best for real-time processing and speaker diarization.', }; infoEl.createEl('p', { text: descriptions[provider] }); @@ -540,7 +540,7 @@ export class SettingsTab extends PluginSettingTab { new Setting(containerEl) .setName('Temperature') .setDesc( - 'Sampling temperature (0-1). lower values make output more focused and deterministic' + 'Sampling temperature (0-1). Lower values make output more focused and deterministic' ) .addText((text) => text @@ -608,8 +608,7 @@ export class SettingsTab extends PluginSettingTab { button .setButtonText('Reset') .setWarning() - // eslint-disable-next-line @typescript-eslint/require-await - .onClick(async () => { + .onClick(() => { new ConfirmationModal( this.app, 'Reset settings', @@ -639,7 +638,7 @@ export class SettingsTab extends PluginSettingTab { // 감사 메시지 containerEl.createEl('p', { - text: 'Thank you for using Speech to text! Your support helps keep this plugin free and actively maintained.', + text: 'Thank you for using speech-to-text! Your support helps keep this plugin free and actively maintained.', cls: 'setting-item-description', }); diff --git a/src/ui/settings/SettingsTabOptimized.ts b/src/ui/settings/SettingsTabOptimized.ts index 7e9f427..0315353 100644 --- a/src/ui/settings/SettingsTabOptimized.ts +++ b/src/ui/settings/SettingsTabOptimized.ts @@ -10,6 +10,7 @@ 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'; /** * 최적화된 설정 탭 컴포넌트 @@ -75,7 +76,7 @@ export class SettingsTabOptimized extends PluginSettingTab { sectionRenderers: new Map(), }; } catch (error) { - this.errorManager.handleError(this.normalizeError(error), { + void this.errorManager.handleError(this.normalizeError(error), { type: ErrorType.RESOURCE, severity: ErrorSeverity.HIGH, context: { component: 'SettingsTab' }, @@ -107,7 +108,7 @@ export class SettingsTabOptimized extends PluginSettingTab { this.state.isDirty = false; this.updateSaveStatus('saved'); } catch (error) { - this.errorManager.handleError(this.normalizeError(error), { + void this.errorManager.handleError(this.normalizeError(error), { type: ErrorType.RESOURCE, severity: ErrorSeverity.MEDIUM, userMessage: '설정 저장 실패', @@ -170,8 +171,9 @@ export class SettingsTabOptimized extends PluginSettingTab { /** * 섹션 에러 처리 */ - private handleSectionError(sectionId: string, error: any): void { - this.errorManager.handleError(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 }, @@ -290,7 +292,7 @@ export class SettingsTabOptimized extends PluginSettingTab { */ private createSection(container: HTMLElement, title: string, description: string): HTMLElement { const section = container.createDiv('settings-section'); - section.createEl('h3', { text: title }); + new Setting(section).setName(title).setHeading(); if (description) { section.createEl('p', { @@ -429,10 +431,7 @@ class SettingsHeader extends SectionRenderer { render(): void { const headerEl = this.container.createDiv({ cls: 'settings-header' }); - headerEl.createEl('h2', { - text: 'Speech to Text 설정', - cls: 'settings-title', - }); + new Setting(headerEl).setName('음성 전사 설정').setHeading(); headerEl.createEl('p', { text: '음성을 텍스트로 변환하는 플러그인 설정을 구성합니다.', @@ -469,7 +468,7 @@ class ApiSettingsSection extends SectionRenderer { private createSection(): HTMLElement { const section = this.container.createDiv('settings-section'); - section.createEl('h3', { text: 'API' }); + new Setting(section).setName('API').setHeading(); section.createEl('p', { text: 'OpenAI API 설정', cls: 'setting-item-description', @@ -483,27 +482,33 @@ class ApiSettingsSection extends SectionRenderer { .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, - this.eventManager! + eventManager ); - input.onChange(async (value) => { - if (this.validator) { - const isValid = await this.validator.validate(value); - this.state.validationStatus.set('apiKey', isValid); + 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 키입니다'); + if (isValid) { + this.plugin.settings.apiKey = value; + this.notifyChange(); + new Notice('✅ API 키가 검증되었습니다'); + } else { + new Notice('❌ 유효하지 않은 API 키입니다'); + } } - } + })(); }); input.render(); @@ -511,7 +516,7 @@ class ApiSettingsSection extends SectionRenderer { private createApiUsageDisplay(section: HTMLElement): void { const usageEl = section.createDiv('api-usage'); - usageEl.createEl('h4', { text: 'API 사용량' }); + new Setting(usageEl).setName('API 사용량').setHeading(); // Placeholder for usage stats const statsEl = usageEl.createDiv('usage-stats'); @@ -692,7 +697,7 @@ class SettingsFooter extends SectionRenderer { URL.revokeObjectURL(url); new Notice('설정을 내보냈습니다'); - } catch (error) { + } catch { new Notice('설정 내보내기 실패'); } } @@ -702,35 +707,40 @@ class SettingsFooter extends SectionRenderer { 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; + 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 settings = JSON.parse(text); + 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, settings); - await this.plugin.saveSettings(); + // Validate and merge settings + Object.assign(this.plugin.settings, parsed); + await this.plugin.saveSettings(); - new Notice('설정을 가져왔습니다'); + new Notice('설정을 가져왔습니다'); - // Refresh UI - need to trigger parent component refresh - // This should be handled via event or callback - } catch (error) { - new Notice('설정 가져오기 실패'); - } + // Refresh UI - need to trigger parent component refresh + // This should be handled via event or callback + } catch { + new Notice('설정 가져오기 실패'); + } + })(); }; input.click(); } - private async resetSettings(): Promise { + private resetSettings(): void { new ConfirmationModal( this.plugin.app, 'Reset settings', @@ -746,7 +756,7 @@ class SettingsFooter extends SectionRenderer { // Refresh UI // Note: We need to refresh the main settings tab, not from within footer // This should be handled by the parent component - } catch (error) { + } catch { new Notice('설정 재설정 실패'); } } diff --git a/src/ui/settings/SettingsTabRefactored.ts b/src/ui/settings/SettingsTabRefactored.ts index d4a7486..68821bd 100644 --- a/src/ui/settings/SettingsTabRefactored.ts +++ b/src/ui/settings/SettingsTabRefactored.ts @@ -17,6 +17,7 @@ import { ErrorSeverity, tryCatchAsync, } from '../../utils/error/ErrorManager'; +import { isPlainRecord } from '../../types/guards'; /** * 개선된 설정 탭 UI 컴포넌트 @@ -34,7 +35,7 @@ export class SettingsTabRefactored extends PluginSettingTab { private errorManager = GlobalErrorManager.getInstance(); // 비동기 작업 관리 - private pendingOperations = new Set>(); + private pendingOperations = new Set>(); // 디바운스된 저장 함수 private debouncedSave = debounceAsync(() => this.plugin.saveSettings(), 500); @@ -89,37 +90,30 @@ export class SettingsTabRefactored extends PluginSettingTab { containerEl.empty(); containerEl.addClass('speech-to-text-settings'); - void tryCatchAsync( - async () => { - await this.renderContent(containerEl); + void tryCatchAsync(() => Promise.resolve(this.renderContent(containerEl)), { + onError: (error) => { + void this.errorManager.handleError(error, { + type: ErrorType.UNKNOWN, + severity: ErrorSeverity.HIGH, + userMessage: '설정 페이지를 불러오는 중 오류가 발생했습니다.', + }); }, - { - onError: (error) => { - this.errorManager.handleError(error, { - type: ErrorType.UNKNOWN, - severity: ErrorSeverity.HIGH, - userMessage: '설정 페이지를 불러오는 중 오류가 발생했습니다.', - }); - }, - } - ); + }); } /** * 콘텐츠 렌더링 */ - private async renderContent(containerEl: HTMLElement): Promise { + private renderContent(containerEl: HTMLElement): void { // 헤더 this.createHeader(containerEl); // 섹션별 설정 - await Promise.all([ - this.createGeneralSection(containerEl), - this.createApiSection(containerEl), - this.createAudioSection(containerEl), - this.createAdvancedSection(containerEl), - this.createShortcutSection(containerEl), - ]); + this.createGeneralSection(containerEl); + this.createApiSection(containerEl); + this.createAudioSection(containerEl); + this.createAdvancedSection(containerEl); + this.createShortcutSection(containerEl); // 푸터 this.createFooter(containerEl); @@ -131,10 +125,7 @@ export class SettingsTabRefactored extends PluginSettingTab { private createHeader(containerEl: HTMLElement): void { const headerEl = containerEl.createDiv({ cls: 'settings-header' }); - headerEl.createEl('h2', { - text: 'Speech to Text 설정', - cls: 'settings-title', - }); + new Setting(headerEl).setName('음성 전사 설정').setHeading(); headerEl.createEl('p', { text: '음성을 텍스트로 변환하는 플러그인 설정을 구성합니다.', @@ -202,77 +193,86 @@ export class SettingsTabRefactored extends PluginSettingTab { cls: 'mod-cta api-key-validate', }); - this.eventManager.add(validateBtn, 'click', 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(async (resolve, reject, signal) => { - 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) { - 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', async () => { - const value = inputEl.value; - if (value && value !== this.maskApiKey(currentKey)) { - if (!value.startsWith('sk-')) { - new Notice('API 키는 "sk-"로 시작해야 합니다'); + this.eventManager.add(validateBtn, 'click', () => { + void (async () => { + const value = inputEl.value; + if (!value || value === this.maskApiKey(currentKey)) { + new Notice('API 키를 입력해주세요'); return; } - this.plugin.settings.apiKey = value; - await this.debouncedSave(); - inputEl.setAttribute('data-has-value', 'true'); - } + validateBtn.disabled = true; + validateBtn.textContent = '검증 중...'; + + // 취소 가능한 Promise로 검증 + const validation = new CancellablePromise((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); @@ -316,10 +316,10 @@ export class SettingsTabRefactored extends PluginSettingTab { // 설정 내보내기 const exportBtn = new ButtonComponent(exportImportEl).setButtonText('설정 내보내기'); - this.eventManager.add(exportBtn.buttonEl, 'click', async () => { - await tryCatchAsync(() => this.exportSettings(), { + this.eventManager.add(exportBtn.buttonEl, 'click', () => { + void tryCatchAsync(() => this.exportSettings(), { onError: (error) => { - this.errorManager.handleError(error, { + void this.errorManager.handleError(error, { type: ErrorType.UNKNOWN, severity: ErrorSeverity.LOW, userMessage: '설정 내보내기에 실패했습니다.', @@ -331,10 +331,10 @@ export class SettingsTabRefactored extends PluginSettingTab { // 설정 가져오기 const importBtn = new ButtonComponent(exportImportEl).setButtonText('설정 가져오기'); - this.eventManager.add(importBtn.buttonEl, 'click', async () => { - await tryCatchAsync(() => this.importSettings(), { + this.eventManager.add(importBtn.buttonEl, 'click', () => { + void tryCatchAsync(() => this.importSettings(), { onError: (error) => { - this.errorManager.handleError(error, { + void this.errorManager.handleError(error, { type: ErrorType.UNKNOWN, severity: ErrorSeverity.LOW, userMessage: '설정 가져오기에 실패했습니다.', @@ -348,11 +348,13 @@ export class SettingsTabRefactored extends PluginSettingTab { .setButtonText('기본값으로 초기화') .setWarning(); - this.eventManager.add(resetBtn.buttonEl, 'click', async () => { - const confirmed = await this.confirmReset(); - if (confirmed) { - await this.resetSettings(); - } + this.eventManager.add(resetBtn.buttonEl, 'click', () => { + void (async () => { + const confirmed = await this.confirmReset(); + if (confirmed) { + await this.resetSettings(); + } + })(); }); // 버전 정보 @@ -379,7 +381,7 @@ export class SettingsTabRefactored extends PluginSettingTab { }); const headerEl = sectionEl.createDiv({ cls: 'section-header' }); - headerEl.createEl('h3', { text: title }); + new Setting(headerEl).setName(title).setHeading(); headerEl.createEl('p', { text: desc, cls: 'section-description' }); return sectionEl.createDiv({ cls: 'section-content' }); @@ -391,7 +393,7 @@ export class SettingsTabRefactored extends PluginSettingTab { private createApiUsageDisplay(containerEl: HTMLElement): void { const usageEl = containerEl.createDiv({ cls: 'api-usage-display' }); - usageEl.createEl('h4', { text: 'API 사용량' }); + new Setting(usageEl).setName('API 사용량').setHeading(); const statsEl = usageEl.createDiv({ cls: 'usage-stats' }); @@ -490,9 +492,9 @@ export class SettingsTabRefactored extends PluginSettingTab { * 설정 내보내기 */ private exportSettings(): Promise { - const exportSettings = { ...this.plugin.settings }; - delete (exportSettings as any).apiKey; - delete (exportSettings as any).encryptedApiKey; + const exportSettings: Record = { ...this.plugin.settings }; + delete exportSettings.apiKey; + delete exportSettings.encryptedApiKey; const json = JSON.stringify(exportSettings, null, 2); const blob = new Blob([json], { type: 'application/json' }); @@ -538,10 +540,13 @@ export class SettingsTabRefactored extends PluginSettingTab { const file = await filePromise; const text = await file.text(); - const settings = JSON.parse(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, settings); + Object.assign(this.plugin.settings, parsed); if (currentApiKey) { this.plugin.settings.apiKey = currentApiKey; @@ -617,7 +622,7 @@ class ConfirmModalRefactored extends Modal { onOpen() { const { contentEl } = this; - contentEl.createEl('h2', { text: this.title }); + new Setting(contentEl).setName(this.title).setHeading(); contentEl.createEl('p', { text: this.message }); const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' }); diff --git a/src/ui/settings/SimpleSettingsTab.ts b/src/ui/settings/SimpleSettingsTab.ts index e4d1e2d..ccd300e 100644 --- a/src/ui/settings/SimpleSettingsTab.ts +++ b/src/ui/settings/SimpleSettingsTab.ts @@ -23,10 +23,10 @@ export class SimpleSettingsTab extends PluginSettingTab { containerEl.empty(); // 제목 - containerEl.createEl('h2', { text: 'Speech to text settings' }); + new Setting(containerEl).setName('Transcription settings').setHeading(); // Provider 선택 드롭다운 - 가장 중요한 기능 - containerEl.createEl('h3', { text: 'API configuration' }); + new Setting(containerEl).setName('API configuration').setHeading(); try { this.debug('Creating provider dropdown...'); @@ -39,7 +39,7 @@ export class SimpleSettingsTab extends PluginSettingTab { this.debug('Adding options to dropdown...'); dropdown .addOption('auto', 'Auto (intelligent selection)') - .addOption('whisper', 'OpenAI Whisper') + .addOption('whisper', 'OpenAI whisper') .addOption('deepgram', 'Deepgram') .setValue(this.plugin.settings.provider || 'auto') .onChange(async (value) => { @@ -61,8 +61,8 @@ export class SimpleSettingsTab extends PluginSettingTab { // Auto 모드일 때는 양쪽 API 키 모두 표시 if (provider === 'auto' || provider === 'whisper') { new Setting(containerEl) - .setName('Openai API key') - .setDesc('Enter your openai API key for whisper') + .setName('OpenAI API key') + .setDesc('Enter your OpenAI API key for whisper') .addText((text) => text .setPlaceholder('sk-...') @@ -78,7 +78,7 @@ export class SimpleSettingsTab extends PluginSettingTab { if (provider === 'auto' || provider === 'deepgram') { new Setting(containerEl) .setName('Deepgram API key') - .setDesc('Enter your deepgram API key') + .setDesc('Enter your Deepgram API key') .addText((text) => text .setPlaceholder('Enter Deepgram API key...') @@ -120,7 +120,7 @@ export class SimpleSettingsTab extends PluginSettingTab { } // 기본 설정들 - containerEl.createEl('h3', { text: 'General settings' }); + new Setting(containerEl).setName('General settings').setHeading(); new Setting(containerEl) .setName('Language') @@ -152,7 +152,7 @@ export class SimpleSettingsTab extends PluginSettingTab { ); // 디버그 정보 - containerEl.createEl('h3', { text: 'Debug information' }); + new Setting(containerEl).setName('Debug information').setHeading(); const debugInfo = { provider: this.plugin.settings.provider, @@ -172,7 +172,7 @@ export class SimpleSettingsTab extends PluginSettingTab { } catch (error) { console.error('Error in SimpleSettingsTab:', error); containerEl.createEl('p', { - text: `Error: ${error}`, + text: `Error: ${error instanceof Error ? error.message : String(error)}`, cls: 'mod-warning', }); } diff --git a/src/ui/settings/base/BaseSettingsComponent.ts b/src/ui/settings/base/BaseSettingsComponent.ts index 73ed612..fd9df39 100644 --- a/src/ui/settings/base/BaseSettingsComponent.ts +++ b/src/ui/settings/base/BaseSettingsComponent.ts @@ -239,7 +239,7 @@ export class SettingsValidator { /** * 설정 상태 관리 */ -export class SettingsState { +export class SettingsState { private state: T; private listeners: Set<(state: T) => void> = new Set(); diff --git a/src/ui/settings/base/CommonUIComponents.ts b/src/ui/settings/base/CommonUIComponents.ts index 41c4df3..21d7f50 100644 --- a/src/ui/settings/base/CommonUIComponents.ts +++ b/src/ui/settings/base/CommonUIComponents.ts @@ -410,7 +410,7 @@ export class FormValidator { /** * 필수 필드 검증 */ - required(field: string, value: any, message?: string): this { + required(field: string, value: unknown, message?: string): this { if (!value || (typeof value === 'string' && value.trim() === '')) { this.errors.set(field, message || `${field}는 필수 항목입니다`); } diff --git a/src/ui/settings/components/AdvancedSettings.ts b/src/ui/settings/components/AdvancedSettings.ts index 7f89054..c55896f 100644 --- a/src/ui/settings/components/AdvancedSettings.ts +++ b/src/ui/settings/components/AdvancedSettings.ts @@ -45,8 +45,8 @@ export class AdvancedSettings { this.plugin.app, 'Clear cache', '기존 캐시를 삭제하시겠습니까?', - async () => { - await this.clearCache(); + () => { + this.clearCache(); } ).open(); } @@ -87,8 +87,8 @@ export class AdvancedSettings { // 캐시 삭제 버튼 cacheManagement.addButton((button) => - button.setButtonText('캐시 삭제').onClick(async () => { - await this.clearCache(); + button.setButtonText('캐시 삭제').onClick(() => { + this.clearCache(); this.updateCacheStatus(cacheStatus); }) ); @@ -164,8 +164,8 @@ export class AdvancedSettings { ); logManagement.addButton((button) => - button.setButtonText('로그 내보내기').onClick(async () => { - await this.exportLogs(); + button.setButtonText('로그 내보내기').onClick(() => { + this.exportLogs(); }) ); @@ -173,8 +173,8 @@ export class AdvancedSettings { button .setButtonText('로그 삭제') .setWarning() - .onClick(async () => { - await this.clearLogs(); + .onClick(() => { + this.clearLogs(); }) ); } diff --git a/src/ui/settings/components/ApiKeyValidator.ts b/src/ui/settings/components/ApiKeyValidator.ts index b96324a..6385577 100644 --- a/src/ui/settings/components/ApiKeyValidator.ts +++ b/src/ui/settings/components/ApiKeyValidator.ts @@ -1,5 +1,6 @@ import { Notice, requestUrl } from 'obsidian'; import type SpeechToTextPlugin from '../../../main'; +import { isPlainRecord } from '../../../types/guards'; /** * API 키 검증 컴포넌트 @@ -59,9 +60,13 @@ export class ApiKeyValidator { // 성공적으로 응답을 받으면 유효한 키 if (response.status === 200) { // Whisper 모델 존재 확인 - const models = response.json.data || []; + const data = isPlainRecord(response.json) ? response.json.data : undefined; + const models = Array.isArray(data) ? data : []; const hasWhisper = models.some( - (model: any) => model.id && model.id.includes('whisper') + (model) => + isPlainRecord(model) && + typeof model.id === 'string' && + model.id.includes('whisper') ); if (!hasWhisper) { @@ -214,11 +219,11 @@ export class ApiKeyValidator { } private getErrorStatus(error: unknown): number | null { - if (!error || typeof error !== 'object') { + if (!isPlainRecord(error)) { return null; } - const status = Reflect.get(error, 'status'); + const status = error.status; if (typeof status === 'number') { return status; } diff --git a/src/ui/settings/components/AudioSettings.ts b/src/ui/settings/components/AudioSettings.ts index 937a986..f87417f 100644 --- a/src/ui/settings/components/AudioSettings.ts +++ b/src/ui/settings/components/AudioSettings.ts @@ -76,7 +76,7 @@ export class AudioSettings { // 온도 설정 const temperatureSetting = new Setting(containerEl) - .setName('온도 (Temperature)') + .setName('온도 (temperature)') .setDesc('텍스트 생성의 창의성 수준 (0: 보수적, 1: 창의적)'); const tempValue = containerEl.createDiv({ cls: 'temperature-value' }); diff --git a/src/ui/settings/components/ProviderSettings.ts b/src/ui/settings/components/ProviderSettings.ts index 8c82dcf..3c393d2 100644 --- a/src/ui/settings/components/ProviderSettings.ts +++ b/src/ui/settings/components/ProviderSettings.ts @@ -81,7 +81,7 @@ export class ProviderSettings { .addDropdown((dropdown) => { dropdown .addOption('auto', '🤖 Automatic (recommended)') - .addOption('whisper', '🎯 OpenAI Whisper') + .addOption('whisper', '🎯 OpenAI whisper') .addOption('deepgram', '🚀 Deepgram') .setValue(this.currentProvider) .onChange(async (value) => { @@ -219,11 +219,11 @@ export class ProviderSettings { .setDesc('How should the system choose between providers?') .addDropdown((dropdown) => { dropdown - .addOption(SelectionStrategy.COST_OPTIMIZED, '💰 Cost Optimized') - .addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ Performance Optimized') - .addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ Quality Optimized') - .addOption(SelectionStrategy.ROUND_ROBIN, '🔄 Round Robin') - .addOption(SelectionStrategy.AB_TEST, '🧪 A/B Testing') + .addOption(SelectionStrategy.COST_OPTIMIZED, '💰 Cost optimized') + .addOption(SelectionStrategy.PERFORMANCE_OPTIMIZED, '⚡ Performance optimized') + .addOption(SelectionStrategy.QUALITY_OPTIMIZED, '✨ Quality optimized') + .addOption(SelectionStrategy.ROUND_ROBIN, '🔄 Round robin') + .addOption(SelectionStrategy.AB_TEST, '🧪 A/B testing') .setValue( this.plugin.settings.selectionStrategy || SelectionStrategy.PERFORMANCE_OPTIMIZED @@ -492,11 +492,11 @@ export class ProviderSettings { cls: 'mod-cta validate-btn', }); - validateBtn.addEventListener('click', async () => { + validateBtn.addEventListener('click', () => { validateBtn.disabled = true; validateBtn.textContent = 'Verifying...'; - const isValid = await this.validateApiKey(provider, inputEl.value); + const isValid = this.validateApiKey(provider, inputEl.value); if (isValid) { new Notice(`✅ ${this.getProviderDisplayName(provider)} API key verified!`); @@ -515,21 +515,23 @@ export class ProviderSettings { * 입력 핸들러 추가 */ private addInputHandler(inputEl: HTMLInputElement, provider: TranscriptionProvider): void { - inputEl.addEventListener('change', async () => { - const value = inputEl.value; + inputEl.addEventListener('change', () => { + void (async () => { + const value = inputEl.value; - // 마스킹된 값이면 무시 - if (value.includes('***')) return; + // 마스킹된 값이면 무시 + if (value.includes('***')) return; - // 형식 검증 - if (this.validateKeyFormat(provider, value)) { - this.apiKeys.set(provider, value); - await this.saveApiKey(provider, value); - inputEl.removeClass('invalid'); - } else { - inputEl.addClass('invalid'); - new Notice(`Invalid ${this.getProviderDisplayName(provider)} key format`); - } + // 형식 검증 + if (this.validateKeyFormat(provider, value)) { + this.apiKeys.set(provider, value); + await this.saveApiKey(provider, value); + inputEl.removeClass('invalid'); + } else { + inputEl.addClass('invalid'); + new Notice(`Invalid ${this.getProviderDisplayName(provider)} key format`); + } + })(); }); } @@ -572,8 +574,8 @@ export class ProviderSettings { private showProviderInfo(provider: string): void { const info: Record = { auto: '🤖 System will automatically select the best provider based on performance and availability', - whisper: '🎯 OpenAI Whisper - High accuracy, supports 50+ languages', - deepgram: '🚀 Deepgram - Fast real-time transcription with excellent accuracy', + whisper: '🎯 OpenAI whisper - high accuracy, supports 50+ languages', + deepgram: '🚀 Deepgram - fast real-time transcription with excellent accuracy', }; new Notice(info[provider] || 'Provider selected'); @@ -599,7 +601,7 @@ export class ProviderSettings { */ private getProviderDisplayName(provider: TranscriptionProvider): string { const names: Record = { - whisper: 'OpenAI Whisper', + whisper: 'OpenAI whisper', deepgram: 'Deepgram', }; return names[provider] || provider; @@ -693,14 +695,7 @@ export class ProviderSettings { } private isSelectionStrategy(value: string): value is SelectionStrategy { - return ( - value === SelectionStrategy.MANUAL || - value === SelectionStrategy.COST_OPTIMIZED || - value === SelectionStrategy.PERFORMANCE_OPTIMIZED || - value === SelectionStrategy.QUALITY_OPTIMIZED || - value === SelectionStrategy.ROUND_ROBIN || - value === SelectionStrategy.AB_TEST - ); + return Object.values(SelectionStrategy).includes(value as SelectionStrategy); } private async saveApiKey(provider: TranscriptionProvider, key: string): Promise { diff --git a/src/ui/settings/components/ShortcutSettings.ts b/src/ui/settings/components/ShortcutSettings.ts index 01df5ec..7f49203 100644 --- a/src/ui/settings/components/ShortcutSettings.ts +++ b/src/ui/settings/components/ShortcutSettings.ts @@ -40,7 +40,7 @@ export class ShortcutSettings { button .setButtonText('기본값 복원') .setWarning() - .onClick(async () => { + .onClick(() => { new ConfirmationModal( this.app, '단축키 초기화', @@ -327,7 +327,10 @@ export class ShortcutSettings { if (!keyMap.has(info.key)) { keyMap.set(info.key, []); } - keyMap.get(info.key)!.push(commandId); + const commands = keyMap.get(info.key); + if (commands) { + commands.push(commandId); + } }); const conflicts: Array<{ key: string; commands: string[] }> = []; diff --git a/src/ui/settings/error/ErrorHandling.ts b/src/ui/settings/error/ErrorHandling.ts index a60c822..70d4850 100644 --- a/src/ui/settings/error/ErrorHandling.ts +++ b/src/ui/settings/error/ErrorHandling.ts @@ -1,5 +1,6 @@ import { Notice, Platform } from 'obsidian'; import type { App } from 'obsidian'; +import { isPlainRecord } from '../../../types/guards'; /** * 에러 타입 정의 @@ -66,21 +67,25 @@ function normalizeError(error: unknown): Error { } function getFieldFromDetails(details: unknown): string | null { - if (!details || typeof details !== 'object') { + if (!isPlainRecord(details)) { return null; } - const field = Reflect.get(details, 'field'); + const field = details.field; return typeof field === 'string' ? field : null; } function getRetryCallback(details: unknown): (() => void) | null { - if (!details || typeof details !== 'object') { + if (!isPlainRecord(details)) { return null; } - const retry = Reflect.get(details, 'retry'); - return typeof retry === 'function' ? retry : null; + const retry = details.retry; + return typeof retry === 'function' + ? () => { + retry(); + } + : null; } /** @@ -310,7 +315,7 @@ export class ErrorBoundary { * 에러 세부사항 가져오기 */ private getErrorDetails(error: Error): string { - const details: any = { + const details: Record = { name: error.name, message: this.getSafeErrorMessage(error), stack: error.stack?.split('\n').slice(0, 5).join('\n'), @@ -393,8 +398,12 @@ export class ErrorBoundary { */ private saveErrorLog(errorData: Record): void { try { - const storedData = this.app.loadLocalStorage('settings-error-logs'); - const logs = storedData ? JSON.parse(storedData) : []; + const storedDataRaw = this.app.loadLocalStorage('settings-error-logs') as unknown; + const storedData = typeof storedDataRaw === 'string' ? storedDataRaw : null; + const parsed = storedData ? (JSON.parse(storedData) as unknown) : []; + const logs: Record[] = Array.isArray(parsed) + ? parsed.filter(isPlainRecord) + : []; logs.push(errorData); // 최대 50개의 로그만 유지 diff --git a/src/ui/settings/provider/ProviderSettingsContainer.ts b/src/ui/settings/provider/ProviderSettingsContainer.ts index 0205e83..409d7a6 100644 --- a/src/ui/settings/provider/ProviderSettingsContainer.ts +++ b/src/ui/settings/provider/ProviderSettingsContainer.ts @@ -195,7 +195,7 @@ export class ProviderSettingsContainer { .addDropdown((dropdown) => { dropdown .addOption('auto', '🤖 Automatic (recommended)') - .addOption('whisper', '🎯 OpenAI Whisper only') + .addOption('whisper', '🎯 OpenAI whisper only') .addOption('deepgram', '🚀 Deepgram only') .setValue(this.currentProvider) .onChange(async (value) => { @@ -358,7 +358,7 @@ export class ProviderSettingsContainer { */ private getProviderDisplayName(provider: string): string { const names: Record = { - whisper: 'OpenAI Whisper', + whisper: 'OpenAI whisper', deepgram: 'Deepgram', auto: 'Automatic', }; @@ -400,7 +400,7 @@ export class ProviderSettingsContainer { private showProviderNotice(provider: string): void { const messages: Record = { auto: '🤖 System will automatically select the best provider', - whisper: '🎯 Using OpenAI Whisper exclusively', + whisper: '🎯 Using OpenAI whisper exclusively', deepgram: '🚀 Using Deepgram exclusively', }; new Notice(messages[provider] || 'Provider updated'); @@ -427,7 +427,7 @@ export class ProviderSettingsContainer { ], }, { - title: '🎯 OpenAI Whisper', + title: '🎯 OpenAI whisper', bullets: [ 'Excellent accuracy for 50+ languages', 'Best for long-form content', @@ -579,10 +579,10 @@ export class ProviderSettingsContainer { await new Promise((resolve) => setTimeout(resolve, 2000)); // 시뮬레이션 notice.hide(); - new Notice('✅ Connection successful!', 3000); + new Notice('✅ Connection successful.', 3000); } catch (error) { notice.hide(); - new Notice('❌ Connection failed', 3000); + new Notice('❌ Connection failed.', 3000); console.error('Connection test error:', error); } } @@ -776,7 +776,7 @@ class ProviderDetailsModal extends Modal { } private getProviderName(): string { - return this.provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram'; + return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram'; } private renderStatus(containerEl: HTMLElement): void { @@ -841,10 +841,10 @@ class ProviderDetailsModal extends Modal { await new Promise((resolve) => setTimeout(resolve, 2000)); notice.hide(); - new Notice('✅ Connection test successful!', 3000); - } catch (error) { + new Notice('✅ Connection test successful.', 3000); + } catch { notice.hide(); - new Notice('❌ Connection test failed', 3000); + new Notice('❌ Connection test failed.', 3000); } } } diff --git a/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts b/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts index f73ab28..03e56e9 100644 --- a/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts +++ b/src/ui/settings/provider/ProviderSettingsContainerRefactored.ts @@ -287,7 +287,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { ).addDropdown((dropdown) => { dropdown .addOption('auto', '🤖 Automatic') - .addOption('whisper', '🎯 Whisper') + .addOption('whisper', '🎯 OpenAI whisper') .addOption('deepgram', '🚀 Deepgram') .setValue(this.state.get().currentProvider) .onChange((value) => { @@ -376,10 +376,10 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { * 메트릭 표시 */ private renderMetricsDisplay(containerEl: HTMLElement): void { - const _section = this.createSection(containerEl, '성능 메트릭', '최근 30일간 통계'); + this.createSection(containerEl, '성능 메트릭', '최근 30일간 통계'); // 메트릭 데이터 (예시) - const _metrics = this.memoized('metrics', () => this.calculateMetrics()); + this.memoized('metrics', () => this.calculateMetrics()); // 차트나 그래프로 표시 this.renderMetricsCharts(); @@ -491,9 +491,9 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { const connectionPromises = providers.map((provider) => { if (this.hasApiKey(provider)) { const isConnected = this.checkProviderConnection(provider); - return { provider, isConnected }; + return Promise.resolve({ provider, isConnected }); } - return { provider, isConnected: false }; + return Promise.resolve({ provider, isConnected: false }); }); const results = await Promise.all(connectionPromises); @@ -567,7 +567,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent { */ private getProviderDisplayName(provider: string): string { const names: Record = { - whisper: 'OpenAI Whisper', + whisper: 'OpenAI whisper', deepgram: 'Deepgram', auto: '자동', }; @@ -828,7 +828,7 @@ class ProviderDetailsModal extends Modal { } private getProviderName(): string { - return this.provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram'; + return this.provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram'; } private createStatusContent(): HTMLElement { diff --git a/src/ui/settings/provider/components/APIKeyManager.ts b/src/ui/settings/provider/components/APIKeyManager.ts index 6c340a2..479a511 100644 --- a/src/ui/settings/provider/components/APIKeyManager.ts +++ b/src/ui/settings/provider/components/APIKeyManager.ts @@ -225,7 +225,7 @@ export class APIKeyManager { cls: 'key-visibility-toggle', attr: { 'aria-label': 'Toggle visibility', - title: 'Show/Hide API key', + title: 'Show/hide API key', }, }); @@ -684,7 +684,7 @@ export class APIKeyManager { * Provider 이름 가져오기 */ private getProviderName(provider: TranscriptionProvider): string { - return provider === 'whisper' ? 'OpenAI Whisper' : 'Deepgram'; + return provider === 'whisper' ? 'OpenAI whisper' : 'Deepgram'; } /** @@ -795,7 +795,7 @@ export class APIKeyManager { modal.titleEl.setText('Environment variable setup'); const contentEl = modal.contentEl; - const instructions = contentEl.createDiv('env-var-instructions') as HTMLDivElement; + const instructions = contentEl.createDiv('env-var-instructions'); instructions.createEl('p', { text: 'To use environment variables for API keys:' }); const orderedList = instructions.createEl('ol'); @@ -903,12 +903,12 @@ export class APIKeyManager { if (this.plugin.settings.apiKey || this.plugin.settings.whisperApiKey) { try { const key = this.plugin.settings.whisperApiKey || this.plugin.settings.apiKey; - const encryptedData = JSON.parse(key!) as unknown; + const encryptedData: unknown = JSON.parse(key); if (isEncryptedData(encryptedData)) { const decrypted = await this.encryptor.decrypt(encryptedData); this.apiKeys.set('whisper', decrypted); } - } catch (error) { + } catch { // 암호화되지 않은 키일 수 있음 (마이그레이션) const key = this.plugin.settings.apiKey; if (key && !key.includes('*')) { @@ -924,7 +924,7 @@ export class APIKeyManager { const decrypted = await this.encryptor.decrypt(encryptedData); this.apiKeys.set('deepgram', decrypted); } - } catch (error) { + } catch { // 암호화되지 않은 키일 수 있음 const key = this.plugin.settings.deepgramApiKey; if (key && !key.includes('*')) { diff --git a/src/ui/settings/provider/components/APIKeyManagerRefactored.ts b/src/ui/settings/provider/components/APIKeyManagerRefactored.ts index 164c8d6..87e783d 100644 --- a/src/ui/settings/provider/components/APIKeyManagerRefactored.ts +++ b/src/ui/settings/provider/components/APIKeyManagerRefactored.ts @@ -66,7 +66,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent { [ 'whisper', { - name: 'OpenAI Whisper', + name: 'OpenAI whisper', placeholder: 'sk-...', pattern: /^sk-[A-Za-z0-9]{48,}$/, validateEndpoint: 'https://api.openai.com/v1/models', @@ -879,7 +879,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent { * 보안 설정 표시 */ private showSecuritySettings(): void { - const modal = new SecuritySettingsModal(this.plugin.app!, this.plugin); + const modal = new SecuritySettingsModal(this.plugin.app, this.plugin); modal.open(); } @@ -964,7 +964,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent { isValid: false, }); } - } catch (error) { + } catch { // 암호화되지 않은 키일 수 있음 if (!key.includes('*')) { this.apiKeys.set(provider, { diff --git a/src/ui/settings/provider/components/AdvancedSettingsPanel.ts b/src/ui/settings/provider/components/AdvancedSettingsPanel.ts index 1846957..6885f53 100644 --- a/src/ui/settings/provider/components/AdvancedSettingsPanel.ts +++ b/src/ui/settings/provider/components/AdvancedSettingsPanel.ts @@ -522,8 +522,8 @@ export class AdvancedSettingsPanel { button .setButtonText('Clear cache') .setWarning() - .onClick(async () => { - await this.clearCache(); + .onClick(() => { + this.clearCache(); }); }); } @@ -880,7 +880,7 @@ export class AdvancedSettingsPanel { */ private showABTestResults(): void { // TODO: Implement A/B test results modal - new Notice('A/B test results coming soon!'); + new Notice('A/B test results coming soon.'); } /** @@ -998,14 +998,7 @@ export class AdvancedSettingsPanel { } private isSelectionStrategy(value: string): value is SelectionStrategy { - return ( - value === SelectionStrategy.MANUAL || - value === SelectionStrategy.COST_OPTIMIZED || - value === SelectionStrategy.PERFORMANCE_OPTIMIZED || - value === SelectionStrategy.QUALITY_OPTIMIZED || - value === SelectionStrategy.ROUND_ROBIN || - value === SelectionStrategy.AB_TEST - ); + return Object.values(SelectionStrategy).includes(value as SelectionStrategy); } private isFallbackStrategy(value: string): value is 'auto' | 'none' | 'manual' { diff --git a/src/utils/async/AsyncManager.ts b/src/utils/async/AsyncManager.ts index 87aadec..d8eaa5c 100644 --- a/src/utils/async/AsyncManager.ts +++ b/src/utils/async/AsyncManager.ts @@ -9,6 +9,9 @@ /** * 취소 가능한 Promise 래퍼 */ +const toError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)); + export class CancellablePromise { private promise: Promise; private cancelled = false; @@ -35,7 +38,7 @@ export class CancellablePromise { }, (reason) => { if (!this.cancelled) { - reject(reason); + reject(toError(reason)); } }, this.abortController.signal @@ -47,8 +50,8 @@ export class CancellablePromise { * Promise 실행 */ then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | undefined | null + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null ): Promise { return this.promise.then(onfulfilled, onrejected); } @@ -57,7 +60,7 @@ export class CancellablePromise { * 에러 처리 */ catch( - onrejected?: ((reason: unknown) => TResult | PromiseLike) | undefined | null + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null ): Promise { return this.promise.catch(onrejected); } @@ -65,7 +68,7 @@ export class CancellablePromise { /** * Finally 처리 */ - finally(onfinally?: (() => void) | undefined | null): Promise { + finally(onfinally?: (() => void) | null): Promise { return this.promise.finally(onfinally); } @@ -173,7 +176,7 @@ export async function retryAsync(fn: () => Promise, options: RetryOptions lastError = error; if (attempt === maxAttempts || !shouldRetry(error, attempt)) { - throw error; + throw toError(error); } onRetry?.(error, attempt); @@ -187,7 +190,7 @@ export async function retryAsync(fn: () => Promise, options: RetryOptions } } - throw lastError; + throw toError(lastError ?? new Error('Unknown error')); } /** @@ -224,13 +227,10 @@ export function debounceAsync( } lastPromise = new Promise((resolve, reject) => { - timeoutId = window.setTimeout(async () => { - try { - const result = await fn(...args); - resolve(result); - } catch (error) { - reject(error); - } + timeoutId = window.setTimeout(() => { + fn(...args) + .then(resolve) + .catch((error) => reject(toError(error))); }, delay); }); @@ -375,7 +375,7 @@ export function allSettled(promises: Promise[]): Promise[ if (result.status === 'fulfilled') { return { status: 'fulfilled', value: result.value }; } else { - return { status: 'rejected', reason: result.reason }; + return { status: 'rejected', reason: result.reason as unknown }; } }) ); @@ -405,8 +405,9 @@ export class AsyncQueue { resolve(result); return result; } catch (error) { - reject(error); - throw error; + const wrappedError = toError(error); + reject(wrappedError); + throw wrappedError; } }); diff --git a/src/utils/common/formatters.ts b/src/utils/common/formatters.ts index a9a9193..1f8c523 100644 --- a/src/utils/common/formatters.ts +++ b/src/utils/common/formatters.ts @@ -158,7 +158,7 @@ export function formatISODate(isoString: string, format = 'YYYY-MM-DD HH:mm'): s throw new Error('Invalid ISO date string'); } return formatDate(date, format); - } catch (error) { + } catch { return isoString; // 파싱 실패 시 원본 반환 } } diff --git a/src/utils/memory/ObjectPool.ts b/src/utils/memory/ObjectPool.ts index 718b037..be698ed 100644 --- a/src/utils/memory/ObjectPool.ts +++ b/src/utils/memory/ObjectPool.ts @@ -145,7 +145,10 @@ export class ObjectPool { */ private findAvailableObject(): T | null { while (this.available.length > 0) { - const obj = this.available.pop()!; + const obj = this.available.pop(); + if (!obj) { + return null; + } // 유효성 검사 if (!this.validate || this.validate(obj)) { @@ -311,9 +314,7 @@ export class ObjectPool { * 타입별 풀 매니저 */ export class PoolManager { - // Using any here to allow heterogeneous pool types under one registry - // without complex type gymnastics. - private static pools = new Map>(); + private static pools = new Map>(); /** * 풀 등록 @@ -321,11 +322,12 @@ export class PoolManager { static register(name: string, options: PoolOptions): ObjectPool { if (this.pools.has(name)) { console.warn(`Pool '${name}' already exists`); - return this.pools.get(name)! as ObjectPool; + const existing = this.pools.get(name); + return existing as ObjectPool; } const pool = new ObjectPool(options); - this.pools.set(name, pool); + this.pools.set(name, pool as ObjectPool); return pool; } diff --git a/src/utils/performance/PerformanceBenchmark.ts b/src/utils/performance/PerformanceBenchmark.ts index 4acb71b..2bceffb 100644 --- a/src/utils/performance/PerformanceBenchmark.ts +++ b/src/utils/performance/PerformanceBenchmark.ts @@ -147,7 +147,10 @@ export class PerformanceBenchmark { metadata, }; - const metrics = this.metrics.get(name)!; + const metrics = this.metrics.get(name); + if (!metrics) { + return; + } metrics.push(metric); // 최대 1000개까지만 유지 @@ -375,7 +378,7 @@ export class PerformanceBenchmark { try { clsObserver.observe({ type: 'layout-shift', buffered: true }); this.observers.push(clsObserver); - } catch (error) { + } catch { console.warn('Layout shift observation not supported'); } } @@ -471,22 +474,23 @@ export class PerformanceBenchmark { */ export function Benchmark(name?: string) { return function (target: object, propertyName: string, descriptor: PropertyDescriptor) { - const originalMethod = descriptor.value; + const originalMethod: unknown = descriptor.value; + if (typeof originalMethod !== 'function') { + return descriptor; + } + const method = originalMethod as (...args: unknown[]) => unknown; const metricName = name || `${target.constructor.name}.${propertyName}`; - if (originalMethod.constructor.name === 'AsyncFunction') { - descriptor.value = function (...args: unknown[]) { - return PerformanceBenchmark.measureAsync(metricName, () => - originalMethod.apply(this, args) + const isAsync = method.constructor.name === 'AsyncFunction'; + descriptor.value = function (this: unknown, ...args: unknown[]) { + if (isAsync) { + return PerformanceBenchmark.measureAsync( + metricName, + () => method.apply(this, args) as Promise ); - }; - } else { - descriptor.value = function (...args: unknown[]) { - return PerformanceBenchmark.measureSync(metricName, () => - originalMethod.apply(this, args) - ); - }; - } + } + return PerformanceBenchmark.measureSync(metricName, () => method.apply(this, args)); + }; return descriptor; }; diff --git a/tests/helpers/e2e.setup.ts b/tests/helpers/e2e.setup.ts index 36ecf8d..1958df3 100644 --- a/tests/helpers/e2e.setup.ts +++ b/tests/helpers/e2e.setup.ts @@ -16,6 +16,95 @@ global.TextDecoder = TextDecoder as any; jest.setTimeout(30000); +if (typeof HTMLMediaElement !== 'undefined') { + Object.defineProperty(HTMLMediaElement.prototype, 'play', { + configurable: true, + value: jest.fn().mockResolvedValue(undefined), + }); + Object.defineProperty(HTMLMediaElement.prototype, 'pause', { + configurable: true, + value: jest.fn(), + }); +} + +const activeTimeouts = new Set>(); +const activeIntervals = new Set>(); +const originalSetTimeout = global.setTimeout; +const originalSetInterval = global.setInterval; +const originalClearTimeout = global.clearTimeout; +const originalClearInterval = global.clearInterval; +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; +const originalConsoleInfo = console.info; +const originalConsoleDebug = console.debug; +const originalConsoleLog = console.log; + +const IGNORED_CONSOLE_WARNINGS = ['Workspace event API not available', '[E2E-Test]']; +const IGNORED_CONSOLE_ERRORS = ['Critical error in render()', '[E2E-Test]']; +const IGNORED_CONSOLE_INFO = [ + 'Whisper provider initialized', + 'Deepgram provider initialized', + 'Providers reinitialized with new configuration', +]; +const IGNORED_CONSOLE_DEBUG = ['Deepgram provider not initialized: disabled or missing API key']; +const IGNORED_CONSOLE_LOG = ['[DEBUG] Test message']; + +const shouldIgnoreConsoleMessage = (ignored: string[], args: unknown[]): boolean => + args.some((arg) => typeof arg === 'string' && ignored.some((text) => arg.includes(text))); + +console.warn = (...args: unknown[]) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_WARNINGS, args)) { + return; + } + originalConsoleWarn(...args); +}; +console.error = (...args: unknown[]) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_ERRORS, args)) { + return; + } + originalConsoleError(...args); +}; +console.info = (...args: unknown[]) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_INFO, args)) { + return; + } + originalConsoleInfo(...args); +}; +console.debug = (...args: unknown[]) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_DEBUG, args)) { + return; + } + originalConsoleDebug(...args); +}; +console.log = (...args: unknown[]) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_LOG, args)) { + return; + } + originalConsoleLog(...args); +}; + +global.setTimeout = ((...args: Parameters) => { + const id = originalSetTimeout(...args); + activeTimeouts.add(id); + return id; +}) as typeof setTimeout; + +global.setInterval = ((...args: Parameters) => { + const id = originalSetInterval(...args); + activeIntervals.add(id); + return id; +}) as typeof setInterval; + +global.clearTimeout = ((id: ReturnType) => { + activeTimeouts.delete(id); + return originalClearTimeout(id); +}) as typeof clearTimeout; + +global.clearInterval = ((id: ReturnType) => { + activeIntervals.delete(id); + return originalClearInterval(id); +}) as typeof clearInterval; + // Fetch API Mock global.fetch = jest.fn(); @@ -351,6 +440,11 @@ beforeEach(() => { // 테스트 완료 후 정리 afterEach(() => { + activeTimeouts.forEach((id) => originalClearTimeout(id)); + activeTimeouts.clear(); + activeIntervals.forEach((id) => originalClearInterval(id)); + activeIntervals.clear(); + jest.restoreAllMocks(); }); diff --git a/tests/helpers/testSetup.js b/tests/helpers/testSetup.js index b88f625..6ecbc9f 100644 --- a/tests/helpers/testSetup.js +++ b/tests/helpers/testSetup.js @@ -8,6 +8,88 @@ const { TextEncoder, TextDecoder } = require('util'); global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; +jest.setTimeout(15000); + +const activeTimeouts = new Set(); +const activeIntervals = new Set(); +const originalSetTimeout = global.setTimeout; +const originalSetInterval = global.setInterval; +const originalClearTimeout = global.clearTimeout; +const originalClearInterval = global.clearInterval; +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; +const originalConsoleInfo = console.info; +const originalConsoleDebug = console.debug; +const originalConsoleLog = console.log; + +const IGNORED_CONSOLE_WARNINGS = ['Workspace event API not available', '[E2E-Test]']; +const IGNORED_CONSOLE_ERRORS = ['Critical error in render()', '[E2E-Test]']; +const IGNORED_CONSOLE_INFO = [ + 'Whisper provider initialized', + 'Deepgram provider initialized', + 'Providers reinitialized with new configuration', +]; +const IGNORED_CONSOLE_DEBUG = ['Deepgram provider not initialized: disabled or missing API key']; +const IGNORED_CONSOLE_LOG = ['[DEBUG] Test message']; + +const shouldIgnoreConsoleMessage = (ignored, args) => + args.some( + (arg) => typeof arg === 'string' && ignored.some((text) => arg.includes(text)) + ); + +global.setTimeout = (...args) => { + const id = originalSetTimeout(...args); + activeTimeouts.add(id); + return id; +}; + +global.setInterval = (...args) => { + const id = originalSetInterval(...args); + activeIntervals.add(id); + return id; +}; + +global.clearTimeout = (id) => { + activeTimeouts.delete(id); + return originalClearTimeout(id); +}; + +global.clearInterval = (id) => { + activeIntervals.delete(id); + return originalClearInterval(id); +}; + +console.warn = (...args) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_WARNINGS, args)) { + return; + } + originalConsoleWarn(...args); +}; +console.error = (...args) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_ERRORS, args)) { + return; + } + originalConsoleError(...args); +}; +console.info = (...args) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_INFO, args)) { + return; + } + originalConsoleInfo(...args); +}; +console.debug = (...args) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_DEBUG, args)) { + return; + } + originalConsoleDebug(...args); +}; +console.log = (...args) => { + if (shouldIgnoreConsoleMessage(IGNORED_CONSOLE_LOG, args)) { + return; + } + originalConsoleLog(...args); +}; + if (typeof globalThis.createEl !== 'function') { globalThis.createEl = (tag, options = {}) => { const el = document.createElement(tag); @@ -193,9 +275,6 @@ expect.extend({ }, }); -// 테스트 타임아웃 설정 -jest.setTimeout(15000); - globalThis.__JEST_FAKE_TIME = Date.now(); if (typeof jest !== 'undefined' && typeof jest.advanceTimersByTime === 'function') { @@ -229,6 +308,11 @@ if (typeof jest !== 'undefined' && typeof jest.advanceTimersByTime === 'function } afterEach(() => { + activeTimeouts.forEach((id) => originalClearTimeout(id)); + activeTimeouts.clear(); + activeIntervals.forEach((id) => originalClearInterval(id)); + activeIntervals.clear(); + globalThis.__JEST_FAKE_TIME = Date.now(); try { const { requestUrl } = require('obsidian');