diff --git a/docs/obsidian-review-8004-issuecomment-4007884875-plan.md b/docs/obsidian-review-8004-issuecomment-4007884875-plan.md new file mode 100644 index 0000000..52c698b --- /dev/null +++ b/docs/obsidian-review-8004-issuecomment-4007884875-plan.md @@ -0,0 +1,56 @@ +# Plan for PR #8004 Follow-up + +- Source comment: https://github.com/obsidianmd/obsidian-releases/pull/8004#issuecomment-4007884875 +- Comment date: 2026-03-05 +- Branch baseline: `fix/pr8004-human-review` + +## Review Summary + +The reviewer has no more line-level comments for now, but asked for three remaining cleanup areas before a broader review: + +1. Choose either Korean or English for the UI and logs. +2. Prefix CSS selectors to avoid leaking styles outside the plugin. +3. Review SVG creation. + +## Decisions + +### 1. UI and log language + +- Choose English as the canonical language for user-facing UI copy and runtime logs. +- Translate remaining Korean notices, labels, button text, ARIA labels, and log messages in the active settings and shared UI paths. +- Leave developer comments unchanged unless they affect runtime text. + +### 2. CSS selector prefixing + +- Prefix provider-settings selectors with the existing `sn-` namespace. +- Update the corresponding DOM class names in `ProviderSettings.ts`. +- Keep selectors scoped under `.speech-to-text-settings` where practical. + +### 3. SVG creation + +- Audit the codebase for raw SVG HTML injection. +- Keep using Obsidian's `setIcon(...)` helper and `document.createElementNS(...)` for programmatic SVG. +- Only change code if a non-namespaced or string-based SVG path is found. + +## Execution Order + +1. Update shared settings/UI text and logs to English. +2. Prefix provider-settings CSS classes and sync the TypeScript renderers. +3. Finish the SVG audit and document whether code changes were needed. +4. Run targeted validation: typecheck, lint, and focused tests if string or selector changes affect coverage. + +## Validation Notes + +- Search for remaining Korean runtime strings in `src/`. +- Search for remaining unprefixed provider-settings selectors. +- Search for raw ` 0) { subtitle.setText( - `지원 형식: ${this.options.accept.map((ext) => `.${ext}`).join(', ')}` + `Supported formats: ${this.options.accept.map((ext) => `.${ext}`).join(', ')}` ); } if (this.options.maxFileSize > 0) { const sizeLimit = this.formatFileSize(this.options.maxFileSize); - subtitle.appendText(` | 최대 크기: ${sizeLimit}`); + subtitle.appendText(` | Maximum size: ${sizeLimit}`); } } @@ -240,7 +240,7 @@ export class FilePickerModal extends Modal { private createSelectedFilesSection(container: HTMLElement) { const section = container.createDiv('selected-files-section'); - section.createEl('h3', { text: '선택된 파일' }); + section.createEl('h3', { text: 'Selected files' }); const fileList = section.createDiv('selected-files-list'); this.updateSelectedFilesList(fileList); @@ -251,7 +251,7 @@ export class FilePickerModal extends Modal { if (this.selectedFiles.length === 0) { container.createEl('p', { - text: '선택된 파일이 없습니다', + text: 'No files selected', cls: 'no-files-message', }); return; @@ -285,7 +285,7 @@ export class FilePickerModal extends Modal { // 제거 버튼 const removeBtn = fileItem.createEl('button', { - text: '제거', + text: 'Remove', cls: 'remove-file-btn', }); removeBtn.onclick = () => { @@ -300,14 +300,14 @@ export class FilePickerModal extends Modal { new Setting(footer) .addButton((btn) => - btn.setButtonText('취소').onClick(() => { + btn.setButtonText('Cancel').onClick(() => { this.onCancel(); this.close(); }) ) .addButton((btn) => btn - .setButtonText(`선택 (${this.selectedFiles.length})`) + .setButtonText(`Select (${this.selectedFiles.length})`) .setCta() .setDisabled(this.selectedFiles.length === 0) .onClick(() => { @@ -319,7 +319,7 @@ export class FilePickerModal extends Modal { private async handleFileSelection(file: TFile) { // 중복 체크 if (this.selectedFiles.some((f) => f.path === file.path)) { - new Notice('이미 선택된 파일입니다'); + new Notice('That file has already been selected.'); return; } @@ -335,15 +335,15 @@ export class FilePickerModal extends Modal { if (!validation.valid) { const errors = - validation.errors?.map((error) => error.message).join('\n') || '알 수 없는 오류'; - new Notice(`파일 검증 실패:\n${errors}`); + validation.errors?.map((error) => error.message).join('\n') || 'Unknown error'; + new Notice(`File validation failed:\n${errors}`); return; } // 경고가 있는 경우 if (validation.warnings && validation.warnings.length > 0) { const warnings = validation.warnings.map((warning) => warning.message).join('\n'); - new Notice(`경고:\n${warnings}`); + new Notice(`Warnings:\n${warnings}`); } this.selectedFiles.push(file); @@ -351,7 +351,7 @@ export class FilePickerModal extends Modal { } private async handleDroppedFiles(files: File[]) { - this.progressIndicator.show('파일 처리 중...'); + this.progressIndicator.show('Processing files...'); for (const file of files) { // Obsidian Vault에서 파일 찾기 @@ -359,7 +359,7 @@ export class FilePickerModal extends Modal { if (vaultFile) { await this.handleFileSelection(vaultFile); } else { - new Notice(`Vault에서 파일을 찾을 수 없습니다: ${file.name}`); + new Notice(`Could not find the file in the vault: ${file.name}`); } } @@ -377,7 +377,10 @@ export class FilePickerModal extends Modal { return { valid: false, errors: [ - { code: 'VALIDATION_ERROR', message: `검증 실패: ${normalizedError.message}` }, + { + code: 'VALIDATION_ERROR', + message: `Validation failed: ${normalizedError.message}`, + }, ], }; } @@ -385,11 +388,11 @@ export class FilePickerModal extends Modal { private processSelectedFiles() { if (this.selectedFiles.length === 0) { - new Notice('선택된 파일이 없습니다'); + new Notice('No files selected.'); return; } - this.progressIndicator.show('파일 처리 중...', true); + this.progressIndicator.show('Processing files...', true); const results: FilePickerResult[] = []; @@ -416,7 +419,7 @@ export class FilePickerModal extends Modal { this.onChoose(results); this.close(); } else { - new Notice('유효한 파일이 없습니다'); + new Notice('No valid files were selected.'); } } @@ -490,13 +493,13 @@ export class FilePickerModal extends Modal { const submitBtn = this.modalEl.querySelector('.mod-cta'); if (submitBtn instanceof HTMLButtonElement) { submitBtn.disabled = this.selectedFiles.length === 0; - submitBtn.setText(`선택 (${this.selectedFiles.length})`); + submitBtn.setText(`Select (${this.selectedFiles.length})`); } } private mergeOptions(options: FilePickerOptions): Required { return { - title: options.title || '오디오 파일 선택', + title: options.title || 'Select audio files', accept: options.accept || ['m4a', 'mp3', 'wav', 'mp4'], maxFileSize: options.maxFileSize || 25 * 1024 * 1024, // 25MB multiple: options.multiple ?? false, diff --git a/src/ui/modals/FilePickerModalRefactored.ts b/src/ui/modals/FilePickerModalRefactored.ts index fe0f675..6a4b341 100644 --- a/src/ui/modals/FilePickerModalRefactored.ts +++ b/src/ui/modals/FilePickerModalRefactored.ts @@ -284,7 +284,7 @@ export class FilePickerModalRefactored extends Modal { try { // Check for duplicates if (this.state.selectedFiles.some((f) => f.path === file.path)) { - new Notice('이미 선택된 파일입니다'); + new Notice('That file has already been selected.'); return; } @@ -313,7 +313,7 @@ export class FilePickerModalRefactored extends Modal { } catch (error) { const normalizedError = this.normalizeError(error); this.logger?.error('File selection failed', normalizedError); - new Notice(`파일 선택 실패: ${normalizedError.message}`); + new Notice(`File selection failed: ${normalizedError.message}`); } }, 300); @@ -324,7 +324,7 @@ export class FilePickerModalRefactored extends Modal { if (this.state.isProcessing) return; this.state.isProcessing = true; - this.components.progressIndicator.show('파일 처리 중...'); + this.components.progressIndicator.show('Processing files...'); try { const results = await Promise.allSettled( @@ -335,7 +335,7 @@ export class FilePickerModalRefactored extends Modal { const failed = results.filter((r) => r.status === 'rejected').length; if (failed > 0) { - new Notice(`${failed}개 파일 처리 실패`); + new Notice(`${failed} file(s) failed to process.`); } if (successful > 0) { @@ -353,7 +353,7 @@ export class FilePickerModalRefactored extends Modal { private async processDroppedFile(file: File): Promise { const vaultFile = this.findVaultFile(file.name); if (!vaultFile) { - throw new Error(`Vault에서 파일을 찾을 수 없습니다: ${file.name}`); + throw new Error(`Could not find the file in the vault: ${file.name}`); } await this.handleFileSelection(vaultFile); @@ -372,7 +372,10 @@ export class FilePickerModalRefactored extends Modal { return { valid: false, errors: [ - { code: 'VALIDATION_ERROR', message: `검증 실패: ${normalizedError.message}` }, + { + code: 'VALIDATION_ERROR', + message: `Validation failed: ${normalizedError.message}`, + }, ], }; } @@ -404,7 +407,7 @@ export class FilePickerModalRefactored extends Modal { */ private renderEmptyState(container: HTMLElement): void { container.createEl('p', { - text: '선택된 파일이 없습니다', + text: 'No files selected', cls: 'no-files-message', }); } @@ -438,7 +441,7 @@ export class FilePickerModalRefactored extends Modal { } this.state.isProcessing = true; - this.components.progressIndicator.show('파일 처리 중...', true); + this.components.progressIndicator.show('Processing files...', true); try { const results = this.processSelectedFiles(); @@ -448,12 +451,12 @@ export class FilePickerModalRefactored extends Modal { this.onChoose(results); this.close(); } else { - new Notice('유효한 파일이 없습니다'); + new Notice('No valid files were selected.'); } } catch (error) { const normalizedError = this.normalizeError(error); this.logger?.error('Submit failed', normalizedError); - new Notice(`처리 실패: ${normalizedError.message}`); + new Notice(`Processing failed: ${normalizedError.message}`); } finally { this.state.isProcessing = false; this.components.progressIndicator.hide(); @@ -515,7 +518,7 @@ export class FilePickerModalRefactored extends Modal { const submitBtn = this.modalEl.querySelector('.mod-cta'); if (submitBtn instanceof HTMLButtonElement) { submitBtn.disabled = this.state.selectedFiles.length === 0; - submitBtn.setText(`선택 (${this.state.selectedFiles.length})`); + submitBtn.setText(`Select (${this.state.selectedFiles.length})`); } }); } @@ -525,8 +528,8 @@ export class FilePickerModalRefactored extends Modal { */ private showValidationError(validation: ValidationResult): void { const errors = - validation.errors?.map((error) => error.message).join('\n') || '알 수 없는 오류'; - new Notice(`파일 검증 실패:\n${errors}`); + validation.errors?.map((error) => error.message).join('\n') || 'Unknown error'; + new Notice(`File validation failed:\n${errors}`); } /** @@ -535,7 +538,7 @@ export class FilePickerModalRefactored extends Modal { private showValidationWarnings(validation: ValidationResult): void { const warnings = validation.warnings?.map((warning) => warning.message).join('\n') || ''; if (warnings) { - new Notice(`경고:\n${warnings}`); + new Notice(`Warnings:\n${warnings}`); } } @@ -551,7 +554,7 @@ export class FilePickerModalRefactored extends Modal { */ private mergeOptions(options: FilePickerOptions): Required { return { - title: options.title || '오디오 파일 선택', + title: options.title || 'Select audio files', accept: options.accept || ['m4a', 'mp3', 'wav', 'mp4'], maxFileSize: options.maxFileSize || 25 * 1024 * 1024, // 25MB multiple: options.multiple ?? false, @@ -629,11 +632,13 @@ class FilePickerUIBuilder { const parts: string[] = []; if (this.options.accept.length > 0) { - parts.push(`지원 형식: ${this.options.accept.map((ext) => `.${ext}`).join(', ')}`); + parts.push( + `Supported formats: ${this.options.accept.map((ext) => `.${ext}`).join(', ')}` + ); } if (this.options.maxFileSize > 0) { - parts.push(`최대 크기: ${this.formatFileSize(this.options.maxFileSize)}`); + parts.push(`Maximum size: ${this.formatFileSize(this.options.maxFileSize)}`); } subtitle.setText(parts.join(' | ')); @@ -687,7 +692,7 @@ class FilePickerUIBuilder { buildSelectedFilesSection(updateCallback: (container: HTMLElement) => void): void { const section = this.container.createDiv('selected-files-section'); - section.createEl('h3', { text: '선택된 파일' }); + section.createEl('h3', { text: 'Selected files' }); const fileList = section.createDiv('selected-files-list'); updateCallback(fileList); @@ -701,10 +706,10 @@ class FilePickerUIBuilder { const footer = this.container.createDiv('file-picker-footer'); new Setting(footer) - .addButton((btn) => btn.setButtonText('취소').onClick(onCancel)) + .addButton((btn) => btn.setButtonText('Cancel').onClick(onCancel)) .addButton((btn) => btn - .setButtonText(`선택 (${fileCount})`) + .setButtonText(`Select (${fileCount})`) .setCta() .setDisabled(fileCount === 0) .onClick(onSubmit) @@ -768,7 +773,7 @@ class SelectedFilesListRenderer { private renderRemoveButton(container: HTMLElement, file: TFile): void { const removeBtn = container.createEl('button', { - text: '제거', + text: 'Remove', cls: 'remove-file-btn', }); diff --git a/src/ui/notifications/NotificationSystem.ts b/src/ui/notifications/NotificationSystem.ts index b1cd3ca..13a3951 100644 --- a/src/ui/notifications/NotificationSystem.ts +++ b/src/ui/notifications/NotificationSystem.ts @@ -8,6 +8,7 @@ import type { App } from 'obsidian'; import { EventManager } from '../../application/EventManager'; +import { safeJsonParse } from '../../utils/common/helpers'; import { StatusIcon } from '../progress/LoadingIndicators'; export type NotificationType = 'success' | 'error' | 'warning' | 'info'; @@ -39,6 +40,12 @@ export interface NotificationAction { style?: 'primary' | 'secondary' | 'link'; } +type StoredNotification = { + id: string; + options: NotificationOptions; + timestamp: number; +}; + /** * Toast 알림 컴포넌트 */ @@ -65,7 +72,7 @@ export class ToastNotification { cls: `toast-container toast-container--${position}`, attr: { role: 'region', - 'aria-label': '알림 영역', + 'aria-label': 'Notification area', 'aria-live': 'polite', 'aria-atomic': 'false', }, @@ -152,7 +159,7 @@ export class ToastNotification { const closeBtn = createEl('button', { cls: 'toast__close', text: '×', - attr: { 'aria-label': '알림 닫기' }, + attr: { 'aria-label': 'Dismiss notification' }, }); closeBtn.addEventListener('click', () => this.dismiss(id)); toast.appendChild(closeBtn); @@ -274,8 +281,18 @@ export class ToastNotification { return; } try { - const storedData = this.app.loadLocalStorage('notifications'); - const notifications = storedData ? JSON.parse(storedData) : []; + const storedData: unknown = this.app.loadLocalStorage('notifications'); + if (typeof storedData !== 'string') { + this.app.saveLocalStorage( + 'notifications', + JSON.stringify([ + { id, options, timestamp: Date.now() } satisfies StoredNotification, + ]) + ); + return; + } + + const notifications = safeJsonParse(storedData, []); notifications.push({ id, options, timestamp: Date.now() }); this.app.saveLocalStorage('notifications', JSON.stringify(notifications)); } catch (e) { @@ -291,8 +308,12 @@ export class ToastNotification { return; } try { - const storedData = this.app.loadLocalStorage('notifications'); - const notifications = storedData ? JSON.parse(storedData) : []; + const storedData: unknown = this.app.loadLocalStorage('notifications'); + if (typeof storedData !== 'string') { + return; + } + + const notifications = safeJsonParse(storedData, []); const filtered = notifications.filter((n: { id: string }) => n.id !== id); this.app.saveLocalStorage('notifications', JSON.stringify(filtered)); } catch (e) { @@ -373,7 +394,7 @@ export class ModalNotification { const closeBtn = createEl('button', { cls: 'modal-notification__close', text: '×', - attr: { 'aria-label': '닫기' }, + attr: { 'aria-label': 'Close' }, }); closeBtn.addEventListener('click', () => { this.dismiss(); @@ -544,7 +565,7 @@ export class StatusBarNotification { const closeBtn = createEl('button', { cls: 'statusbar-notification__close', text: '×', - attr: { 'aria-label': '닫기' }, + attr: { 'aria-label': 'Close' }, }); closeBtn.addEventListener('click', () => this.hide()); this.currentNotification.appendChild(closeBtn); @@ -662,11 +683,11 @@ export class NotificationManager { static confirm(message: string, title?: string): Promise { return ModalNotification.show({ type: 'warning', - title: title || '확인', + title: title || 'Confirm', message, actions: [ - { label: '취소', callback: () => {}, style: 'secondary' }, - { label: '확인', callback: () => {}, style: 'primary' }, + { label: 'Cancel', callback: () => {}, style: 'secondary' }, + { label: 'Confirm', callback: () => {}, style: 'primary' }, ], }); } diff --git a/src/ui/progress/ProgressBar.ts b/src/ui/progress/ProgressBar.ts index 1c30531..e1a7190 100644 --- a/src/ui/progress/ProgressBar.ts +++ b/src/ui/progress/ProgressBar.ts @@ -120,7 +120,7 @@ export class ProgressBar { if (this.options.showTimeRemaining && !this.options.indeterminate) { this.timeRemainingElement = createEl('span', { cls: 'progress-bar__time-remaining', - text: '계산 중...', + text: 'Calculating...', }); infoContainer.appendChild(this.timeRemainingElement); } @@ -228,7 +228,7 @@ export class ProgressBar { if (!this.startTime) { this.startTime = Date.now(); - this.timeRemainingElement.textContent = '계산 중...'; + this.timeRemainingElement.textContent = 'Calculating...'; return; } @@ -237,9 +237,9 @@ export class ProgressBar { const remaining = estimatedTotal - elapsed; if (remaining > 0) { - this.timeRemainingElement.textContent = `남은 시간: ${this.formatTime(remaining)}`; + this.timeRemainingElement.textContent = `Time remaining: ${this.formatTime(remaining)}`; } else { - this.timeRemainingElement.textContent = '거의 완료...'; + this.timeRemainingElement.textContent = 'Almost done...'; } } @@ -252,11 +252,11 @@ export class ProgressBar { const hours = Math.floor(minutes / 60); if (hours > 0) { - return `${hours}시간 ${minutes % 60}분`; + return `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { - return `${minutes}분 ${seconds % 60}초`; + return `${minutes}m ${seconds % 60}s`; } else { - return `${seconds}초`; + return `${seconds}s`; } } @@ -504,14 +504,14 @@ export class MultiStepProgressBar { // ARIA 속성 업데이트 switch (status) { case 'completed': - stepEl.setAttribute('aria-label', `${step.label} 완료`); + stepEl.setAttribute('aria-label', `${step.label} completed`); break; case 'active': - stepEl.setAttribute('aria-label', `${step.label} 진행 중`); + stepEl.setAttribute('aria-label', `${step.label} in progress`); stepEl.setAttribute('aria-current', 'step'); break; case 'error': - stepEl.setAttribute('aria-label', `${step.label} 오류`); + stepEl.setAttribute('aria-label', `${step.label} error`); break; } } @@ -541,7 +541,7 @@ export class MultiStepProgressBar { setError(stepId: string, error: string) { this.updateStepStatus(stepId, 'error'); this.progressBar.setColor('error'); - this.progressBar.setLabel(`오류: ${error}`); + this.progressBar.setLabel(`Error: ${error}`); // 이벤트 발생 this.eventManager.emit('step:error', { stepId, error }); diff --git a/src/ui/progress/StatusMessage.ts b/src/ui/progress/StatusMessage.ts index f1f36ce..054fee0 100644 --- a/src/ui/progress/StatusMessage.ts +++ b/src/ui/progress/StatusMessage.ts @@ -8,7 +8,7 @@ import { EventManager } from '../../application/EventManager'; -export type Language = 'ko' | 'en' | 'ja' | 'zh'; +export type Language = 'en'; export type MessageType = 'info' | 'success' | 'warning' | 'error' | 'progress'; export interface StatusMessage { @@ -20,244 +20,76 @@ export interface StatusMessage { export interface LocalizedMessage { [key: string]: { - [lang in Language]: string; + en: string; }; } +const englishOnly = (message: string): { en: string } => ({ en: message }); + /** * 다국어 메시지 저장소 */ export class MessageStore { private static messages: LocalizedMessage = { - // 파일 선택 관련 - 'file.selecting': { - ko: '파일을 선택하는 중...', - en: 'Selecting file...', - ja: 'ファイルを選択中...', - zh: '正在选择文件...', - }, - 'file.selected': { - ko: '파일이 선택되었습니다: {fileName}', - en: 'File selected: {fileName}', - ja: 'ファイルが選択されました: {fileName}', - zh: '已选择文件: {fileName}', - }, - 'file.validating': { - ko: '파일을 검증하는 중...', - en: 'Validating file...', - ja: 'ファイルを検証中...', - zh: '正在验证文件...', - }, - 'file.invalid_format': { - ko: '지원하지 않는 파일 형식입니다. {supportedFormats} 형식만 지원됩니다.', - en: 'Unsupported file format. Only {supportedFormats} formats are supported.', - ja: 'サポートされていないファイル形式です。{supportedFormats}形式のみサポートされています。', - zh: '不支持的文件格式。仅支持 {supportedFormats} 格式。', - }, - 'file.too_large': { - ko: '파일이 너무 큽니다. 최대 {maxSize}까지 지원됩니다.', - en: 'File is too large. Maximum {maxSize} is supported.', - ja: 'ファイルが大きすぎます。最大{maxSize}までサポートされています。', - zh: '文件太大。最大支持 {maxSize}。', - }, - - // 업로드 관련 - 'upload.preparing': { - ko: '업로드를 준비하는 중...', - en: 'Preparing upload...', - ja: 'アップロードの準備中...', - zh: '正在准备上传...', - }, - 'upload.progress': { - ko: '업로드 중... {percentage}%', - en: 'Uploading... {percentage}%', - ja: 'アップロード中... {percentage}%', - zh: '正在上传... {percentage}%', - }, - 'upload.completed': { - ko: '업로드가 완료되었습니다', - en: 'Upload completed', - ja: 'アップロードが完了しました', - zh: '上传完成', - }, - 'upload.failed': { - ko: '업로드에 실패했습니다: {error}', - en: 'Upload failed: {error}', - ja: 'アップロードに失敗しました: {error}', - zh: '上传失败: {error}', - }, - - // API 처리 관련 - 'api.connecting': { - ko: 'API 서버에 연결하는 중...', - en: 'Connecting to API server...', - ja: 'APIサーバーに接続中...', - zh: '正在连接到API服务器...', - }, - 'api.processing': { - ko: '음성을 처리하는 중... 이 작업은 몇 분 정도 걸릴 수 있습니다.', - en: 'Processing audio... This may take a few minutes.', - ja: '音声を処理中... この作業には数分かかる場合があります。', - zh: '正在处理音频... 这可能需要几分钟。', - }, - 'api.transcribing': { - ko: '음성을 텍스트로 변환하는 중... {percentage}%', - en: 'Transcribing audio to text... {percentage}%', - ja: '音声をテキストに変換中... {percentage}%', - zh: '正在将音频转换为文本... {percentage}%', - }, - 'api.formatting': { - ko: '텍스트를 포맷팅하는 중...', - en: 'Formatting text...', - ja: 'テキストをフォーマット中...', - zh: '正在格式化文本...', - }, - 'api.completed': { - ko: '변환이 완료되었습니다', - en: 'Conversion completed', - ja: '変換が完了しました', - zh: '转换完成', - }, - 'api.rate_limit': { - ko: 'API 요청 한도에 도달했습니다. {retryAfter}초 후에 다시 시도해주세요.', - en: 'API rate limit reached. Please try again after {retryAfter} seconds.', - ja: 'APIリクエストの上限に達しました。{retryAfter}秒後に再試行してください。', - zh: '已达到API请求限制。请在 {retryAfter} 秒后重试。', - }, - - // 에러 메시지 - 'error.network': { - ko: '네트워크 연결에 실패했습니다. 인터넷 연결을 확인해주세요.', - en: 'Network connection failed. Please check your internet connection.', - ja: 'ネットワーク接続に失敗しました。インターネット接続を確認してください。', - zh: '网络连接失败。请检查您的互联网连接。', - }, - 'error.api_key_invalid': { - ko: 'API 키가 유효하지 않습니다. 설정에서 올바른 API 키를 입력해주세요.', - en: 'Invalid API key. Please enter a valid API key in settings.', - ja: 'APIキーが無効です。設定で正しいAPIキーを入力してください。', - zh: 'API密钥无效。请在设置中输入有效的API密钥。', - }, - 'error.api_key_missing': { - ko: 'API 키가 설정되지 않았습니다. 설정에서 API 키를 입력해주세요.', - en: 'API key is not set. Please enter an API key in settings.', - ja: 'APIキーが設定されていません。設定でAPIキーを入力してください。', - zh: 'API密钥未设置。请在设置中输入API密钥。', - }, - 'error.server': { - ko: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', - en: 'Server error occurred. Please try again later.', - ja: 'サーバーエラーが発生しました。しばらくしてから再試行してください。', - zh: '服务器错误。请稍后重试。', - }, - 'error.timeout': { - ko: '요청 시간이 초과되었습니다. 파일 크기를 줄이거나 네트워크 연결을 확인해주세요.', - en: 'Request timed out. Please reduce file size or check network connection.', - ja: 'リクエストがタイムアウトしました。ファイルサイズを減らすか、ネットワーク接続を確認してください。', - zh: '请求超时。请减小文件大小或检查网络连接。', - }, - 'error.unknown': { - ko: '알 수 없는 오류가 발생했습니다: {error}', - en: 'Unknown error occurred: {error}', - ja: '不明なエラーが発生しました: {error}', - zh: '发生未知错误: {error}', - }, - - // 성공 메시지 - 'success.saved': { - ko: '저장되었습니다', - en: 'Saved', - ja: '保存されました', - zh: '已保存', - }, - 'success.copied': { - ko: '클립보드에 복사되었습니다', - en: 'Copied to clipboard', - ja: 'クリップボードにコピーされました', - zh: '已复制到剪贴板', - }, - 'success.inserted': { - ko: '텍스트가 삽입되었습니다', - en: 'Text inserted', - ja: 'テキストが挿入されました', - zh: '文本已插入', - }, - - // 작업 단계 - 'step.initializing': { - ko: '초기화 중...', - en: 'Initializing...', - ja: '初期化中...', - zh: '正在初始化...', - }, - 'step.file_selection': { - ko: '파일 선택', - en: 'File selection', - ja: 'ファイル選択', - zh: '文件选择', - }, - 'step.file_validation': { - ko: '파일 검증', - en: 'File validation', - ja: 'ファイル検証', - zh: '文件验证', - }, - 'step.uploading': { - ko: '업로드', - en: 'Uploading', - ja: 'アップロード', - zh: '上传', - }, - 'step.processing': { - ko: 'API 처리', - en: 'API processing', - ja: 'API処理', - zh: 'API处理', - }, - 'step.formatting': { - ko: '포맷팅', - en: 'Formatting', - ja: 'フォーマット', - zh: '格式化', - }, - 'step.completed': { - ko: '완료', - en: 'Completed', - ja: '完了', - zh: '完成', - }, - - // 해결 방법 - 'solution.check_internet': { - ko: '해결 방법: 인터넷 연결을 확인하고 다시 시도해주세요.', - en: 'Solution: Check your internet connection and try again.', - ja: '解決方法: インターネット接続を確認して再試行してください。', - zh: '解决方案: 检查您的互联网连接并重试。', - }, - 'solution.check_api_key': { - ko: '해결 방법: 설정에서 올바른 API 키를 입력했는지 확인해주세요.', - en: 'Solution: Make sure you have entered the correct API key in settings.', - ja: '解決方法: 設定で正しいAPIキーを入力したことを確認してください。', - zh: '解决方案: 确保您在设置中输入了正确的API密钥。', - }, - 'solution.reduce_file_size': { - ko: '해결 방법: 파일 크기를 줄이거나 더 짧은 음성 파일을 사용해주세요.', - en: 'Solution: Reduce file size or use a shorter audio file.', - ja: '解決方法: ファイルサイズを減らすか、より短い音声ファイルを使用してください。', - zh: '解决方案: 减小文件大小或使用较短的音频文件。', - }, - 'solution.retry_later': { - ko: '해결 방법: 잠시 후 다시 시도해주세요.', - en: 'Solution: Please try again later.', - ja: '解決方法: しばらくしてから再試行してください。', - zh: '解决方案: 请稍后重试。', - }, + 'file.selecting': englishOnly('Selecting file...'), + 'file.selected': englishOnly('File selected: {fileName}'), + 'file.validating': englishOnly('Validating file...'), + 'file.invalid_format': englishOnly( + 'Unsupported file format. Only {supportedFormats} formats are supported.' + ), + 'file.too_large': englishOnly('File is too large. Maximum {maxSize} is supported.'), + 'upload.preparing': englishOnly('Preparing upload...'), + 'upload.progress': englishOnly('Uploading... {percentage}%'), + 'upload.completed': englishOnly('Upload completed'), + 'upload.failed': englishOnly('Upload failed: {error}'), + 'api.connecting': englishOnly('Connecting to API server...'), + 'api.processing': englishOnly('Processing audio... This may take a few minutes.'), + 'api.transcribing': englishOnly('Transcribing audio to text... {percentage}%'), + 'api.formatting': englishOnly('Formatting text...'), + 'api.completed': englishOnly('Conversion completed'), + 'api.rate_limit': englishOnly( + 'API rate limit reached. Please try again after {retryAfter} seconds.' + ), + 'error.network': englishOnly( + 'Network connection failed. Please check your internet connection.' + ), + 'error.api_key_invalid': englishOnly( + 'Invalid API key. Please enter a valid API key in settings.' + ), + 'error.api_key_missing': englishOnly( + 'API key is not set. Please enter an API key in settings.' + ), + 'error.server': englishOnly('Server error occurred. Please try again later.'), + 'error.timeout': englishOnly( + 'Request timed out. Please reduce file size or check network connection.' + ), + 'error.unknown': englishOnly('Unknown error occurred: {error}'), + 'success.saved': englishOnly('Saved'), + 'success.copied': englishOnly('Copied to clipboard'), + 'success.inserted': englishOnly('Text inserted'), + 'step.initializing': englishOnly('Initializing...'), + 'step.file_selection': englishOnly('File selection'), + 'step.file_validation': englishOnly('File validation'), + 'step.uploading': englishOnly('Uploading'), + 'step.processing': englishOnly('API processing'), + 'step.formatting': englishOnly('Formatting'), + 'step.completed': englishOnly('Completed'), + 'solution.check_internet': englishOnly( + 'Solution: Check your internet connection and try again.' + ), + 'solution.check_api_key': englishOnly( + 'Solution: Make sure you have entered the correct API key in settings.' + ), + 'solution.reduce_file_size': englishOnly( + 'Solution: Reduce file size or use a shorter audio file.' + ), + 'solution.retry_later': englishOnly('Solution: Please try again later.'), }; /** * 메시지 추가 */ - static addMessage(key: string, translations: { [lang in Language]: string }) { + static addMessage(key: string, translations: { en: string }) { this.messages[key] = translations; } @@ -308,7 +140,7 @@ export class MessageStore { export class StatusMessageDisplay { private element: HTMLElement | null = null; private messageQueue: StatusMessage[] = []; - private currentLanguage: Language = 'ko'; + private currentLanguage: Language = 'en'; private maxMessages = 50; private showTimestamp = true; private autoScroll = true; @@ -322,7 +154,7 @@ export class StatusMessageDisplay { autoScroll?: boolean; } = {} ) { - this.currentLanguage = options.language || 'ko'; + this.currentLanguage = options.language || 'en'; this.maxMessages = options.maxMessages || 50; this.showTimestamp = options.showTimestamp !== false; this.autoScroll = options.autoScroll !== false; @@ -330,7 +162,7 @@ export class StatusMessageDisplay { } private isLanguage(value: string): value is Language { - return value === 'ko' || value === 'en' || value === 'ja' || value === 'zh'; + return value === 'en'; } /** @@ -346,7 +178,7 @@ export class StatusMessageDisplay { const header = createEl('div', { cls: 'sn-status-message-display__header' }); const title = createEl('h3', { - text: this.getLocalizedText('Status messages', '상태 메시지'), + text: this.getLocalizedText('Status messages'), }); header.appendChild(title); @@ -357,12 +189,7 @@ export class StatusMessageDisplay { const langSelect = createEl('select', { cls: 'sn-status-message-display__lang-select' }); langSelect.setAttribute('aria-label', 'Language selection'); - const languages = [ - { value: 'ko', label: '한국어' }, - { value: 'en', label: 'English' }, - { value: 'ja', label: '日本語' }, - { value: 'zh', label: '中文' }, - ]; + const languages = [{ value: 'en', label: 'English' }]; if (langSelect instanceof HTMLSelectElement) { languages.forEach((lang) => { @@ -389,7 +216,7 @@ export class StatusMessageDisplay { // 클리어 버튼 const clearBtn = createEl('button', { cls: 'sn-status-message-display__clear', - text: this.getLocalizedText('Clear', '지우기'), + text: this.getLocalizedText('Clear'), }); clearBtn.addEventListener('click', () => this.clear()); controls.appendChild(clearBtn); @@ -550,8 +377,8 @@ export class StatusMessageDisplay { /** * 로컬라이즈된 텍스트 가져오기 */ - private getLocalizedText(en: string, ko: string): string { - return this.currentLanguage === 'ko' ? ko : en; + private getLocalizedText(en: string): string { + return en; } /** @@ -570,7 +397,7 @@ export class StatusMessageDisplay { export class StatusMessageManager { private static instance: StatusMessageManager; private display: StatusMessageDisplay | null = null; - private currentLanguage: Language = 'ko'; + private currentLanguage: Language = 'en'; private eventManager: EventManager; private constructor() { @@ -589,17 +416,7 @@ export class StatusMessageManager { * 언어 자동 감지 */ private detectLanguage() { - const lang = navigator.language.toLowerCase(); - - if (lang.startsWith('ko')) { - this.currentLanguage = 'en'; - } else if (lang.startsWith('ja')) { - this.currentLanguage = 'ja'; - } else if (lang.startsWith('zh')) { - this.currentLanguage = 'zh'; - } else { - this.currentLanguage = 'en'; - } + this.currentLanguage = 'en'; } /** diff --git a/src/ui/settings/SettingsTab.ts b/src/ui/settings/SettingsTab.ts index 5628840..0605ac8 100644 --- a/src/ui/settings/SettingsTab.ts +++ b/src/ui/settings/SettingsTab.ts @@ -49,7 +49,7 @@ export class SettingsTab extends PluginSettingTab { containerEl.addClass('speech-to-text-settings'); // Add main title - new Setting(containerEl).setName('Speech note').setHeading(); + new Setting(containerEl).setName('Speech Note').setHeading(); this.debug('Title setting created'); // Add debug info section at the top @@ -127,7 +127,7 @@ export class SettingsTab extends PluginSettingTab { this.createProviderSelection(providerContainer); // Provider별 설정 컨테이너 - const settingsContainer = containerEl.createEl('div', { cls: 'provider-settings' }); + const settingsContainer = containerEl.createEl('div', { cls: 'sn-provider-settings' }); // 선택된 Provider에 따라 동적으로 설정 표시 const currentProvider = this.plugin.settings.provider || 'auto'; @@ -153,7 +153,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) => { @@ -166,14 +166,14 @@ export class SettingsTab extends PluginSettingTab { // Provider별 설정 UI 업데이트 const settingsContainer = - containerEl.parentElement?.querySelector('.provider-settings'); + containerEl.parentElement?.querySelector('.sn-provider-settings'); this.debug('Settings container found:', !!settingsContainer); if (settingsContainer instanceof HTMLElement) { this.debug('Updating provider settings UI for:', value); this.renderProviderSettings(settingsContainer, value); } else { - console.error('Could not find .provider-settings container'); + console.error('Could not find .sn-provider-settings container'); } // Provider 정보 업데이트 @@ -182,7 +182,7 @@ export class SettingsTab extends PluginSettingTab { this.updateProviderInfo(infoEl, value); } - new Notice(`Provider changed to: ${value}`); + new Notice(`Provider changed to ${this.getProviderLabel(value)}.`); }); this.debug('Dropdown setup complete'); @@ -263,7 +263,7 @@ export class SettingsTab extends PluginSettingTab { .addOption('performance_optimized', 'Performance optimized') .addOption('quality_optimized', 'Quality optimized') .addOption('round_robin', 'Round robin') - .addOption('ab_test', 'A/b testing') + .addOption('ab_test', 'A/B testing') .setValue( this.plugin.settings.selectionStrategy || SelectionStrategy.PERFORMANCE_OPTIMIZED @@ -317,7 +317,7 @@ export class SettingsTab extends PluginSettingTab { // API Endpoint new Setting(containerEl) .setName('API endpoint') - .setDesc('Openai API endpoint (leave the default unless using a custom endpoint)') + .setDesc('OpenAI API endpoint (leave the default unless using a custom endpoint)') .addText((text) => text .setPlaceholder('https://api.openai.com/v1') @@ -340,7 +340,7 @@ export class SettingsTab extends PluginSettingTab { // Deepgram 전용 컨테이너 생성 const deepgramContainer = containerEl.createEl('div', { - cls: 'deepgram-settings-container', + cls: 'sn-deepgram-settings-container', }); this.debug('Deepgram container created:', deepgramContainer); @@ -366,8 +366,8 @@ 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') + .setName('OpenAI API key') + .setDesc('Enter your OpenAI API key for Whisper transcription') .addText((text) => { text.setPlaceholder('Sk-...') .setValue(this.maskApiKey(this.plugin.settings.apiKey || '')) @@ -378,7 +378,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.'); } }); @@ -440,7 +440,7 @@ 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.', }; @@ -459,11 +459,11 @@ export class SettingsTab extends PluginSettingTab { dropdown .addOption('auto', 'Auto-detect') .addOption('en', 'English') - .addOption('ko', '한국어') - .addOption('ja', '日本語') - .addOption('zh', '中文') - .addOption('es', 'Español') - .addOption('fr', 'Français') + .addOption('ko', 'Korean') + .addOption('ja', 'Japanese') + .addOption('zh', 'Chinese') + .addOption('es', 'Spanish') + .addOption('fr', 'French') .addOption('de', 'Deutsch') .setValue(this.plugin.settings.language || 'auto') .onChange(async (value) => { @@ -525,7 +525,7 @@ export class SettingsTab extends PluginSettingTab { if (provider === 'whisper' || provider === 'auto') { new Setting(containerEl) .setName('Whisper model') - .setDesc('Select the whisper model to use') + .setDesc('Select the Whisper model to use') .addDropdown((dropdown) => dropdown .addOption('whisper-1', 'Whisper v1 (default)') @@ -649,7 +649,7 @@ export class SettingsTab extends PluginSettingTab { .setDesc('If you find this plugin helpful, consider buying me a coffee ☕') .addButton((button) => button - .setButtonText('☕ buy me a coffee') + .setButtonText('☕ Buy me a coffee') .setCta() .onClick(() => { window.open('https://buymeacoffee.com/asyouplz', '_blank'); @@ -661,6 +661,18 @@ export class SettingsTab extends PluginSettingTab { return value === 'auto' || value === 'whisper' || value === 'deepgram'; } + private getProviderLabel(provider: 'auto' | 'whisper' | 'deepgram'): string { + switch (provider) { + case 'whisper': + return 'OpenAI Whisper'; + case 'deepgram': + return 'Deepgram'; + case 'auto': + default: + return 'Automatic'; + } + } + private isSelectionStrategy(value: string): value is SelectionStrategy { return ( value === SelectionStrategy.MANUAL || diff --git a/src/ui/settings/SimpleSettingsTab.ts b/src/ui/settings/SimpleSettingsTab.ts index 52b6825..7e3614d 100644 --- a/src/ui/settings/SimpleSettingsTab.ts +++ b/src/ui/settings/SimpleSettingsTab.ts @@ -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-...') @@ -129,9 +129,9 @@ export class SimpleSettingsTab extends PluginSettingTab { dropdown .addOption('auto', 'Auto-detect') .addOption('en', 'English') - .addOption('ko', '한국어') - .addOption('ja', '日本語') - .addOption('zh', '中文') + .addOption('ko', 'Korean') + .addOption('ja', 'Japanese') + .addOption('zh', 'Chinese') .setValue(this.plugin.settings.language || 'auto') .onChange(async (value) => { this.plugin.settings.language = value; diff --git a/src/ui/settings/accessibility/AccessibilityEnhancements.ts b/src/ui/settings/accessibility/AccessibilityEnhancements.ts index cebed49..573fb21 100644 --- a/src/ui/settings/accessibility/AccessibilityEnhancements.ts +++ b/src/ui/settings/accessibility/AccessibilityEnhancements.ts @@ -37,7 +37,7 @@ export class AccessibilityManager { private setupAriaRegions(): void { // 메인 영역 this.container.setAttribute('role', 'main'); - this.container.setAttribute('aria-label', '설정 패널'); + this.container.setAttribute('aria-label', 'Settings panel'); // 라이브 리전 생성 this.announcer.createLiveRegion(); @@ -46,7 +46,7 @@ export class AccessibilityManager { const nav = this.container.querySelector('.settings-nav'); if (nav) { nav.setAttribute('role', 'navigation'); - nav.setAttribute('aria-label', '설정 네비게이션'); + nav.setAttribute('aria-label', 'Settings navigation'); } } @@ -55,17 +55,17 @@ export class AccessibilityManager { */ private setupKeyboardShortcuts(): void { this.keyboardNav.registerShortcut('Alt+S', () => { - this.announcer.announce('설정 저장'); + this.announcer.announce('Settings saved'); // 저장 로직 }); this.keyboardNav.registerShortcut('Alt+R', () => { - this.announcer.announce('설정 초기화'); + this.announcer.announce('Settings reset'); // 초기화 로직 }); this.keyboardNav.registerShortcut('Escape', () => { - this.announcer.announce('설정 패널 닫기'); + this.announcer.announce('Settings panel closed'); // 닫기 로직 }); } @@ -151,10 +151,15 @@ export class ScreenReaderAnnouncer { this.createLiveRegion(); } + const liveRegion = this.liveRegion; + if (!liveRegion) { + return; + } + this.announcementQueue.push(message); if (priority === 'assertive') { - this.liveRegion!.setAttribute('aria-live', 'assertive'); + liveRegion.setAttribute('aria-live', 'assertive'); } void this.processQueue(); @@ -169,7 +174,10 @@ export class ScreenReaderAnnouncer { this.isProcessing = true; while (this.announcementQueue.length > 0) { - const message = this.announcementQueue.shift()!; + const message = this.announcementQueue.shift(); + if (!message) { + continue; + } if (this.liveRegion) { this.liveRegion.textContent = message; @@ -255,7 +263,10 @@ export class KeyboardNavigationManager { const shortcut = this.getShortcutKey(e); if (this.shortcuts.has(shortcut)) { e.preventDefault(); - this.shortcuts.get(shortcut)!(); + const handler = this.shortcuts.get(shortcut); + if (handler) { + handler(); + } return; } @@ -547,7 +558,7 @@ export class AriaHelper { element.setAttribute('aria-busy', String(isLoading)); if (isLoading) { - element.setAttribute('aria-label', '로딩 중...'); + element.setAttribute('aria-label', 'Loading...'); } else { element.removeAttribute('aria-label'); } diff --git a/src/ui/settings/base/BaseSettingsComponent.ts b/src/ui/settings/base/BaseSettingsComponent.ts index fd9df39..41980af 100644 --- a/src/ui/settings/base/BaseSettingsComponent.ts +++ b/src/ui/settings/base/BaseSettingsComponent.ts @@ -107,7 +107,7 @@ export abstract class BaseSettingsComponent { */ protected async withErrorHandling( operation: () => Promise, - errorMessage = '작업 중 오류가 발생했습니다' + errorMessage = 'An error occurred while processing the request.' ): Promise { try { return await operation(); @@ -168,7 +168,7 @@ export abstract class BaseSettingsComponent { */ protected async saveSettings(): Promise { await this.plugin.saveSettings(); - this.showNotice('설정이 저장되었습니다'); + this.showNotice('Settings saved.'); } /** diff --git a/src/ui/settings/base/CommonUIComponents.ts b/src/ui/settings/base/CommonUIComponents.ts index 21d7f50..c986eef 100644 --- a/src/ui/settings/base/CommonUIComponents.ts +++ b/src/ui/settings/base/CommonUIComponents.ts @@ -329,12 +329,12 @@ export class UIComponentFactory { if (details) { const detailsEl = errorEl.createEl('details'); - detailsEl.createEl('summary', { text: '자세히 보기' }); + detailsEl.createEl('summary', { text: 'View details' }); detailsEl.createEl('pre', { text: details, cls: 'error-details' }); } if (onRetry) { - new ButtonComponent(errorEl).setButtonText('다시 시도').onClick(onRetry); + new ButtonComponent(errorEl).setButtonText('Retry').onClick(onRetry); } return errorEl; @@ -346,8 +346,8 @@ export class UIComponentFactory { static showConfirmDialog( title: string, message: string, - confirmText = '확인', - cancelText = '취소' + confirmText = 'Confirm', + cancelText = 'Cancel' ): Promise { return new Promise((resolve) => { const modal = createEl('div', { cls: 'modal-container' }); @@ -412,7 +412,7 @@ export class FormValidator { */ required(field: string, value: unknown, message?: string): this { if (!value || (typeof value === 'string' && value.trim() === '')) { - this.errors.set(field, message || `${field}는 필수 항목입니다`); + this.errors.set(field, message || `${field} is required.`); } return this; } @@ -422,7 +422,7 @@ export class FormValidator { */ minLength(field: string, value: string, min: number, message?: string): this { if (value.length < min) { - this.errors.set(field, message || `${field}는 최소 ${min}자 이상이어야 합니다`); + this.errors.set(field, message || `${field} must be at least ${min} characters.`); } return this; } @@ -432,7 +432,7 @@ export class FormValidator { */ maxLength(field: string, value: string, max: number, message?: string): this { if (value.length > max) { - this.errors.set(field, message || `${field}는 최대 ${max}자까지 입력 가능합니다`); + this.errors.set(field, message || `${field} must be ${max} characters or fewer.`); } return this; } @@ -442,7 +442,7 @@ export class FormValidator { */ pattern(field: string, value: string, pattern: RegExp, message?: string): this { if (!pattern.test(value)) { - this.errors.set(field, message || `${field} 형식이 올바르지 않습니다`); + this.errors.set(field, message || `${field} has an invalid format.`); } return this; } @@ -452,7 +452,7 @@ export class FormValidator { */ range(field: string, value: number, min: number, max: number, message?: string): this { if (value < min || value > max) { - this.errors.set(field, message || `${field}는 ${min}에서 ${max} 사이여야 합니다`); + this.errors.set(field, message || `${field} must be between ${min} and ${max}.`); } return this; } diff --git a/src/ui/settings/components/AdvancedSettings.ts b/src/ui/settings/components/AdvancedSettings.ts index c55896f..d7ab278 100644 --- a/src/ui/settings/components/AdvancedSettings.ts +++ b/src/ui/settings/components/AdvancedSettings.ts @@ -28,12 +28,12 @@ export class AdvancedSettings { * 캐시 설정 */ private renderCacheSettings(containerEl: HTMLElement): void { - containerEl.createEl('h4', { text: '캐시 설정' }); + containerEl.createEl('h4', { text: 'Cache settings' }); // 캐시 활성화 new Setting(containerEl) - .setName('캐시 사용') - .setDesc('변환 결과를 캐시하여 동일한 파일 재처리 시 속도를 향상시킵니다') + .setName('Enable cache') + .setDesc('Cache transcription results to speed up repeated processing') .addToggle((toggle) => toggle.setValue(this.plugin.settings.enableCache).onChange(async (value) => { this.plugin.settings.enableCache = value; @@ -44,7 +44,7 @@ export class AdvancedSettings { new ConfirmationModal( this.plugin.app, 'Clear cache', - '기존 캐시를 삭제하시겠습니까?', + 'Do you want to delete the existing cache?', () => { this.clearCache(); } @@ -56,12 +56,12 @@ export class AdvancedSettings { // 캐시 TTL if (this.plugin.settings.enableCache) { const ttlSetting = new Setting(containerEl) - .setName('캐시 유효 기간') - .setDesc('캐시된 결과를 보관할 시간'); + .setName('Cache retention period') + .setDesc('How long to keep cached results'); const ttlValue = containerEl.createDiv({ cls: 'ttl-value' }); const currentHours = Math.round(this.plugin.settings.cacheTTL / (1000 * 60 * 60)); - ttlValue.setText(`${currentHours} 시간`); + ttlValue.setText(`${currentHours} h`); ttlSetting.addSlider((slider) => slider @@ -69,7 +69,7 @@ export class AdvancedSettings { .setValue(currentHours) .onChange(async (value) => { this.plugin.settings.cacheTTL = value * 60 * 60 * 1000; - ttlValue.setText(`${value} 시간`); + ttlValue.setText(`${value} h`); await this.plugin.saveSettings(); }) .setDynamicTooltip() @@ -78,8 +78,8 @@ export class AdvancedSettings { // 캐시 관리 const cacheManagement = new Setting(containerEl) - .setName('캐시 관리') - .setDesc('캐시된 데이터를 관리합니다'); + .setName('Cache management') + .setDesc('Manage cached data'); // 캐시 상태 표시 const cacheStatus = containerEl.createDiv({ cls: 'cache-status' }); @@ -87,7 +87,7 @@ export class AdvancedSettings { // 캐시 삭제 버튼 cacheManagement.addButton((button) => - button.setButtonText('캐시 삭제').onClick(() => { + button.setButtonText('Clear cache').onClick(() => { this.clearCache(); this.updateCacheStatus(cacheStatus); }) @@ -95,7 +95,7 @@ export class AdvancedSettings { // 캐시 통계 보기 cacheManagement.addButton((button) => - button.setButtonText('통계 보기').onClick(() => { + button.setButtonText('View statistics').onClick(() => { this.showCacheStatistics(); }) ); @@ -105,12 +105,12 @@ export class AdvancedSettings { * 로깅 설정 */ private renderLoggingSettings(containerEl: HTMLElement): void { - containerEl.createEl('h4', { text: '로깅 설정' }); + containerEl.createEl('h4', { text: 'Logging settings' }); // 디버그 로깅 new Setting(containerEl) - .setName('디버그 로깅') - .setDesc('상세한 디버그 정보를 콘솔에 출력합니다') + .setName('Debug logging') + .setDesc('Print detailed debug information to the console') .addToggle((toggle) => toggle .setValue( @@ -126,14 +126,14 @@ export class AdvancedSettings { // 로그 레벨 new Setting(containerEl) - .setName('로그 레벨') - .setDesc('출력할 로그의 최소 수준을 선택하세요') + .setName('Log level') + .setDesc('Choose the minimum log level to output') .addDropdown((dropdown) => dropdown - .addOption('error', '오류만') - .addOption('warn', '경고 이상') - .addOption('info', '정보 이상') - .addOption('debug', '모든 로그') + .addOption('error', 'Errors only') + .addOption('warn', 'Warnings and above') + .addOption('info', 'Info and above') + .addOption('debug', 'All logs') .setValue('info') .onChange(async (_value) => { // 로그 레벨 설정 @@ -143,8 +143,8 @@ export class AdvancedSettings { // 로그 파일 저장 new Setting(containerEl) - .setName('로그 파일 저장') - .setDesc('로그를 파일로 저장합니다') + .setName('Save logs to file') + .setDesc('Save log output to a file') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 로그 파일 저장 설정 @@ -154,24 +154,24 @@ export class AdvancedSettings { // 로그 관리 const logManagement = new Setting(containerEl) - .setName('로그 관리') - .setDesc('로그 데이터를 관리합니다'); + .setName('Log management') + .setDesc('Manage log data'); logManagement.addButton((button) => - button.setButtonText('로그 보기').onClick(() => { + button.setButtonText('View logs').onClick(() => { this.showLogs(); }) ); logManagement.addButton((button) => - button.setButtonText('로그 내보내기').onClick(() => { + button.setButtonText('Export logs').onClick(() => { this.exportLogs(); }) ); logManagement.addButton((button) => button - .setButtonText('로그 삭제') + .setButtonText('Clear logs') .setWarning() .onClick(() => { this.clearLogs(); @@ -183,17 +183,17 @@ export class AdvancedSettings { * 성능 설정 */ private renderPerformanceSettings(containerEl: HTMLElement): void { - containerEl.createEl('h4', { text: '성능 설정' }); + containerEl.createEl('h4', { text: 'Performance settings' }); // 동시 처리 수 new Setting(containerEl) - .setName('동시 처리') - .setDesc('동시에 처리할 수 있는 최대 작업 수') + .setName('Concurrent jobs') + .setDesc('Maximum number of jobs to process at the same time') .addDropdown((dropdown) => dropdown - .addOption('1', '1개 (안정적)') - .addOption('2', '2개') - .addOption('3', '3개 (빠름)') + .addOption('1', '1 job (stable)') + .addOption('2', '2 jobs') + .addOption('3', '3 jobs (fast)') .setValue('1') .onChange(async (_value) => { // 동시 처리 설정 @@ -203,8 +203,8 @@ export class AdvancedSettings { // 자동 재시도 new Setting(containerEl) - .setName('자동 재시도') - .setDesc('실패한 작업을 자동으로 재시도합니다') + .setName('Automatic retry') + .setDesc('Automatically retry failed jobs') .addToggle((toggle) => toggle.setValue(true).onChange(async (_value) => { // 자동 재시도 설정 @@ -214,14 +214,14 @@ export class AdvancedSettings { // 재시도 횟수 new Setting(containerEl) - .setName('최대 재시도 횟수') - .setDesc('실패 시 재시도할 최대 횟수') + .setName('Maximum retry count') + .setDesc('Maximum number of retries after a failure') .addDropdown((dropdown) => dropdown - .addOption('1', '1회') - .addOption('2', '2회') - .addOption('3', '3회') - .addOption('5', '5회') + .addOption('1', '1 retry') + .addOption('2', '2 retries') + .addOption('3', '3 retries') + .addOption('5', '5 retries') .setValue('3') .onChange(async (_value) => { // 재시도 횟수 설정 @@ -231,18 +231,18 @@ export class AdvancedSettings { // 타임아웃 설정 const timeoutSetting = new Setting(containerEl) - .setName('요청 타임아웃') - .setDesc('API 요청 타임아웃 시간 (초)'); + .setName('Request timeout') + .setDesc('API request timeout in seconds'); const timeoutValue = containerEl.createDiv({ cls: 'timeout-value' }); - timeoutValue.setText('30 초'); + timeoutValue.setText('30 s'); timeoutSetting.addSlider((slider) => slider .setLimits(10, 120, 5) .setValue(30) .onChange(async (value) => { - timeoutValue.setText(`${value} 초`); + timeoutValue.setText(`${value} s`); // 타임아웃 설정 저장 await this.plugin.saveSettings(); }) @@ -251,8 +251,8 @@ export class AdvancedSettings { // 메모리 최적화 new Setting(containerEl) - .setName('메모리 최적화') - .setDesc('메모리 사용을 최적화합니다 (큰 파일 처리 시 유용)') + .setName('Memory optimization') + .setDesc('Optimize memory usage for large files') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 메모리 최적화 설정 @@ -265,18 +265,18 @@ export class AdvancedSettings { * 실험적 기능 설정 */ private renderExperimentalSettings(containerEl: HTMLElement): void { - containerEl.createEl('h4', { text: '실험적 기능' }); + containerEl.createEl('h4', { text: 'Experimental features' }); const warningEl = containerEl.createDiv({ cls: 'experimental-warning' }); warningEl.createEl('span', { - text: '⚠️ 실험적 기능은 불안정할 수 있습니다', + text: 'Experimental features may be unstable.', cls: 'warning-text', }); // 배치 처리 new Setting(containerEl) - .setName('배치 처리') - .setDesc('여러 파일을 한 번에 처리합니다') + .setName('Batch processing') + .setDesc('Process multiple files in a single run') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 배치 처리 설정 @@ -286,8 +286,8 @@ export class AdvancedSettings { // 실시간 변환 new Setting(containerEl) - .setName('실시간 변환') - .setDesc('녹음과 동시에 실시간으로 변환합니다') + .setName('Real-time transcription') + .setDesc('Transcribe while recording in real time') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 실시간 변환 설정 @@ -298,8 +298,8 @@ export class AdvancedSettings { // 화자 분리 new Setting(containerEl) - .setName('화자 분리') - .setDesc('여러 화자를 구분하여 표시합니다') + .setName('Speaker diarization') + .setDesc('Separate and label multiple speakers') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 화자 분리 설정 @@ -310,8 +310,8 @@ export class AdvancedSettings { // 자동 번역 new Setting(containerEl) - .setName('자동 번역') - .setDesc('변환된 텍스트를 자동으로 번역합니다') + .setName('Automatic translation') + .setDesc('Automatically translate transcribed text') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 자동 번역 설정 @@ -332,12 +332,12 @@ export class AdvancedSettings { const cacheCount = this.getCacheCount(); statusEl.createEl('div', { - text: `캐시된 항목: ${cacheCount}개`, + text: `Cached entries: ${cacheCount}`, cls: 'cache-stat', }); statusEl.createEl('div', { - text: `전체 크기: ${this.formatBytes(cacheSize)}`, + text: `Total size: ${this.formatBytes(cacheSize)}`, cls: 'cache-stat', }); } @@ -349,9 +349,9 @@ export class AdvancedSettings { try { // Obsidian API를 통해 캐시 삭제 this.plugin.app.saveLocalStorage('speech-to-text-cache', null); - new Notice('캐시가 삭제되었습니다'); + new Notice('Cache cleared.'); } catch (error) { - new Notice('캐시 삭제 실패'); + new Notice('Failed to clear cache.'); console.error(error); } } @@ -365,10 +365,10 @@ export class AdvancedSettings { cacheHits: 75, cacheMisses: 25, hitRate: '75%', - avgSavings: '2.3초', + avgSavings: '2.3 s', }; - new Notice(`캐시 적중률: ${stats.hitRate}, 평균 절약 시간: ${stats.avgSavings}`); + new Notice(`Cache hit rate: ${stats.hitRate}, average time saved: ${stats.avgSavings}`); } /** @@ -376,7 +376,7 @@ export class AdvancedSettings { */ private showLogs(): void { // 로그 모달 표시 - new Notice('로그 뷰어는 준비 중입니다'); + new Notice('Log viewer is not available yet.'); } /** @@ -394,9 +394,9 @@ export class AdvancedSettings { a.click(); URL.revokeObjectURL(url); - new Notice('로그를 내보냈습니다'); + new Notice('Logs exported.'); } catch (error) { - new Notice('로그 내보내기 실패'); + new Notice('Failed to export logs.'); console.error(error); } } @@ -408,10 +408,10 @@ export class AdvancedSettings { new ConfirmationModal( this.plugin.app, 'Clear logs', - '모든 로그를 삭제하시겠습니까?', + 'Do you want to delete all logs?', () => { // 로그 삭제 로직 - new Notice('로그가 삭제되었습니다'); + new Notice('Logs cleared.'); } ).open(); } diff --git a/src/ui/settings/components/ApiKeyValidator.ts b/src/ui/settings/components/ApiKeyValidator.ts index d41c449..d3d27d5 100644 --- a/src/ui/settings/components/ApiKeyValidator.ts +++ b/src/ui/settings/components/ApiKeyValidator.ts @@ -16,20 +16,20 @@ export class ApiKeyValidator { */ validateFormat(apiKey: string): { valid: boolean; message?: string } { if (!apiKey) { - return { valid: false, message: 'API 키를 입력해주세요' }; + return { valid: false, message: 'Enter an API key.' }; } if (!apiKey.startsWith('sk-')) { - return { valid: false, message: 'API 키는 "sk-"로 시작해야 합니다' }; + return { valid: false, message: 'The API key must start with "sk-".' }; } if (apiKey.length < 40) { - return { valid: false, message: 'API 키가 너무 짧습니다' }; + return { valid: false, message: 'The API key is too short.' }; } // 프로젝트 키 형식 검증 (sk-proj-) if (apiKey.startsWith('sk-proj-') && apiKey.length < 50) { - return { valid: false, message: '프로젝트 API 키가 너무 짧습니다' }; + return { valid: false, message: 'The project API key is too short.' }; } return { valid: true }; @@ -42,7 +42,7 @@ export class ApiKeyValidator { // 먼저 형식 검증 const formatValidation = this.validateFormat(apiKey); if (!formatValidation.valid) { - new Notice(formatValidation.message || '유효하지 않은 API 키 형식'); + new Notice(formatValidation.message || 'Invalid API key format.'); return false; } @@ -70,7 +70,7 @@ export class ApiKeyValidator { ); if (!hasWhisper) { - new Notice('⚠ API 키는 유효하지만 whisper 모델 접근 권한이 없을 수 있습니다'); + new Notice('The API key is valid, but it may not have access to Whisper.'); } return true; @@ -81,19 +81,19 @@ export class ApiKeyValidator { const status = this.getErrorStatus(error); // 401: 인증 실패 (잘못된 키) if (status === 401) { - new Notice('❌ 유효하지 않은 API 키입니다'); + new Notice('Invalid API key.'); return false; } // 429: Rate limit (키는 유효하지만 한도 초과) if (status === 429) { - new Notice('⚠ API 키는 유효하지만 사용 한도를 초과했습니다'); + new Notice('The API key is valid, but the usage limit has been exceeded.'); return true; // 키 자체는 유효함 } // 기타 네트워크 오류 - console.error('API 키 검증 실패:', error); - new Notice('네트워크 오류로 API 키를 검증할 수 없습니다'); + console.error('API key validation failed:', error); + new Notice('The API key could not be validated because of a network error.'); return false; } } @@ -130,7 +130,7 @@ export class ApiKeyValidator { remaining, }; } catch (error) { - console.error('사용량 조회 실패:', error); + console.error('Failed to fetch API usage:', error); return null; } } @@ -165,7 +165,7 @@ export class ApiKeyValidator { const reversed = encoded.split('').reverse().join(''); return btoa(reversed); } catch (error) { - console.error('API 키 암호화 실패:', error); + console.error('Failed to encrypt API key:', error); return ''; } } @@ -182,7 +182,7 @@ export class ApiKeyValidator { const encoded = reversed.split('').reverse().join(''); return atob(encoded); } catch (error) { - console.error('API 키 복호화 실패:', error); + console.error('Failed to decrypt API key:', error); return ''; } } diff --git a/src/ui/settings/components/AudioSettings.ts b/src/ui/settings/components/AudioSettings.ts index ea8969a..8437273 100644 --- a/src/ui/settings/components/AudioSettings.ts +++ b/src/ui/settings/components/AudioSettings.ts @@ -11,23 +11,23 @@ export class AudioSettings { render(containerEl: HTMLElement): void { // 언어 설정 new Setting(containerEl) - .setName('언어') - .setDesc('음성 인식 언어를 선택하세요 (자동 감지 권장)') + .setName('Language') + .setDesc('Choose the speech recognition language (auto-detect recommended)') .addDropdown((dropdown) => dropdown - .addOption('auto', '자동 감지') + .addOption('auto', 'Auto-detect') .addOption('en', 'English') - .addOption('ko', '한국어') - .addOption('ja', '日本語') - .addOption('zh', '中文') - .addOption('es', 'Español') - .addOption('fr', 'Français') + .addOption('ko', 'Korean') + .addOption('ja', 'Japanese') + .addOption('zh', 'Chinese') + .addOption('es', 'Spanish') + .addOption('fr', 'French') .addOption('de', 'Deutsch') - .addOption('it', 'Italiano') - .addOption('pt', 'Português') - .addOption('ru', 'Русский') - .addOption('ar', 'العربية') - .addOption('hi', 'हिन्दी') + .addOption('it', 'Italian') + .addOption('pt', 'Portuguese') + .addOption('ru', 'Russian') + .addOption('ar', 'Arabic') + .addOption('hi', 'Hindi') .setValue(this.plugin.settings.language) .onChange(async (value) => { this.plugin.settings.language = value; @@ -37,11 +37,11 @@ export class AudioSettings { // Whisper 모델 선택 new Setting(containerEl) - .setName('Whisper 모델') - .setDesc('사용할 whisper 모델을 선택하세요') + .setName('Whisper model') + .setDesc('Choose which Whisper model to use') .addDropdown((dropdown) => dropdown - .addOption('whisper-1', 'Whisper-1 (기본)') + .addOption('whisper-1', 'Whisper-1 (default)') .setValue(this.plugin.settings.model) .onChange(async (value: string) => { if (value === 'whisper-1') { @@ -54,15 +54,15 @@ export class AudioSettings { // 응답 형식 new Setting(containerEl) - .setName('응답 형식') - .setDesc('API 응답 형식을 선택하세요') + .setName('Response format') + .setDesc('Choose the API response format') .addDropdown((dropdown) => dropdown - .addOption('json', 'JSON (구조화된 데이터)') - .addOption('text', 'Text (일반 텍스트)') - .addOption('verbose_json', 'Verbose JSON (상세 정보)') - .addOption('srt', 'Srt (자막 형식)') - .addOption('vtt', 'Vtt (webvtt 형식)') + .addOption('json', 'JSON (structured data)') + .addOption('text', 'Text (plain text)') + .addOption('verbose_json', 'Verbose JSON (detailed output)') + .addOption('srt', 'SRT (subtitle format)') + .addOption('vtt', 'VTT (WebVTT format)') .setValue( typeof this.plugin.settings['responseFormat'] === 'string' ? this.plugin.settings['responseFormat'] @@ -76,8 +76,8 @@ export class AudioSettings { // 온도 설정 const temperatureSetting = new Setting(containerEl) - .setName('온도 (temperature)') - .setDesc('텍스트 생성의 창의성 수준 (0: 보수적, 1: 창의적)'); + .setName('Temperature') + .setDesc('Creativity level for generated text (0 = focused, 1 = more varied)'); const tempValue = containerEl.createDiv({ cls: 'temperature-value' }); tempValue.setText(String(this.plugin.settings.temperature || 0)); @@ -96,11 +96,11 @@ export class AudioSettings { // 프롬프트 설정 new Setting(containerEl) - .setName('커스텀 프롬프트') - .setDesc('음성 인식을 가이드할 프롬프트를 입력하세요 (선택사항)') + .setName('Custom prompt') + .setDesc('Enter an optional prompt to guide speech recognition') .addTextArea((text) => text - .setPlaceholder('예: 의학 용어가 포함된 대화입니다.') + .setPlaceholder('Example: This recording contains medical terminology.') .setValue(this.plugin.settings.prompt || '') .onChange(async (value) => { this.plugin.settings.prompt = value; @@ -110,8 +110,8 @@ export class AudioSettings { // 파일 크기 제한 const fileSizeSetting = new Setting(containerEl) - .setName('최대 파일 크기') - .setDesc('업로드 가능한 최대 파일 크기 (mb)'); + .setName('Maximum file size') + .setDesc('Maximum upload size in MB'); const sizeValue = containerEl.createDiv({ cls: 'filesize-value' }); const currentSize = Math.round(this.plugin.settings.maxFileSize / (1024 * 1024)); @@ -131,8 +131,8 @@ export class AudioSettings { // 오디오 품질 설정 new Setting(containerEl) - .setName('오디오 전처리') - .setDesc('업로드 전 오디오 파일을 최적화합니다') + .setName('Audio preprocessing') + .setDesc('Optimize audio files before upload') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 오디오 전처리 설정 @@ -142,8 +142,8 @@ export class AudioSettings { // 노이즈 제거 new Setting(containerEl) - .setName('노이즈 제거') - .setDesc('배경 소음을 자동으로 제거합니다 (실험적)') + .setName('Noise reduction') + .setDesc('Automatically remove background noise (experimental)') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 노이즈 제거 설정 @@ -154,17 +154,17 @@ export class AudioSettings { // 지원 파일 형식 const formatInfo = containerEl.createDiv({ cls: 'format-info' }); - formatInfo.createEl('h4', { text: '지원 파일 형식' }); + formatInfo.createEl('h4', { text: 'Supported file formats' }); const formatList = formatInfo.createEl('ul'); const formats = [ - { ext: 'm4a', desc: 'Apple 오디오' }, - { ext: 'mp3', desc: 'MP3 오디오' }, - { ext: 'wav', desc: 'WAV 오디오' }, - { ext: 'mp4', desc: 'MP4 비디오 (오디오 추출)' }, - { ext: 'mpeg', desc: 'MPEG 오디오' }, - { ext: 'mpga', desc: 'MPEG 오디오' }, - { ext: 'webm', desc: 'WebM 오디오' }, + { ext: 'm4a', desc: 'Apple audio' }, + { ext: 'mp3', desc: 'MP3 audio' }, + { ext: 'wav', desc: 'WAV audio' }, + { ext: 'mp4', desc: 'MP4 video (audio extracted)' }, + { ext: 'mpeg', desc: 'MPEG audio' }, + { ext: 'mpga', desc: 'MPEG audio' }, + { ext: 'webm', desc: 'WebM audio' }, ]; formats.forEach((format) => { @@ -175,14 +175,14 @@ export class AudioSettings { // 제한사항 안내 const limitations = containerEl.createDiv({ cls: 'limitations-info' }); - limitations.createEl('h4', { text: '제한사항' }); + limitations.createEl('h4', { text: 'Limits' }); const limitList = limitations.createEl('ul'); const limits = [ - '최대 파일 크기: 25MB', - '최대 오디오 길이: 제한 없음 (큰 파일은 자동 분할)', - '동시 처리: 1개 파일', - 'API 요청 제한: 분당 50회', + 'Maximum file size: 25 MB', + 'Maximum audio length: unlimited (large files are split automatically)', + 'Concurrent processing: 1 file', + 'API request limit: 50 requests per minute', ]; limits.forEach((limit) => { diff --git a/src/ui/settings/components/GeneralSettings.ts b/src/ui/settings/components/GeneralSettings.ts index dd6412d..6839a61 100644 --- a/src/ui/settings/components/GeneralSettings.ts +++ b/src/ui/settings/components/GeneralSettings.ts @@ -12,8 +12,8 @@ export class GeneralSettings { render(containerEl: HTMLElement): void { // 자동 삽입 설정 new Setting(containerEl) - .setName('자동 삽입') - .setDesc('변환된 텍스트를 자동으로 노트에 삽입합니다') + .setName('Auto-insert transcription') + .setDesc('Automatically insert transcribed text into the active note') .addToggle((toggle) => toggle.setValue(this.plugin.settings.autoInsert).onChange(async (value) => { this.plugin.settings.autoInsert = value; @@ -23,13 +23,13 @@ export class GeneralSettings { // 삽입 위치 설정 new Setting(containerEl) - .setName('삽입 위치') - .setDesc('텍스트를 삽입할 위치를 선택하세요') + .setName('Insert position') + .setDesc('Choose where the transcribed text should be inserted') .addDropdown((dropdown) => dropdown - .addOption('cursor', '커서 위치') - .addOption('end', '노트 끝') - .addOption('beginning', '노트 시작') + .addOption('cursor', 'At cursor position') + .addOption('end', 'At end of note') + .addOption('beginning', 'At beginning of note') .setValue(this.plugin.settings.insertPosition) .onChange(async (value: string) => { if (this.isInsertPosition(value)) { @@ -41,17 +41,17 @@ export class GeneralSettings { // 텍스트 포맷 설정 new Setting(containerEl) - .setName('기본 텍스트 형식') - .setDesc('변환된 텍스트의 기본 형식을 선택하세요') + .setName('Default text format') + .setDesc('Choose the default format for transcribed text') .addDropdown((dropdown) => dropdown - .addOption('plain', '일반 텍스트') - .addOption('markdown', '마크다운') - .addOption('quote', '인용구') - .addOption('bullet', '글머리 기호') - .addOption('heading', '제목') - .addOption('code', '코드 블록') - .addOption('callout', '콜아웃') + .addOption('plain', 'Plain text') + .addOption('markdown', 'Markdown') + .addOption('quote', 'Quote') + .addOption('bullet', 'Bullet list') + .addOption('heading', 'Heading') + .addOption('code', 'Code block') + .addOption('callout', 'Callout') .setValue(this.plugin.settings.textFormat || 'plain') .onChange(async (value) => { if (this.isTextFormat(value)) { @@ -63,8 +63,8 @@ export class GeneralSettings { // 타임스탬프 추가 new Setting(containerEl) - .setName('타임스탬프 추가') - .setDesc('변환된 텍스트에 타임스탬프를 추가합니다') + .setName('Add timestamp') + .setDesc('Add timestamps to transcribed text') .addToggle((toggle) => toggle .setValue(this.plugin.settings.addTimestamp || false) @@ -77,13 +77,13 @@ export class GeneralSettings { // 타임스탬프 형식 if (this.plugin.settings.addTimestamp) { new Setting(containerEl) - .setName('타임스탬프 형식') - .setDesc('타임스탬프 표시 형식을 선택하세요') + .setName('Timestamp format') + .setDesc('Choose how timestamps should be displayed') .addDropdown((dropdown) => dropdown - .addOption('none', '없음') - .addOption('inline', '인라인') - .addOption('sidebar', '사이드바') + .addOption('none', 'None') + .addOption('inline', 'Inline') + .addOption('sidebar', 'Sidebar') .setValue(this.plugin.settings.timestampFormat) .onChange(async (value: string) => { if (this.isTimestampFormat(value)) { @@ -96,8 +96,8 @@ export class GeneralSettings { // 포맷 옵션 표시 new Setting(containerEl) - .setName('포맷 옵션 표시') - .setDesc('변환 후 텍스트 포맷 옵션을 표시합니다') + .setName('Show format options') + .setDesc('Show formatting options after transcription') .addToggle((toggle) => toggle .setValue(this.plugin.settings.showFormatOptions || false) @@ -109,13 +109,13 @@ export class GeneralSettings { // 새 노트 생성 설정 new Setting(containerEl) - .setName('활성 에디터가 없을 때') - .setDesc('활성 에디터가 없을 때 동작을 선택하세요') + .setName('When no editor is active') + .setDesc('Choose what to do when there is no active editor') .addDropdown((dropdown) => dropdown - .addOption('create', '새 노트 생성') - .addOption('skip', '건너뛰기') - .addOption('ask', '물어보기') + .addOption('create', 'Create a new note') + .addOption('skip', 'Skip') + .addOption('ask', 'Ask first') .setValue('create') // 기본값 .onChange(async (_value) => { // 추가 설정 저장 로직 @@ -125,13 +125,13 @@ export class GeneralSettings { // UI 테마 new Setting(containerEl) - .setName('UI 테마') - .setDesc('플러그인 UI 테마를 선택하세요') + .setName('UI theme') + .setDesc('Choose the plugin UI theme') .addDropdown((dropdown) => dropdown - .addOption('auto', '자동 (시스템 따름)') - .addOption('light', '라이트') - .addOption('dark', '다크') + .addOption('auto', 'Auto (follow system)') + .addOption('light', 'Light') + .addOption('dark', 'Dark') .setValue('auto') .onChange(async (value) => { // UI 테마 적용 로직 @@ -142,8 +142,8 @@ export class GeneralSettings { // 알림 설정 new Setting(containerEl) - .setName('알림 표시') - .setDesc('작업 완료 및 오류 알림을 표시합니다') + .setName('Show notifications') + .setDesc('Show completion and error notifications') .addToggle((toggle) => toggle.setValue(true).onChange(async (_value) => { // 알림 설정 저장 @@ -153,8 +153,8 @@ export class GeneralSettings { // 사운드 효과 new Setting(containerEl) - .setName('사운드 효과') - .setDesc('작업 완료 시 사운드를 재생합니다') + .setName('Sound effects') + .setDesc('Play a sound when tasks complete') .addToggle((toggle) => toggle.setValue(false).onChange(async (_value) => { // 사운드 설정 저장 diff --git a/src/ui/settings/components/ProviderSettings.ts b/src/ui/settings/components/ProviderSettings.ts index 2d32923..5485c56 100644 --- a/src/ui/settings/components/ProviderSettings.ts +++ b/src/ui/settings/components/ProviderSettings.ts @@ -80,9 +80,9 @@ export class ProviderSettings { .setDesc('Choose a specific provider or use automatic selection') .addDropdown((dropdown) => { dropdown - .addOption('auto', '🤖 automatic (recommended)') - .addOption('whisper', '🎯 openai whisper') - .addOption('deepgram', '🚀 deepgram') + .addOption('auto', '🤖 Automatic (recommended)') + .addOption('whisper', '🎯 OpenAI Whisper') + .addOption('deepgram', '🚀 Deepgram') .setValue(this.currentProvider) .onChange(async (value) => { if (this.isProviderValue(value)) { @@ -104,15 +104,15 @@ export class ProviderSettings { */ private renderApiKeyInputs(containerEl: HTMLElement): void { const apiKeysContainer = containerEl.createDiv({ - cls: 'api-keys-container', + cls: 'sn-api-keys-container', }); // Whisper API Key this.renderSingleApiKey( apiKeysContainer, 'whisper', - 'Openai API key', - 'Enter your openai API key (starts with sk-)', + 'OpenAI API key', + 'Enter your OpenAI API key (starts with sk-)', 'sk-...' ); @@ -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 @@ -279,10 +279,10 @@ export class ProviderSettings { * A/B Testing 설정 */ private renderABTestSettings(containerEl: HTMLElement): void { - const abTestEl = containerEl.createDiv({ cls: 'ab-test-settings' }); + const abTestEl = containerEl.createDiv({ cls: 'sn-ab-test-settings' }); new Setting(abTestEl) - .setName('Enable a/b testing') + .setName('Enable A/B testing') .setDesc('Compare providers to find the best one for your use case') .addToggle((toggle) => { toggle @@ -319,7 +319,7 @@ export class ProviderSettings { }); // 분할 비율 표시 - const displayEl = containerEl.createDiv({ cls: 'split-display' }); + const displayEl = containerEl.createDiv({ cls: 'sn-split-display' }); displayEl.createEl('span', { text: `Whisper: ${currentSplit}%` }); displayEl.createEl('span', { text: `Deepgram: ${100 - currentSplit}%` }); }); @@ -347,9 +347,9 @@ export class ProviderSettings { * 메트릭 표시 */ private renderMetrics(containerEl: HTMLElement): void { - const metricsEl = containerEl.createDiv({ cls: 'metrics-container' }); + const metricsEl = containerEl.createDiv({ cls: 'sn-metrics-container' }); - metricsEl.createEl('h4', { text: '📊 performance metrics' }); + metricsEl.createEl('h4', { text: '📊 Performance metrics' }); // 각 Provider별 메트릭 this.renderProviderMetrics(metricsEl, 'whisper'); @@ -365,10 +365,12 @@ export class ProviderSettings { private renderProviderMetrics(containerEl: HTMLElement, provider: TranscriptionProvider): void { const stats = this.getProviderStats(provider); - const statsEl = containerEl.createDiv({ cls: `provider-stats ${provider}` }); + const statsEl = containerEl.createDiv({ + cls: `sn-provider-stats sn-provider-stats--${provider}`, + }); statsEl.createEl('h5', { text: this.getProviderDisplayName(provider) }); - const statGrid = statsEl.createDiv({ cls: 'stat-grid' }); + const statGrid = statsEl.createDiv({ cls: 'sn-stat-grid' }); const statItems = [ { @@ -391,9 +393,9 @@ export class ProviderSettings { ]; statItems.forEach((item) => { - const itemEl = statGrid.createDiv({ cls: 'stat-item' }); - itemEl.createEl('span', { cls: 'stat-label', text: item.label }); - const valueSpan = itemEl.createEl('span', { cls: 'stat-value', text: item.value }); + const itemEl = statGrid.createDiv({ cls: 'sn-stat-item' }); + itemEl.createEl('span', { cls: 'sn-stat-label', text: item.label }); + const valueSpan = itemEl.createEl('span', { cls: 'sn-stat-value', text: item.value }); if (item.className) { valueSpan.addClass(item.className); } @@ -404,20 +406,20 @@ export class ProviderSettings { * 비교 차트 렌더링 */ private renderComparisonChart(containerEl: HTMLElement): void { - const chartEl = containerEl.createDiv({ cls: 'comparison-chart' }); - chartEl.createEl('h5', { text: '📈 provider comparison' }); + const chartEl = containerEl.createDiv({ cls: 'sn-comparison-chart' }); + chartEl.createEl('h5', { text: '📈 Provider comparison' }); // 간단한 막대 차트 (실제로는 Chart.js 등 사용 권장) - const chartContent = chartEl.createDiv({ cls: 'chart-content' }); - const bars = chartContent.createDiv({ cls: 'chart-bars' }); + const chartContent = chartEl.createDiv({ cls: 'sn-chart-content' }); + const bars = chartContent.createDiv({ cls: 'sn-chart-bars' }); - const whisperBar = bars.createDiv({ cls: 'chart-bar chart-bar--whisper' }); - whisperBar.createEl('span', { cls: 'bar-label', text: 'Whisper' }); + const whisperBar = bars.createDiv({ cls: 'sn-chart-bar sn-chart-bar--whisper' }); + whisperBar.createEl('span', { cls: 'sn-bar-label', text: 'Whisper' }); - const deepgramBar = bars.createDiv({ cls: 'chart-bar chart-bar--deepgram' }); - deepgramBar.createEl('span', { cls: 'bar-label', text: 'Deepgram' }); + const deepgramBar = bars.createDiv({ cls: 'sn-chart-bar sn-chart-bar--deepgram' }); + deepgramBar.createEl('span', { cls: 'sn-bar-label', text: 'Deepgram' }); - const legend = chartContent.createDiv({ cls: 'chart-legend' }); + const legend = chartContent.createDiv({ cls: 'sn-chart-legend' }); legend.createEl('span', { text: 'Overall performance score' }); } @@ -431,10 +433,10 @@ export class ProviderSettings { const deepgramConnected = this.checkConnection('deepgram'); if (whisperConnected || deepgramConnected) { - statusEl.addClass('connected'); + statusEl.addClass('sn-connection-status--connected'); statusEl.setText('✅ connected'); } else { - statusEl.addClass('disconnected'); + statusEl.addClass('sn-connection-status--disconnected'); statusEl.setText('⚠️ no providers configured'); } } @@ -459,7 +461,7 @@ export class ProviderSettings { ): void { const toggleBtn = containerEl.createEl('button', { text: '👁', - cls: 'visibility-toggle', + cls: 'sn-visibility-toggle', }); let isVisible = false; @@ -489,7 +491,7 @@ export class ProviderSettings { ): void { const validateBtn = containerEl.createEl('button', { text: 'Verify', - cls: 'mod-cta validate-btn', + cls: 'mod-cta sn-validate-btn', }); validateBtn.addEventListener('click', () => { @@ -500,10 +502,10 @@ export class ProviderSettings { if (isValid) { new Notice(`✅ ${this.getProviderDisplayName(provider)} API key verified!`); - inputEl.addClass('valid'); + inputEl.addClass('sn-api-key-input--valid'); } else { new Notice(`❌ Invalid ${this.getProviderDisplayName(provider)} API key`); - inputEl.addClass('invalid'); + inputEl.addClass('sn-api-key-input--invalid'); } validateBtn.disabled = false; @@ -526,9 +528,9 @@ export class ProviderSettings { if (this.validateKeyFormat(provider, value)) { this.apiKeys.set(provider, value); await this.saveApiKey(provider, value); - inputEl.removeClass('invalid'); + inputEl.removeClass('sn-api-key-input--invalid'); } else { - inputEl.addClass('invalid'); + inputEl.addClass('sn-api-key-input--invalid'); new Notice(`Invalid ${this.getProviderDisplayName(provider)} key format`); } })(); @@ -544,16 +546,16 @@ export class ProviderSettings { if (this.currentProvider === 'auto') { // Auto mode: 모든 키 표시 - whisperEl?.removeClass('hidden'); - deepgramEl?.removeClass('hidden'); + whisperEl?.removeClass('sn-hidden'); + deepgramEl?.removeClass('sn-hidden'); } else if (this.currentProvider === 'whisper') { // Whisper only - whisperEl?.removeClass('hidden'); - deepgramEl?.addClass('hidden'); + whisperEl?.removeClass('sn-hidden'); + deepgramEl?.addClass('sn-hidden'); } else if (this.currentProvider === 'deepgram') { // Deepgram only - whisperEl?.addClass('hidden'); - deepgramEl?.removeClass('hidden'); + whisperEl?.addClass('sn-hidden'); + deepgramEl?.removeClass('sn-hidden'); } } @@ -574,7 +576,7 @@ 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', + whisper: '🎯 OpenAI Whisper - high accuracy, supports 50+ languages', deepgram: '🚀 Deepgram - fast real-time transcription with excellent accuracy', }; @@ -601,7 +603,7 @@ export class ProviderSettings { */ private getProviderDisplayName(provider: TranscriptionProvider): string { const names: Record = { - whisper: 'OpenAI whisper', + whisper: 'OpenAI Whisper', deepgram: 'Deepgram', }; return names[provider] || provider; @@ -611,10 +613,10 @@ export class ProviderSettings { * 통계 클래스 가져오기 */ private getStatClass(value: number): string { - if (value >= 0.95) return 'stat-excellent'; - if (value >= 0.85) return 'stat-good'; - if (value >= 0.7) return 'stat-fair'; - return 'stat-poor'; + if (value >= 0.95) return 'sn-stat-excellent'; + if (value >= 0.85) return 'sn-stat-good'; + if (value >= 0.7) return 'sn-stat-fair'; + return 'sn-stat-poor'; } /** diff --git a/src/ui/settings/components/ShortcutSettings.ts b/src/ui/settings/components/ShortcutSettings.ts index d1e4c18..e27038b 100644 --- a/src/ui/settings/components/ShortcutSettings.ts +++ b/src/ui/settings/components/ShortcutSettings.ts @@ -26,7 +26,7 @@ export class ShortcutSettings { // 단축키 설명 const descEl = containerEl.createDiv({ cls: 'shortcut-description' }); descEl.createEl('p', { - text: '각 기능에 대한 단축키를 설정할 수 있습니다. 단축키를 클릭하여 변경하세요.', + text: 'Configure a shortcut for each action. Click a shortcut to change it.', }); // 단축키 목록 @@ -34,17 +34,17 @@ export class ShortcutSettings { // 기본값 복원 버튼 new Setting(containerEl) - .setName('단축키 초기화') - .setDesc('모든 단축키를 기본값으로 복원합니다') + .setName('Reset shortcuts') + .setDesc('Restore all shortcuts to their default values') .addButton((button) => button - .setButtonText('기본값 복원') + .setButtonText('Restore defaults') .setWarning() .onClick(() => { new ConfirmationModal( this.app, - '단축키 초기화', - '모든 단축키를 기본값으로 복원하시겠습니까?', + 'Reset shortcuts', + 'Do you want to restore all shortcuts to their default values?', async () => { await this.resetToDefaults(); this.render(containerEl); // UI 새로고침 @@ -66,38 +66,38 @@ export class ShortcutSettings { const shortcuts = [ { id: 'transcribe-audio', - name: '음성 파일 변환', - desc: '음성 파일을 선택하여 텍스트로 변환합니다', + name: 'Transcribe audio file', + desc: 'Select an audio file and transcribe it to text', }, { id: 'transcribe-clipboard', - name: '클립보드 음성 변환', - desc: '클립보드의 음성을 텍스트로 변환합니다', + name: 'Transcribe clipboard audio', + desc: 'Transcribe audio content from the clipboard', }, { id: 'show-format-options', - name: '포맷 옵션 표시', - desc: '텍스트 포맷 옵션을 표시합니다', + name: 'Show format options', + desc: 'Show text formatting options', }, { id: 'show-history', - name: '변환 기록 표시', - desc: '최근 변환 기록을 표시합니다', + name: 'Show transcription history', + desc: 'Show recent transcription history', }, { id: 'cancel-transcription', - name: '변환 취소', - desc: '진행 중인 변환을 취소합니다', + name: 'Cancel transcription', + desc: 'Cancel the current transcription task', }, { id: 'undo-insertion', - name: '삽입 취소', - desc: '마지막 텍스트 삽입을 취소합니다', + name: 'Undo insertion', + desc: 'Undo the most recent text insertion', }, { id: 'redo-insertion', - name: '삽입 다시 실행', - desc: '취소한 텍스트 삽입을 다시 실행합니다', + name: 'Redo insertion', + desc: 'Redo the last undone text insertion', }, ]; @@ -109,13 +109,13 @@ export class ShortcutSettings { const keyEl = setting.controlEl.createDiv({ cls: 'shortcut-key' }); const keyDisplay = keyEl.createEl('kbd', { - text: currentKey || '설정 안 됨', + text: currentKey || 'Not set', cls: currentKey ? 'shortcut-set' : 'shortcut-unset', }); // 변경 버튼 setting.addButton((button) => - button.setButtonText('변경').onClick(() => { + button.setButtonText('Change').onClick(() => { this.openShortcutModal(shortcut.id, shortcut.name, (newKey) => { if (newKey) { void this.setShortcut(shortcut.id, newKey); @@ -130,11 +130,11 @@ export class ShortcutSettings { if (currentKey) { setting.addButton((button) => button - .setButtonText('삭제') + .setButtonText('Remove') .setWarning() .onClick(async () => { await this.removeShortcut(shortcut.id); - keyDisplay.textContent = '설정 안 됨'; + keyDisplay.textContent = 'Not set'; keyDisplay.className = 'shortcut-unset'; }) ); @@ -150,12 +150,12 @@ export class ShortcutSettings { if (conflicts.length > 0) { const conflictEl = containerEl.createDiv({ cls: 'shortcut-conflicts' }); - conflictEl.createEl('h4', { text: '⚠️ 단축키 충돌 감지됨' }); + conflictEl.createEl('h4', { text: '⚠️ Shortcut conflicts detected' }); const conflictList = conflictEl.createEl('ul'); conflicts.forEach((conflict) => { conflictList.createEl('li', { - text: `"${conflict.key}"가 "${conflict.commands.join('", "')}"에서 중복됩니다`, + text: `"${conflict.key}" is assigned to "${conflict.commands.join('", "')}".`, }); }); } @@ -180,8 +180,8 @@ export class ShortcutSettings { if (existingCommand && existingCommand !== commandId) { new ConfirmationModal( this.app, - '단축키 충돌', - `"${key}"는 이미 다른 명령에 할당되어 있습니다. 덮어쓰시겠습니까?`, + 'Shortcut conflict', + `"${key}" is already assigned to another command. Do you want to overwrite it?`, () => { // 기존 단축키 제거 void this.removeShortcut(existingCommand); @@ -267,7 +267,7 @@ export class ShortcutSettings { // Obsidian 명령에 단축키 등록 this.registerHotkey(commandId, key); - new Notice(`단축키가 설정되었습니다: ${key}`); + new Notice(`Shortcut set: ${key}`); } /** @@ -293,7 +293,7 @@ export class ShortcutSettings { // Obsidian에서 단축키 제거 this.unregisterHotkey(commandId); - new Notice('단축키가 제거되었습니다'); + new Notice('Shortcut removed.'); } /** @@ -314,7 +314,7 @@ export class ShortcutSettings { this.plugin.settings['shortcuts'] = {}; await this.plugin.saveSettings(); - new Notice('단축키가 기본값으로 복원되었습니다'); + new Notice('Shortcuts restored to defaults.'); } /** @@ -410,33 +410,33 @@ class ShortcutModal extends Modal { onOpen() { const { contentEl } = this; - contentEl.createEl('h2', { text: `단축키 설정: ${this.commandName}` }); + contentEl.createEl('h2', { text: `Configure shortcut: ${this.commandName}` }); // 현재 단축키 표시 const currentKeyEl = contentEl.createDiv({ cls: 'current-shortcut' }); - currentKeyEl.createEl('span', { text: '현재 단축키: ' }); + currentKeyEl.createEl('span', { text: 'Current shortcut: ' }); const keyDisplay = currentKeyEl.createEl('kbd', { - text: this.currentKey || '설정 안 됨', + text: this.currentKey || 'Not set', cls: 'shortcut-display', }); // 녹음 영역 const recordEl = contentEl.createDiv({ cls: 'shortcut-record' }); - recordEl.createEl('p', { text: '새 단축키를 입력하려면 아래 버튼을 클릭하세요:' }); + recordEl.createEl('p', { text: 'Click the button below to record a new shortcut:' }); const recordButton = new ButtonComponent(recordEl) - .setButtonText('단축키 녹음 시작') + .setButtonText('Start recording') .onClick(() => { this.startRecording(recordButton, keyDisplay); }); // 입력 필드 (직접 입력) const manualEl = contentEl.createDiv({ cls: 'shortcut-manual' }); - manualEl.createEl('p', { text: '또는 직접 입력:' }); + manualEl.createEl('p', { text: 'Or enter one manually:' }); const inputEl = manualEl.createEl('input', { type: 'text', - placeholder: '예: Ctrl+Shift+T', + placeholder: 'Example: Ctrl+Shift+T', value: this.currentKey, }); @@ -444,20 +444,20 @@ class ShortcutModal extends Modal { const target = e.target; if (target instanceof HTMLInputElement) { this.currentKey = target.value; - keyDisplay.textContent = this.currentKey || '설정 안 됨'; + keyDisplay.textContent = this.currentKey || 'Not set'; } }); // 버튼 const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' }); - new ButtonComponent(buttonContainer).setButtonText('취소').onClick(() => { + new ButtonComponent(buttonContainer).setButtonText('Cancel').onClick(() => { this.onSubmit(null); this.close(); }); new ButtonComponent(buttonContainer) - .setButtonText('삭제') + .setButtonText('Remove') .setWarning() .onClick(() => { this.onSubmit(''); @@ -465,7 +465,7 @@ class ShortcutModal extends Modal { }); new ButtonComponent(buttonContainer) - .setButtonText('저장') + .setButtonText('Save') .setCta() .onClick(() => { this.onSubmit(this.currentKey); @@ -485,7 +485,7 @@ class ShortcutModal extends Modal { this.isRecording = true; this.recordedKeys.clear(); - button.setButtonText('녹음 중... (esc로 취소)'); + button.setButtonText('Recording... (Esc to cancel)'); button.buttonEl.addClass('is-recording'); // 키 이벤트 리스너 @@ -531,7 +531,7 @@ class ShortcutModal extends Modal { */ private stopRecording(button: ButtonComponent): void { this.isRecording = false; - button.setButtonText('단축키 녹음 시작'); + button.setButtonText('Start recording'); button.buttonEl.removeClass('is-recording'); } diff --git a/src/ui/settings/error/ErrorHandling.ts b/src/ui/settings/error/ErrorHandling.ts index 70d4850..1954a9c 100644 --- a/src/ui/settings/error/ErrorHandling.ts +++ b/src/ui/settings/error/ErrorHandling.ts @@ -34,13 +34,13 @@ export class SettingsError extends Error { */ getUserMessage(): string { const messages: Record = { - [ErrorType.VALIDATION]: '입력값이 올바르지 않습니다', - [ErrorType.NETWORK]: '네트워크 연결을 확인해주세요', - [ErrorType.AUTHENTICATION]: '인증에 실패했습니다. API 키를 확인해주세요', - [ErrorType.CONFIGURATION]: '설정에 문제가 있습니다', - [ErrorType.PERMISSION]: '권한이 없습니다', - [ErrorType.STORAGE]: '저장소 접근에 실패했습니다', - [ErrorType.UNKNOWN]: '알 수 없는 오류가 발생했습니다', + [ErrorType.VALIDATION]: 'The input is invalid.', + [ErrorType.NETWORK]: 'Check your network connection.', + [ErrorType.AUTHENTICATION]: 'Authentication failed. Check your API key.', + [ErrorType.CONFIGURATION]: 'There is a problem with the settings.', + [ErrorType.PERMISSION]: 'Permission denied.', + [ErrorType.STORAGE]: 'Failed to access storage.', + [ErrorType.UNKNOWN]: 'An unknown error occurred.', }; return messages[this.type] || this.message; @@ -115,7 +115,7 @@ export class ErrorHandlerChain { // 기본 핸들러 console.error('Unhandled error:', error); - new Notice('예기치 않은 오류가 발생했습니다'); + new Notice('An unexpected error occurred.'); } } @@ -129,7 +129,7 @@ export class ValidationErrorHandler implements ErrorHandler { handle(error: Error): void { if (!(error instanceof SettingsError)) { - new Notice('입력값이 올바르지 않습니다'); + new Notice('The input is invalid.'); return; } @@ -164,13 +164,13 @@ export class NetworkErrorHandler implements ErrorHandler { handle(error: Error): void { if (!(error instanceof SettingsError)) { - new Notice('네트워크 연결을 확인해주세요'); + new Notice('Check your network connection.'); return; } if (this.retryCount < this.maxRetries && error.recoverable) { this.retryCount++; - new Notice(`네트워크 오류 (재시도 ${this.retryCount}/${this.maxRetries})`); + new Notice(`Network error (retry ${this.retryCount}/${this.maxRetries})`); // 재시도 로직 setTimeout(() => { @@ -181,7 +181,7 @@ export class NetworkErrorHandler implements ErrorHandler { } }, 1000 * this.retryCount); } else { - new Notice('네트워크 연결을 확인해주세요'); + new Notice('Check your network connection.'); this.retryCount = 0; } } @@ -264,7 +264,7 @@ export class ErrorBoundary { const isRecoverable = error instanceof SettingsError && error.isRecoverable(); const container = this.fallbackUI.createDiv({ cls: 'error-container' }); container.createDiv({ cls: 'error-icon', text: '⚠️' }); - container.createEl('h3', { text: '문제가 발생했습니다' }); + container.createEl('h3', { text: 'Something went wrong' }); container.createEl('p', { cls: 'error-message', text: this.getSafeErrorMessage(error), @@ -273,21 +273,21 @@ export class ErrorBoundary { if (isRecoverable) { container.createEl('button', { cls: 'mod-cta retry-button', - text: '다시 시도', + text: 'Retry', }); container.createEl('button', { cls: 'reset-button', - text: '설정 초기화', + text: 'Reset settings', }); } else { container.createEl('button', { cls: 'refresh-button', - text: '새로고침', + text: 'Reload', }); } const detailsEl = container.createEl('details', { cls: 'error-details' }); - detailsEl.createEl('summary', { text: '기술적 세부사항' }); + detailsEl.createEl('summary', { text: 'Technical details' }); detailsEl.createEl('pre', { text: this.getErrorDetails(error) }); // 버튼 이벤트 핸들러 @@ -428,7 +428,7 @@ class AuthenticationErrorHandler implements ErrorHandler { handle(error: Error): void { if (!(error instanceof SettingsError)) { - new Notice('인증에 실패했습니다. API 키를 확인해주세요'); + new Notice('Authentication failed. Check your API key.'); return; } new Notice(error.getUserMessage()); @@ -452,7 +452,7 @@ class StorageErrorHandler implements ErrorHandler { handle(error: Error): void { if (!(error instanceof SettingsError)) { - new Notice('저장소 접근에 실패했습니다'); + new Notice('Failed to access storage.'); return; } new Notice(error.getUserMessage()); @@ -462,7 +462,7 @@ class StorageErrorHandler implements ErrorHandler { void navigator.storage.estimate().then((estimate) => { const percentUsed = ((estimate.usage || 0) / (estimate.quota || 1)) * 100; if (percentUsed > 90) { - new Notice('저장 공간이 부족합니다. 일부 데이터를 정리해주세요.'); + new Notice('Storage is almost full. Free up some space and try again.'); } }); } @@ -537,17 +537,17 @@ export class ErrorRecoveryStrategy { */ private registerDefaultStrategies(): void { this.strategies.set(ErrorType.NETWORK, () => { - new Notice('네트워크 오류가 발생했습니다. 오프라인 모드로 전환합니다.'); + new Notice('A network error occurred. Switching to offline mode.'); // 오프라인 모드 활성화 }); this.strategies.set(ErrorType.AUTHENTICATION, () => { - new Notice('인증 실패. API 키를 다시 입력해주세요.'); + new Notice('Authentication failed. Enter the API key again.'); // API 키 재입력 다이얼로그 표시 }); this.strategies.set(ErrorType.STORAGE, () => { - new Notice('저장 실패. 임시 저장소를 사용합니다.'); + new Notice('Saving failed. Falling back to temporary storage.'); // 메모리 저장소로 전환 }); } diff --git a/src/ui/settings/provider/components/AdvancedSettingsPanel.ts b/src/ui/settings/provider/components/AdvancedSettingsPanel.ts index 87a09cd..b1bd758 100644 --- a/src/ui/settings/provider/components/AdvancedSettingsPanel.ts +++ b/src/ui/settings/provider/components/AdvancedSettingsPanel.ts @@ -847,23 +847,23 @@ export class AdvancedSettingsPanel { * Split Display 업데이트 */ private updateSplitDisplay(containerEl: HTMLElement): void { - let displayEl = containerEl.querySelector('.split-display'); + let displayEl = containerEl.querySelector('.sn-split-display'); if (!(displayEl instanceof HTMLElement)) { - displayEl = containerEl.createDiv({ cls: 'split-display' }); + displayEl = containerEl.createDiv({ cls: 'sn-split-display' }); } - const visualization = createEl('div', { cls: 'split-visualization' }); + const visualization = createEl('div', { cls: 'sn-split-visualization' }); - const splitBar = createEl('div', { cls: 'split-bar' }); + const splitBar = createEl('div', { cls: 'sn-split-bar' }); - const providerA = createEl('div', { cls: 'split-a' }); + const providerA = createEl('div', { cls: 'sn-split-a' }); providerA.setAttribute('style', `--sn-width-pct:${this.abTestSplit}%`); const providerALabel = createEl('span', { text: `Provider A: ${this.abTestSplit}%` }); providerA.appendChild(providerALabel); const providerBWidth = 100 - this.abTestSplit; - const providerB = createEl('div', { cls: 'split-b' }); + const providerB = createEl('div', { cls: 'sn-split-b' }); providerB.setAttribute('style', `--sn-width-pct:${providerBWidth}%`); const providerBLabel = createEl('span', { text: `Provider B: ${providerBWidth}%` }); providerB.appendChild(providerBLabel); diff --git a/src/ui/settings/settings.css b/src/ui/settings/settings.css index 6141391..1013df3 100644 --- a/src/ui/settings/settings.css +++ b/src/ui/settings/settings.css @@ -6,11 +6,11 @@ @import url('../styles/file-picker.css'); /* Minimal layout tweaks for settings containers */ -.deepgram-settings-container { +.sn-deepgram-settings-container { display: block; margin-top: 8px; } -.provider-settings { +.sn-provider-settings { display: block; } diff --git a/src/utils/ErrorHandler.ts b/src/utils/ErrorHandler.ts index 78d33c2..8033101 100644 --- a/src/utils/ErrorHandler.ts +++ b/src/utils/ErrorHandler.ts @@ -6,11 +6,11 @@ export class ErrorHandler { getUserFriendlyMessage(error: Error): string { const codeMessageMap: Record = { - ECONNREFUSED: '서버에 연결할 수 없습니다.', - ETIMEDOUT: '연결 시간이 초과되었습니다.', - ENOTFOUND: '서버를 찾을 수 없습니다.', - EPERM: '권한이 없습니다.', - ENOSPC: '저장 공간이 부족합니다.', + ECONNREFUSED: 'Could not connect to the server.', + ETIMEDOUT: 'The connection timed out.', + ENOTFOUND: 'The server could not be found.', + EPERM: 'Permission denied.', + ENOSPC: 'Not enough storage space is available.', }; for (const [code, message] of Object.entries(codeMessageMap)) { @@ -24,14 +24,14 @@ export class ErrorHandler { getSolution(code: string): string { const solutions: Record = { - INVALID_API_KEY: '설정에서 올바른 API 키를 입력해주세요.', - NETWORK_ERROR: '네트워크 연결을 확인한 뒤 다시 시도해주세요.', - TIMEOUT: '잠시 후 다시 시도하거나 타임아웃 값을 늘려주세요.', - FILE_TOO_LARGE: '파일 크기를 줄이거나 다른 파일을 선택해주세요.', - UNSUPPORTED_FORMAT: '지원되는 오디오 형식을 사용해주세요.', + INVALID_API_KEY: 'Enter a valid API key in settings.', + NETWORK_ERROR: 'Check your network connection and try again.', + TIMEOUT: 'Try again later or increase the timeout value.', + FILE_TOO_LARGE: 'Reduce the file size or choose a different file.', + UNSUPPORTED_FORMAT: 'Use a supported audio format.', }; - return solutions[code] ?? '문제를 확인하고 다시 시도해주세요.'; + return solutions[code] ?? 'Check the problem and try again.'; } handle(error: unknown, context?: Record): void { diff --git a/src/utils/error/ErrorManager.ts b/src/utils/error/ErrorManager.ts index faa88f8..1fdda48 100644 --- a/src/utils/error/ErrorManager.ts +++ b/src/utils/error/ErrorManager.ts @@ -8,6 +8,7 @@ import type { App } from 'obsidian'; import type { EventManager } from '../../application/EventManager'; +import { safeJsonParse } from '../common/helpers'; /** * 에러 타입 정의 @@ -93,7 +94,8 @@ export class GlobalErrorManager { private setupGlobalHandlers(): void { // Unhandled rejection 처리 window.addEventListener('unhandledrejection', (event) => { - void this.handleError(new Error(event.reason), { + const rejectionError = normalizeError(event.reason); + void this.handleError(rejectionError, { type: ErrorType.UNKNOWN, severity: ErrorSeverity.HIGH, context: { promise: true }, @@ -103,7 +105,13 @@ export class GlobalErrorManager { // 일반 에러 처리 window.addEventListener('error', (event) => { - void this.handleError(event.error || new Error(event.message), { + const eventError = + event.error instanceof Error + ? event.error + : new Error( + typeof event.message === 'string' ? event.message : 'Unknown error' + ); + void this.handleError(eventError, { type: ErrorType.UNKNOWN, severity: ErrorSeverity.HIGH, context: { @@ -226,7 +234,10 @@ export class GlobalErrorManager { if (!this.strategies.has(type)) { this.strategies.set(type, []); } - this.strategies.get(type)!.push(strategy); + const strategies = this.strategies.get(type); + if (strategies) { + strategies.push(strategy); + } } /** @@ -364,8 +375,12 @@ export class LocalStorageErrorReporter implements ErrorReporter { getLogs(): ErrorInfo[] { try { - const data = this.app.loadLocalStorage(this.storageKey); - return data ? JSON.parse(data) : []; + const data: unknown = this.app.loadLocalStorage(this.storageKey); + if (typeof data !== 'string') { + return []; + } + + return safeJsonParse(data, []); } catch { return []; } @@ -488,10 +503,10 @@ export class ErrorBoundary { private showDefaultFallback(error: Error): void { this.element.empty(); const container = this.element.createDiv('error-boundary-fallback'); - container.createEl('h3', { text: '문제가 발생했습니다' }); + container.createEl('h3', { text: 'Something went wrong' }); container.createEl('p', { text: error.message }); container.createEl('button', { - text: '다시 시도', + text: 'Retry', cls: 'error-boundary-retry', }); } @@ -596,14 +611,20 @@ export async function tryCatchAsync( */ export function retryOnError(maxAttempts = 3, delay = 1000) { return function (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { - const originalMethod = descriptor.value; + void target; + void propertyKey; + const originalMethod = descriptor.value as ((...args: unknown[]) => unknown) | undefined; descriptor.value = async function (...args: unknown[]) { + if (typeof originalMethod !== 'function') { + throw new Error('retryOnError can only be applied to methods'); + } + let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return await originalMethod.apply(this, args); + return await Promise.resolve(originalMethod.apply(this, args)); } catch (error) { lastError = normalizeError(error); diff --git a/styles/provider-settings.css b/styles/provider-settings.css index 0fcd892..3e6367c 100644 --- a/styles/provider-settings.css +++ b/styles/provider-settings.css @@ -36,18 +36,18 @@ margin-top: 0.5rem; } -.speech-to-text-settings .sn-connection-status.connected { +.speech-to-text-settings .sn-connection-status.sn-connection-status--connected { background-color: var(--interactive-success); color: var(--text-on-accent); } -.speech-to-text-settings .sn-connection-status.disconnected { +.speech-to-text-settings .sn-connection-status.sn-connection-status--disconnected { background-color: var(--background-modifier-error); color: var(--text-error); } /* === API Key Inputs === */ -.api-keys-container { +.speech-to-text-settings .sn-api-keys-container { margin: 1rem 0; } @@ -67,11 +67,11 @@ box-shadow: 0 0 0 2px var(--interactive-accent-hover); } -.speech-to-text-settings .sn-api-key-input.valid { +.speech-to-text-settings .sn-api-key-input.sn-api-key-input--valid { border-color: var(--interactive-success); } -.speech-to-text-settings .sn-api-key-input.invalid { +.speech-to-text-settings .sn-api-key-input.sn-api-key-input--invalid { border-color: var(--text-error); } @@ -80,8 +80,8 @@ } /* === Control Buttons === */ -.visibility-toggle, -.validate-btn { +.speech-to-text-settings .sn-visibility-toggle, +.speech-to-text-settings .sn-validate-btn { margin-left: 0.5rem; padding: 0.4rem 0.8rem; border-radius: 4px; @@ -89,28 +89,28 @@ transition: all 0.2s ease; } -.visibility-toggle { +.speech-to-text-settings .sn-visibility-toggle { background: var(--background-secondary); border: 1px solid var(--background-modifier-border); font-size: 1.1em; } -.visibility-toggle:hover { +.speech-to-text-settings .sn-visibility-toggle:hover { background: var(--background-modifier-hover); } -.validate-btn { +.speech-to-text-settings .sn-validate-btn { background: var(--interactive-accent); color: var(--text-on-accent); border: none; font-weight: 500; } -.validate-btn:hover:not(:disabled) { +.speech-to-text-settings .sn-validate-btn:hover:not(:disabled) { background: var(--interactive-accent-hover); } -.validate-btn:disabled { +.speech-to-text-settings .sn-validate-btn:disabled { opacity: 0.5; cursor: not-allowed; } @@ -129,14 +129,14 @@ } /* === A/B Test Settings === */ -.ab-test-settings { +.speech-to-text-settings .sn-ab-test-settings { margin-top: 1rem; padding: 1rem; background: var(--background-primary-alt); border-radius: 6px; } -.split-display { +.speech-to-text-settings .sn-split-display { display: flex; justify-content: space-around; margin-top: 1rem; @@ -146,21 +146,21 @@ font-size: 0.9em; } -.split-display span { +.speech-to-text-settings .sn-split-display span { padding: 0.25rem 0.5rem; background: var(--background-secondary); border-radius: 3px; } /* === Metrics Container === */ -.metrics-container { +.speech-to-text-settings .sn-metrics-container { margin-top: 2rem; padding: 1.5rem; background: var(--background-secondary); border-radius: 8px; } -.metrics-container h4 { +.speech-to-text-settings .sn-metrics-container h4 { margin-bottom: 1rem; color: var(--text-normal); font-size: 1.1em; @@ -168,7 +168,7 @@ } /* === Provider Stats === */ -.provider-stats { +.speech-to-text-settings .sn-provider-stats { margin-bottom: 1.5rem; padding: 1rem; background: var(--background-primary); @@ -176,66 +176,66 @@ border: 1px solid var(--background-modifier-border); } -.provider-stats h5 { +.speech-to-text-settings .sn-provider-stats h5 { margin-bottom: 0.75rem; color: var(--text-normal); font-weight: 600; } -.stat-grid { +.speech-to-text-settings .sn-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; } -.stat-item { +.speech-to-text-settings .sn-stat-item { display: flex; flex-direction: column; } -.stat-label { +.speech-to-text-settings .sn-stat-label { font-size: 0.85em; color: var(--text-muted); margin-bottom: 0.25rem; } -.stat-value { +.speech-to-text-settings .sn-stat-value { font-size: 1.1em; font-weight: 600; color: var(--text-normal); } /* === Stat Value Colors === */ -.stat-excellent { +.speech-to-text-settings .sn-stat-excellent { color: var(--interactive-success) !important; } -.stat-good { +.speech-to-text-settings .sn-stat-good { color: var(--text-accent) !important; } -.stat-fair { +.speech-to-text-settings .sn-stat-fair { color: var(--text-warning) !important; } -.stat-poor { +.speech-to-text-settings .sn-stat-poor { color: var(--text-error) !important; } /* === Comparison Chart === */ -.comparison-chart { +.speech-to-text-settings .sn-comparison-chart { margin-top: 1.5rem; padding: 1rem; background: var(--background-primary); border-radius: 6px; } -.comparison-chart h5 { +.speech-to-text-settings .sn-comparison-chart h5 { margin-bottom: 1rem; font-weight: 600; } -.chart-bars { +.speech-to-text-settings .sn-chart-bars { display: flex; justify-content: space-around; align-items: flex-end; @@ -243,7 +243,7 @@ margin-bottom: 1rem; } -.chart-bar { +.speech-to-text-settings .sn-chart-bar { width: 80px; background: linear-gradient(to top, var(--interactive-accent), var(--interactive-accent-hover)); border-radius: 4px 4px 0 0; @@ -254,20 +254,20 @@ transition: all 0.3s ease; } -.chart-bar:hover { +.speech-to-text-settings .sn-chart-bar:hover { transform: translateY(-5px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); } -.chart-bar.whisper { +.speech-to-text-settings .sn-chart-bar.sn-chart-bar--whisper { background: linear-gradient(to top, #10a37f, #1ac5a0); } -.chart-bar.deepgram { +.speech-to-text-settings .sn-chart-bar.sn-chart-bar--deepgram { background: linear-gradient(to top, #635bff, #8b85ff); } -.bar-label { +.speech-to-text-settings .sn-bar-label { position: absolute; bottom: -25px; font-size: 0.85em; @@ -275,7 +275,7 @@ white-space: nowrap; } -.chart-legend { +.speech-to-text-settings .sn-chart-legend { text-align: center; font-size: 0.9em; color: var(--text-muted); @@ -296,7 +296,7 @@ } } -.loading { +.speech-to-text-settings .sn-loading { animation: pulse 1.5s ease-in-out infinite; } @@ -306,26 +306,26 @@ min-width: 200px; } - .stat-grid { + .speech-to-text-settings .sn-stat-grid { grid-template-columns: 1fr 1fr; } - .chart-bars { + .speech-to-text-settings .sn-chart-bars { height: 120px; } - .chart-bar { + .speech-to-text-settings .sn-chart-bar { width: 60px; } } /* === Dark Mode Adjustments === */ -.theme-dark .speech-to-text-settings .sn-connection-status.connected { +.theme-dark .speech-to-text-settings .sn-connection-status.sn-connection-status--connected { background-color: rgba(16, 163, 127, 0.2); color: #10a37f; } -.theme-dark .speech-to-text-settings .sn-connection-status.disconnected { +.theme-dark .speech-to-text-settings .sn-connection-status.sn-connection-status--disconnected { background-color: rgba(255, 87, 87, 0.2); color: #ff5757; } @@ -334,7 +334,7 @@ background: rgba(255, 255, 255, 0.02); } -.theme-dark .provider-stats { +.theme-dark .speech-to-text-settings .sn-provider-stats { background: rgba(255, 255, 255, 0.03); } @@ -345,9 +345,9 @@ border-radius: 4px; } -button:focus-visible, -input:focus-visible, -select:focus-visible { +.speech-to-text-settings button:focus-visible, +.speech-to-text-settings input:focus-visible, +.speech-to-text-settings select:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } diff --git a/tests/e2e/error-handling.e2e.test.ts b/tests/e2e/error-handling.e2e.test.ts index 3e6663c..51e220b 100644 --- a/tests/e2e/error-handling.e2e.test.ts +++ b/tests/e2e/error-handling.e2e.test.ts @@ -514,11 +514,11 @@ describe('E2E: 에러 처리 시나리오', () => { describe('사용자 친화적 에러 메시지', () => { test('기술적 에러를 사용자 친화적 메시지로 변환', () => { const technicalErrors = [ - { error: 'ECONNREFUSED', message: '서버에 연결할 수 없습니다.' }, - { error: 'ETIMEDOUT', message: '연결 시간이 초과되었습니다.' }, - { error: 'ENOTFOUND', message: '서버를 찾을 수 없습니다.' }, - { error: 'EPERM', message: '권한이 없습니다.' }, - { error: 'ENOSPC', message: '저장 공간이 부족합니다.' }, + { error: 'ECONNREFUSED', message: 'Could not connect to the server.' }, + { error: 'ETIMEDOUT', message: 'The connection timed out.' }, + { error: 'ENOTFOUND', message: 'The server could not be found.' }, + { error: 'EPERM', message: 'Permission denied.' }, + { error: 'ENOSPC', message: 'Not enough storage space is available.' }, ]; technicalErrors.forEach(({ error, message }) => { @@ -530,8 +530,8 @@ describe('E2E: 에러 처리 시나리오', () => { test('에러 코드별 해결 방법 제시', () => { const errorWithSolution = { code: 'INVALID_API_KEY', - message: 'API 키가 유효하지 않습니다.', - solution: '설정에서 올바른 API 키를 입력해주세요.', + message: 'Invalid API key.', + solution: 'Enter a valid API key in settings.', }; const solution = errorHandler.getSolution(errorWithSolution.code);