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:
asyouplz 2026-01-29 23:33:20 +09:00 committed by GitHub
parent cb98d70ce8
commit 2d8bc68615
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1223 additions and 638 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,7 +65,7 @@ export class RateLimiter {
);
resolve();
} catch (error) {
reject(error);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}

View file

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

View file

@ -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>;
}
}
// 기본 설정을 베이스로 사용자 설정 덮어쓰기

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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('설정 재설정 실패');
}
}

View file

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

View file

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

View file

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

View file

@ -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}는 필수 항목입니다`);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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[] }> = [];

View file

@ -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개의 로그만 유지

View file

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

View file

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

View file

@ -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('*')) {

View file

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

View file

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

View file

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

View file

@ -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; // 파싱 실패 시 원본 반환
}
}

View file

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

View file

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

View file

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

View file

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