mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 06:43:33 +00:00
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:
parent
f5638bf4a4
commit
1686d84e85
27 changed files with 681 additions and 734 deletions
56
docs/obsidian-review-8004-issuecomment-4007884875-plan.md
Normal file
56
docs/obsidian-review-8004-issuecomment-4007884875-plan.md
Normal 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`.
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
// 사운드 설정 저장
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
// 메모리 저장소로 전환
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue