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