fix(ui): align settings review follow-ups (#73)

Standardize remaining runtime settings copy to English, namespace provider settings selectors,
and apply the sentence-case cleanup flagged in review.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
asyouplz 2026-03-13 17:30:33 +09:00 committed by GitHub
parent f5638bf4a4
commit 1686d84e85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 681 additions and 734 deletions

View file

@ -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 `<svg` or `createEl('svg')` usage and confirm only safe creation paths remain.
## Additional Bot Follow-up
- Follow-up comment: https://github.com/obsidianmd/obsidian-releases/pull/8004#issuecomment-4029277995
- Comment date: 2026-03-10
Applied follow-up cleanup:
1. Use sentence case for the `DragDropZone` helper text.
2. Remove the unused `safeJsonParse` import flagged in `StatisticsDashboard.ts`.

View file

@ -55,7 +55,7 @@ export class DragDropZone {
// 서브 텍스트
const subText = mainText.createEl('p', { cls: 'sn-drop-zone-subtext' });
subText.setText('or click to choose files');
subText.setText('Or click to choose files');
// 지원 형식 표시
const formats = mainText.createEl('p', { cls: 'sn-drop-zone-formats' });

View file

@ -164,7 +164,7 @@ export class FileValidator {
valid: false,
error: {
code: 'INVALID_EXTENSION',
message: `지원하지 않는 형식: .${extension}. 지원 형식: ${supported}`,
message: `Unsupported format: .${extension}. Supported formats: ${supported}`,
field: 'extension',
},
};
@ -186,7 +186,7 @@ export class FileValidator {
valid: false,
error: {
code: 'FILE_TOO_SMALL',
message: `파일이 너무 작습니다 (${size} bytes). 최소 크기: ${this.MIN_FILE_SIZE} bytes`,
message: `File is too small (${size} bytes). Minimum size: ${this.MIN_FILE_SIZE} bytes`,
field: 'size',
},
};
@ -237,7 +237,7 @@ export class FileValidator {
valid: false,
warning: {
code: 'PROBLEMATIC_FILENAME',
message: '파일명에 문제가 될 수 있는 문자가 포함되어 있습니다',
message: 'The filename contains characters that may cause problems.',
},
};
}
@ -249,7 +249,7 @@ export class FileValidator {
valid: true,
warning: {
code: 'SPECIAL_CHARS_IN_FILENAME',
message: '파일명에 특수 문자가 많이 포함되어 있습니다',
message: 'The filename contains many special characters.',
},
};
}
@ -278,7 +278,7 @@ export class FileValidator {
valid: false,
error: {
code: 'INVALID_FILE_STRUCTURE',
message: '파일 구조가 올바르지 않습니다',
message: 'The file structure is invalid.',
field: 'format',
},
};
@ -300,7 +300,7 @@ export class FileValidator {
valid: false,
error: {
code: 'INVALID_FORMAT',
message: `파일 내용이 예상된 ${extension.toUpperCase()} 형식과 일치하지 않습니다`,
message: `The file contents do not match the expected ${extension.toUpperCase()} format`,
field: 'format',
},
};

View file

@ -8,7 +8,6 @@
import type { App } from 'obsidian';
import { EventManager } from '../../application/EventManager';
import { safeJsonParse } from '../../utils/common/helpers';
import { ConfirmationModal } from '../modals/ConfirmationModal';
import { Notice } from 'obsidian';

View file

@ -87,7 +87,7 @@ export class FilePickerModal extends Modal {
const maxFileSizeMb =
typeof settings?.maxFileSize === 'number' ? settings.maxFileSize : 25;
this.options = this.mergeOptions({
title: '오디오 파일 선택',
title: 'Select audio files',
accept: ['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm'],
maxFileSize: maxFileSizeMb * 1024 * 1024,
multiple: false,
@ -188,13 +188,13 @@ export class FilePickerModal extends Modal {
const subtitle = header.createEl('p', { cls: 'file-picker-subtitle' });
if (this.options.accept.length > 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<FilePickerOptions> {
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,

View file

@ -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<void> {
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<FilePickerOptions> {
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',
});

View file

@ -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<StoredNotification[]>(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<StoredNotification[]>(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<boolean> {
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' },
],
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -107,7 +107,7 @@ export abstract class BaseSettingsComponent {
*/
protected async withErrorHandling<T>(
operation: () => Promise<T>,
errorMessage = '작업 중 오류가 발생했습니다'
errorMessage = 'An error occurred while processing the request.'
): Promise<T | null> {
try {
return await operation();
@ -168,7 +168,7 @@ export abstract class BaseSettingsComponent {
*/
protected async saveSettings(): Promise<void> {
await this.plugin.saveSettings();
this.showNotice('설정이 저장되었습니다');
this.showNotice('Settings saved.');
}
/**

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

@ -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) => {
// 사운드 설정 저장

View file

@ -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<string, string> = {
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<TranscriptionProvider, string> = {
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';
}
/**

View file

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

View file

@ -34,13 +34,13 @@ export class SettingsError extends Error {
*/
getUserMessage(): string {
const messages: Record<ErrorType, string> = {
[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.');
// 메모리 저장소로 전환
});
}

View file

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

View file

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

View file

@ -6,11 +6,11 @@ export class ErrorHandler {
getUserFriendlyMessage(error: Error): string {
const codeMessageMap: Record<string, string> = {
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<string, string> = {
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<string, unknown>): void {

View file

@ -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<ErrorInfo[]>(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<T>(
*/
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);

View file

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

View file

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