Address PR #8004 Review Feedback and Improve Compliance (#44)

* fix: address PR #8004 review feedback and improve type safety

* fix: restore legacy password factors and fix formatting

* fix: finalize PR feedback following Obsidian review compliance

* fix: finalize PR feedback with technical refinements and obsidian compliance

* fix: finalize PR refinements following Claude Code feedback and repo cleanup

* fix: remove legacy migration logic and further simplify codebase for obsidian compliance

* refactor: final cleanup of unused variables and improved type safety for PR #44

* style: fix formatting issues to resolve CI pipeline error
This commit is contained in:
asyouplz 2026-01-19 17:23:06 +09:00 committed by GitHub
parent e436b94a97
commit 5cf1effdeb
24 changed files with 256 additions and 745 deletions

2
.gitignore vendored
View file

@ -7,6 +7,8 @@ node_modules/
dist/
build/
out/
main.js
styles.css
*.js
*.js.map
*.d.ts

View file

@ -2,7 +2,7 @@
<div align="center">
[![Version](https://img.shields.io/badge/version-3.0.4-blue.svg)](https://github.com/asyouplz/SpeechNote/releases)
[![Version](https://img.shields.io/badge/version-3.0.9-blue.svg)](https://github.com/asyouplz/SpeechNote/releases)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Obsidian](https://img.shields.io/badge/obsidian-%3E%3D0.15.0-purple.svg)](https://obsidian.md)
[![OpenAI](https://img.shields.io/badge/OpenAI-Whisper%20API-orange.svg)](https://platform.openai.com/docs/guides/speech-to-text)

121
main.js

File diff suppressed because one or more lines are too long

View file

@ -15,6 +15,8 @@ export interface LazyLoadOptions {
onLoad?: (module: unknown) => void;
}
import { requestUrl } from 'obsidian';
export interface LoadingState {
isLoading: boolean;
error?: Error;
@ -179,12 +181,8 @@ export class LazyLoader {
* (link prefetch)
*/
static preloadResources(resources: string[]): void {
if (typeof fetch !== 'function') {
return;
}
resources.forEach((resource) => {
void fetch(resource, { cache: 'force-cache', mode: 'no-cors' }).catch((error) => {
void requestUrl({ url: resource, method: 'GET' }).catch((error) => {
console.warn(`Failed to preload resource: ${resource}`, error);
});
});

View file

@ -1,4 +1,4 @@
import { TFile } from 'obsidian';
import { TFile, requestUrl, Notice } from 'obsidian';
import type {
ITranscriptionService,
TranscriptionResult,
@ -355,14 +355,14 @@ export class TranscriptionService implements ITranscriptionService {
externalSignal
);
if (!this.isResponseOk(response)) {
const message = await this.extractErrorMessage(response);
const message = this.extractErrorMessage(response);
const error = new Error(message);
if (typeof response.status === 'number') {
(error as { status?: number }).status = response.status;
}
throw error;
}
return await this.extractSuccessResponse(response);
return this.extractSuccessResponse(response);
} finally {
if (this.abortController === controller) {
this.abortController = undefined;
@ -372,45 +372,43 @@ export class TranscriptionService implements ITranscriptionService {
private async fetchWithSignal(
url: string,
body: FormData,
body: FormData | string | ArrayBuffer,
signal?: AbortSignal,
delayForAbort = false
): Promise<{
ok?: boolean;
status?: number;
ok: boolean;
status: number;
statusText?: string;
json?: () => Promise<unknown>;
text?: () => Promise<string>;
json?: unknown;
text?: string;
}> {
if (typeof fetch !== 'function') {
throw new Error('Fetch API is not available');
}
const headers: Record<string, string> = {};
const apiKey = this.getApiKey();
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const fetchPromise = fetch(url, {
const responsePromise = requestUrl({
url,
method: 'POST',
headers,
body,
signal,
}) as Promise<{
ok?: boolean;
status?: number;
statusText?: string;
json?: () => Promise<unknown>;
text?: () => Promise<string>;
}>;
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,
text: response.text,
}));
const effectiveFetchPromise =
this.isTestEnvironment() && delayForAbort
? fetchPromise.then(async (response) => {
? responsePromise.then(async (response) => {
await this.sleep(150);
return response;
})
: fetchPromise;
: responsePromise;
if (!signal) {
return await effectiveFetchPromise;
@ -424,28 +422,40 @@ export class TranscriptionService implements ITranscriptionService {
let restoreOnAbort: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
abortHandler = () => reject(this.createAbortError());
if (typeof signal.addEventListener === 'function') {
if (signal && typeof signal.addEventListener === 'function') {
signal.addEventListener('abort', abortHandler, { once: true });
}
if ('onabort' in signal) {
const previous = signal.onabort;
signal.onabort = (event) => {
if (signal && 'onabort' in signal) {
const sig = signal as AbortSignal;
const previous = sig.onabort;
sig.onabort = (event: Event) => {
if (typeof previous === 'function') {
previous.call(signal, event);
}
abortHandler?.();
};
restoreOnAbort = () => {
signal.onabort = previous;
sig.onabort = previous;
};
}
});
try {
return await Promise.race([effectiveFetchPromise, abortPromise]);
return (await Promise.race([effectiveFetchPromise, abortPromise])) as {
ok: boolean;
status: number;
statusText?: string;
json?: unknown;
text?: string;
};
} finally {
if (abortHandler && typeof signal.removeEventListener === 'function') {
signal.removeEventListener('abort', abortHandler);
if (
abortHandler &&
signal &&
'removeEventListener' in signal &&
typeof signal.removeEventListener === 'function'
) {
(signal as AbortSignal).removeEventListener('abort', abortHandler);
}
if (restoreOnAbort) {
restoreOnAbort();
@ -453,35 +463,23 @@ export class TranscriptionService implements ITranscriptionService {
}
}
private isResponseOk(response: { ok?: boolean; status?: number }): boolean {
if (typeof response.ok === 'boolean') {
return response.ok;
}
if (typeof response.status === 'number') {
return response.status >= 200 && response.status < 300;
}
return true;
private isResponseOk(response: { ok: boolean; status: number }): boolean {
return response.ok;
}
private async extractSuccessResponse(response: {
json?: () => Promise<unknown>;
text?: () => Promise<string>;
}): Promise<{ text: string; language?: string }> {
private extractSuccessResponse(response: { json?: unknown; text?: string }): {
text: string;
language?: string;
} {
const settings = isPlainRecord(this.settings) ? this.settings : {};
const responseFormat =
typeof settings.responseFormat === 'string' ? settings.responseFormat : undefined;
const textFn = response.text;
const jsonFn = response.json;
const hasText = typeof textFn === 'function';
const hasJson = typeof jsonFn === 'function';
if (responseFormat === 'text' && hasText) {
const text = await textFn();
return { text: text ?? '' };
if (responseFormat === 'text' && response && typeof response.text === 'string') {
return { text: response.text ?? '' };
}
if (hasJson) {
const json = await jsonFn();
if (response && response.json !== undefined) {
const json = response.json;
if (isPlainRecord(json)) {
const text = typeof json.text === 'string' ? json.text : '';
const language = typeof json.language === 'string' ? json.language : undefined;
@ -489,22 +487,21 @@ export class TranscriptionService implements ITranscriptionService {
}
}
if (hasText) {
const text = await textFn();
return { text: text ?? '' };
if (response && typeof response.text === 'string') {
return { text: response.text ?? '' };
}
return { text: '' };
}
private async extractErrorMessage(response: {
private extractErrorMessage(response: {
status?: number;
statusText?: string;
json?: () => Promise<unknown>;
text?: () => Promise<string>;
}): Promise<string> {
if (typeof response.json === 'function') {
const json = await response.json();
json?: unknown;
text?: string;
}): string {
if (response && response.json !== undefined) {
const json = response.json;
if (isPlainRecord(json)) {
const error = isPlainRecord(json.error) ? json.error : undefined;
if (typeof error?.message === 'string') {
@ -516,14 +513,14 @@ export class TranscriptionService implements ITranscriptionService {
}
}
if (typeof response.text === 'function') {
const text = await response.text();
if (response && typeof response.text === 'string') {
const text = response.text;
if (text) {
return text;
}
}
if (response.statusText) {
if (response && response.statusText) {
return response.statusText;
}
@ -638,7 +635,12 @@ export class TranscriptionService implements ITranscriptionService {
if (!effectiveTimeout) {
return promise;
}
if (this.isTestEnvironment() && typeof (setTimeout as any).mock === 'object' && !signal) {
if (
this.isTestEnvironment() &&
'mock' in setTimeout &&
typeof (setTimeout as any).mock === 'object' &&
!signal
) {
return promise;
}

View file

@ -45,10 +45,10 @@ export interface DeepgramSpecificOptions {
punctuate?: boolean;
smartFormat?: boolean;
diarize?: boolean;
utterances?: boolean;
numerals?: boolean;
profanityFilter?: boolean;
redact?: string[];
utterances?: boolean;
keywords?: string[];
detectLanguage?: boolean;
}

View file

@ -302,7 +302,7 @@ export class DeepgramServiceRefactored {
punctuate: options?.punctuate !== false,
smart_format: options?.smartFormat,
diarize: options?.diarize,
utterances: (options as any)?.utterances,
utterances: options?.utterances,
numerals: options?.numerals,
profanity_filter: options?.profanityFilter,
};
@ -336,7 +336,11 @@ export class DeepgramServiceRefactored {
/**
* Handle API errors
*/
private handleApiError(response: any): never {
private handleApiError(response: {
status: number;
json: any;
headers?: Record<string, string>;
}): never {
const errorBody = response.json;
const errorMessage = errorBody?.message ?? errorBody?.error ?? 'Unknown error';
@ -356,7 +360,13 @@ export class DeepgramServiceRefactored {
false,
402
),
429: () => new ProviderRateLimitError('deepgram', response.headers?.['retry-after']),
429: () => {
const retryAfter = response.headers?.['retry-after'];
return new ProviderRateLimitError(
'deepgram',
retryAfter ? parseInt(retryAfter, 10) : undefined
);
},
500: () => new ProviderUnavailableError('deepgram'),
502: () => new ProviderUnavailableError('deepgram'),
503: () => new ProviderUnavailableError('deepgram'),
@ -379,7 +389,15 @@ export class DeepgramServiceRefactored {
/**
* Create segments from words
*/
private createSegments(words?: any[]): TranscriptionSegment[] {
private createSegments(
words?: Array<{
word: string;
confidence: number;
start: number;
end: number;
speaker?: number;
}>
): TranscriptionSegment[] {
if (!words?.length) {
return [];
}
@ -400,7 +418,16 @@ export class DeepgramServiceRefactored {
/**
* Create a single segment from words
*/
private createSegment(words: any[], id: number): TranscriptionSegment {
private createSegment(
words: Array<{
word: string;
confidence: number;
start: number;
end: number;
speaker?: number;
}>,
id: number
): TranscriptionSegment {
const text = words.map((w) => w.word).join(' ');
const totalConfidence = words.reduce((acc, w) => acc + w.confidence, 0);
@ -410,7 +437,7 @@ export class DeepgramServiceRefactored {
end: words[words.length - 1].end,
text,
confidence: totalConfidence / words.length,
speaker: words[0].speaker,
speaker: words[0].speaker !== undefined ? String(words[0].speaker) : undefined,
};
}

View file

@ -456,13 +456,11 @@ export class ModelMigrationService {
return false; // 기본적으로 사용자 동의 필요
case 'cost_threshold': {
const _maxIncrease = condition.parameters.maxIncrease || 20;
// 비용 증가율 계산 로직
return true; // 임시 구현
}
case 'feature_compatible': {
const _requiredFeatures = condition.parameters.requiredFeatures || [];
// 기능 호환성 체크
return true; // 임시 구현
}

View file

@ -30,7 +30,7 @@ export interface CacheStats {
hitRate: number;
}
export class MemoryCache<T = any> {
export class MemoryCache<T = unknown> {
private cache = new Map<string, CacheEntry<T>>();
private accessOrder: string[] = [];
private stats: CacheStats = {
@ -208,8 +208,8 @@ export class MemoryCache<T = any> {
/**
* ()
*/
dump(): Record<string, any> {
const entries: Record<string, any> = {};
dump(): Record<string, unknown> {
const entries: Record<string, unknown> = {};
for (const [key, entry] of this.cache.entries()) {
entries[key] = {
@ -281,7 +281,7 @@ export class MemoryCache<T = any> {
/**
*
*/
private estimateSize(data: any): number {
private estimateSize(data: unknown): number {
try {
const str = JSON.stringify(data);
return str.length * 2; // UTF-16 기준
@ -335,10 +335,10 @@ export const globalCache = new MemoryCache({
*
*/
export function Cacheable(options: { ttl?: number; key?: string } = {}) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
return function (target: object, propertyName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
descriptor.value = async function (...args: unknown[]) {
const cacheKey =
options.key || `${target.constructor.name}.${propertyName}:${JSON.stringify(args)}`;

View file

@ -5,21 +5,21 @@ export { ILogger };
export class Logger implements ILogger {
constructor(private prefix: string) {}
debug(message: string, context?: any): void {
debug(message: string, context?: unknown): void {
if (process.env.NODE_ENV === 'development') {
console.debug(`[${this.prefix}] DEBUG:`, message, context || '');
}
}
info(message: string, context?: any): void {
info(message: string, context?: unknown): void {
console.debug(`[${this.prefix}] INFO:`, message, context || '');
}
warn(message: string, context?: any): void {
warn(message: string, context?: unknown): void {
console.warn(`[${this.prefix}] WARN:`, message, context || '');
}
error(message: string, error?: Error, context?: any): void {
error(message: string, error?: Error, context?: unknown): void {
console.error(`[${this.prefix}] ERROR:`, message, error, context || '');
}
}

View file

@ -3,13 +3,7 @@
* Web Crypto API를 /
*/
/* eslint-disable @typescript-eslint/no-unsafe-assignment -- Obsidian's loadLocalStorage returns any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access -- Required for settings field access */
/* eslint-disable @typescript-eslint/no-unsafe-argument -- Required for JSON.parse results */
/* eslint-disable @typescript-eslint/no-unsafe-return -- Required for settings object return */
/* eslint-disable @typescript-eslint/no-explicit-any -- SettingsEncryptor handles dynamic settings */
import { type App, Notice } from 'obsidian';
import { type App, Notice, Platform } from 'obsidian';
export interface EncryptedData {
data: string; // Base64 encoded encrypted data
@ -48,7 +42,7 @@ export class Encryptor implements IEncryptor {
private initializeVaultSalt(): void {
if (!this.app) return;
const storedSalt = this.app.loadLocalStorage('encryption_vault_salt');
const storedSalt = this.app.loadLocalStorage('encryption_vault_salt') as string | null;
if (storedSalt) {
this.vaultSalt = storedSalt;
} else {
@ -79,7 +73,7 @@ export class Encryptor implements IEncryptor {
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
salt: salt.buffer as ArrayBuffer,
iterations: this.iterations,
hash: 'SHA-256',
},
@ -110,75 +104,6 @@ export class Encryptor implements IEncryptor {
throw new Error('Vault salt not initialized. Call setApp() first.');
}
/**
* Legacy password generation for migration from older versions.
* Attempts to reconstruct the old system password for backward compatibility.
*
* @deprecated This method uses platform-specific APIs (navigator, screen, Intl)
* for backward compatibility only. It will be removed in a future version
* when migration is no longer needed (target: v4.0.0).
* DO NOT use these APIs in new code - they trigger Obsidian plugin review issues.
*/
private getLegacySystemPassword(): string | null {
// Feature detection - check if required platform APIs are available
const hasNavigator = typeof navigator !== 'undefined';
const hasScreen = typeof screen !== 'undefined';
const hasIntl = typeof Intl !== 'undefined';
// If none of the platform APIs are available, migration is not possible
if (!hasNavigator && !hasScreen && !hasIntl) {
console.warn(
'Legacy migration not available: platform APIs not accessible in this environment'
);
return null;
}
try {
// LEGACY CODE - Required for migration from pre-3.1.0 versions only
// Uses platform APIs that are deprecated for new functionality
// NOTE: navigator/screen API usage below is intentional for backward compatibility
// Will be removed in v4.0.0 when migration period ends
const factors = [
hasNavigator ? navigator.userAgent : '',
hasNavigator ? navigator.language : '',
hasScreen ? `${screen.width}x${screen.height}` : '',
hasIntl ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
'ObsidianSpeechToText2024',
];
return factors.join('|');
} catch (legacyError) {
console.warn('Legacy password generation failed:', legacyError);
return null;
}
}
/**
* Attempt decryption with legacy password (for migration).
*/
async decryptWithLegacy(encryptedData: EncryptedData): Promise<string> {
const legacyPassword = this.getLegacySystemPassword();
if (!legacyPassword) {
throw new Error('Legacy password generation failed');
}
const encryptedBuffer = this.base64ToBuffer(encryptedData.data);
const iv = this.base64ToBuffer(encryptedData.iv);
const salt = this.base64ToBuffer(encryptedData.salt);
const key = await this.deriveKey(legacyPassword, salt);
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: this.algorithm,
iv,
},
key,
encryptedBuffer
);
return new TextDecoder().decode(decryptedBuffer);
}
/**
*
*/
@ -196,10 +121,10 @@ export class Encryptor implements IEncryptor {
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: this.algorithm,
iv,
iv: iv.buffer as ArrayBuffer,
},
key,
encodedText
encodedText.buffer as ArrayBuffer
);
// Base64 인코딩
@ -231,10 +156,10 @@ export class Encryptor implements IEncryptor {
const decryptedBuffer = await crypto.subtle.decrypt(
{
name: this.algorithm,
iv,
iv: iv.buffer as ArrayBuffer,
},
key,
encryptedBuffer
encryptedBuffer.buffer as ArrayBuffer
);
// 텍스트 디코딩
@ -273,6 +198,15 @@ export class Encryptor implements IEncryptor {
}
}
/**
* EncryptedData
*/
export function isEncryptedData(data: unknown): data is EncryptedData {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
return typeof d.data === 'string' && typeof d.iv === 'string' && typeof d.salt === 'string';
}
/**
* API
*
@ -356,43 +290,18 @@ export class SecureApiKeyManager {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const storedData = this.app.loadLocalStorage(this.storageKey);
const storedData = this.app.loadLocalStorage(this.storageKey) as string | null;
if (!storedData) {
return null;
}
const encrypted: EncryptedData = JSON.parse(storedData);
const encrypted: EncryptedData = JSON.parse(storedData) as EncryptedData;
try {
// Try new password first
return await this.encryptor.decrypt(encrypted);
} catch (newPasswordError) {
// Log the error before attempting legacy migration
console.debug(
'New password decryption failed, attempting legacy migration:',
newPasswordError
);
// Fall back to legacy password for migration
if (this.encryptor instanceof Encryptor) {
try {
const decrypted = await this.encryptor.decryptWithLegacy(encrypted);
// Re-encrypt with new password for future use
const reEncrypted = await this.encryptor.encrypt(decrypted);
this.app.saveLocalStorage(this.storageKey, JSON.stringify(reEncrypted));
// Show notice only after successful save
new Notice('API key migrated to new encryption format.');
return decrypted;
} catch (legacyError) {
console.error('Legacy decryption also failed:', legacyError);
new Notice(
'Failed to decrypt API key. Please re-enter your API key in settings.',
10000
);
throw legacyError;
}
}
throw new Error('Decryption failed');
} catch (decryptionError) {
console.error('Decryption failed:', decryptionError);
throw decryptionError;
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
@ -408,7 +317,7 @@ export class SecureApiKeyManager {
console.error('Failed to retrieve API key after retries:', lastError);
// Backup corrupted data for potential recovery
const corruptedData = this.app.loadLocalStorage(this.storageKey);
const corruptedData = this.app.loadLocalStorage(this.storageKey) as string | null;
if (corruptedData) {
const backupKey = `${this.storageKey}_backup_${Date.now()}`;
this.app.saveLocalStorage(backupKey, corruptedData);
@ -424,7 +333,7 @@ export class SecureApiKeyManager {
* API
*/
hasApiKey(): boolean {
return this.app.loadLocalStorage(this.storageKey) !== null;
return (this.app.loadLocalStorage(this.storageKey) as string | null) !== null;
}
/**
@ -485,9 +394,11 @@ export class SettingsEncryptor {
/**
*
*/
async encryptSensitiveSettings(settings: any): Promise<any> {
async encryptSensitiveSettings(
settings: Record<string, unknown>
): Promise<Record<string, unknown>> {
const sensitiveFields = ['apiKey', 'tokens', 'credentials'];
const encryptedSettings = { ...settings };
const encryptedSettings: Record<string, any> = { ...settings };
for (const field of sensitiveFields) {
if (settings[field]) {
@ -503,14 +414,17 @@ export class SettingsEncryptor {
/**
*
*/
async decryptSensitiveSettings(encryptedSettings: any): Promise<any> {
const settings = { ...encryptedSettings };
async decryptSensitiveSettings(
encryptedSettings: Record<string, unknown>
): Promise<Record<string, unknown>> {
const settings: Record<string, any> = { ...encryptedSettings };
const sensitiveFields = ['apiKey', 'tokens', 'credentials'];
for (const field of sensitiveFields) {
if (encryptedSettings[field] && typeof encryptedSettings[field] === 'object') {
const fieldValue = encryptedSettings[field];
if (fieldValue && typeof fieldValue === 'object' && isEncryptedData(fieldValue)) {
try {
const decrypted = await this.encryptor.decrypt(encryptedSettings[field]);
const decrypted = await this.encryptor.decrypt(fieldValue);
settings[field] = JSON.parse(decrypted);
} catch (error) {
console.error(`Failed to decrypt ${field}:`, error);

View file

@ -43,7 +43,7 @@ export class ProgressIndicator {
this.progressElement.addClass('sn-fade-hidden');
// 오버레이
const _overlay = this.progressElement.createDiv('progress-overlay');
this.progressElement.createDiv('progress-overlay');
// 컨테이너
const content = this.progressElement.createDiv('progress-content');
@ -58,7 +58,7 @@ export class ProgressIndicator {
// 진행률 바
const progressBar = barContainer.createDiv('progress-bar');
const _progressFill = progressBar.createDiv('progress-fill');
progressBar.createDiv('progress-fill');
const progressText = progressBar.createDiv('progress-text');
progressText.setText('0%');

View file

@ -32,7 +32,6 @@ export class ConfirmationModal extends Modal {
cancelButton.setDisabled(true);
confirmButton.setDisabled(true);
this.close();
if (this.onCancel) {
try {
await this.onCancel();
@ -41,6 +40,7 @@ export class ConfirmationModal extends Modal {
new Notice('Action failed. Please check console for details.');
}
}
this.close();
});
const confirmButton = new ButtonComponent(buttonContainer)
@ -52,12 +52,15 @@ export class ConfirmationModal extends Modal {
cancelButton.setDisabled(true);
confirmButton.setDisabled(true);
this.close();
try {
await this.onConfirm();
this.close();
} catch (error) {
console.error('ConfirmationModal: onConfirm callback error:', error);
new Notice('Action failed. Please check console for details.');
this.isProcessing = false;
cancelButton.setDisabled(false);
confirmButton.setDisabled(false);
}
});
}

View file

@ -192,13 +192,13 @@ export class MessageStore {
},
'step.file_selection': {
ko: '파일 선택',
en: 'File Selection',
en: 'File selection',
ja: 'ファイル選択',
zh: '文件选择',
},
'step.file_validation': {
ko: '파일 검증',
en: 'File Validation',
en: 'File validation',
ja: 'ファイル検証',
zh: '文件验证',
},
@ -210,7 +210,7 @@ export class MessageStore {
},
'step.processing': {
ko: 'API 처리',
en: 'API Processing',
en: 'API processing',
ja: 'API処理',
zh: 'API处理',
},

View file

@ -2,9 +2,6 @@
* Phase 3 UI
*/
/* eslint-disable @typescript-eslint/await-thenable -- Legacy patterns */
/* eslint-disable @typescript-eslint/no-misused-promises -- Event handler patterns */
import { App, PluginSettingTab, Setting, Notice, Modal, ButtonComponent } from 'obsidian';
import type SpeechToTextPlugin from '../../main';
import { SettingsAPI } from '../../infrastructure/api/SettingsAPI';
@ -114,7 +111,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
this.createHeader(containerEl);
// 탭 네비게이션
const _tabContainer = this.createTabNavigation(containerEl);
this.createTabNavigation(containerEl);
// 섹션 컨테이너
const contentContainer = containerEl.createDiv({ cls: 'settings-content' });
@ -201,7 +198,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
tabEl.setAttribute('data-tab', tab.id);
tabEl.addEventListener('click', () => {
tabEl.addEventListener('click', async () => {
// 활성 탭 업데이트
tabContainer.querySelectorAll('.settings-tab').forEach((el) => {
el.removeClass('active');
@ -211,7 +208,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
// 콘텐츠 표시
const contentContainer = containerEl.querySelector('.settings-content');
if (contentContainer instanceof HTMLElement) {
this.showTabContent(contentContainer, tab.id);
await this.showTabContent(contentContainer, tab.id);
}
});
});
@ -225,7 +222,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
/**
*
*/
private showTabContent(container: HTMLElement, tabId: string): void {
private async showTabContent(container: HTMLElement, tabId: string): Promise<void> {
container.empty();
switch (tabId) {
@ -233,16 +230,16 @@ export class EnhancedSettingsTab extends PluginSettingTab {
this.showGeneralSettings(container);
break;
case 'api':
void this.showApiSettings(container);
await this.showApiSettings(container);
break;
case 'audio':
void this.showAudioSettings(container);
await this.showAudioSettings(container);
break;
case 'advanced':
void this.showAdvancedSettings(container);
await this.showAdvancedSettings(container);
break;
case 'shortcuts':
void this.showShortcutSettings(container);
await this.showShortcutSettings(container);
break;
case 'about':
this.showAbout(container);
@ -413,7 +410,7 @@ export class EnhancedSettingsTab extends PluginSettingTab {
await this.settingsAPI.set('api', api);
// UI 업데이트
void this.showApiSettings(container);
await this.showApiSettings(container);
}
});
});

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/require-await -- Legacy callback patterns */
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison -- Type guard pattern */
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
@ -54,8 +53,8 @@ export class SettingsTab extends PluginSettingTab {
// Add debug info section at the top
const debugSection = containerEl.createEl('details', { cls: 'speech-to-text-debug' });
const _debugSummary = debugSection.createEl('summary', { text: 'Debug information' });
const _debugContent = debugSection.createEl('pre', {
debugSection.createEl('summary', { text: 'Debug information' });
debugSection.createEl('pre', {
text: JSON.stringify(
{
pluginExists: !!this.plugin,
@ -366,8 +365,8 @@ export class SettingsTab extends PluginSettingTab {
private renderWhisperApiKey(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName('Openai API key')
.setDesc('Enter your openai API key for whisper transcription')
.setName('OpenAI API key')
.setDesc('Enter your OpenAI API key for Whisper transcription')
.addText((text) => {
text.setPlaceholder('sk-...')
.setValue(this.maskApiKey(this.plugin.settings.apiKey || ''))
@ -401,7 +400,7 @@ export class SettingsTab extends PluginSettingTab {
private renderDeepgramApiKey(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName('Deepgram API key')
.setDesc('Enter your deepgram API key for transcription')
.setDesc('Enter your Deepgram API key for transcription')
.addText((text) => {
text.setPlaceholder('Enter Deepgram API key...')
.setValue(this.maskApiKey(this.plugin.settings.deepgramApiKey || ''))

View file

@ -490,11 +490,9 @@ export class SettingsTabRefactored extends PluginSettingTab {
*
*/
private exportSettings(): Promise<void> {
const {
apiKey: _apiKey,
encryptedApiKey: _encryptedApiKey,
...exportSettings
} = this.plugin.settings;
const exportSettings = { ...this.plugin.settings };
delete (exportSettings as any).apiKey;
delete (exportSettings as any).encryptedApiKey;
const json = JSON.stringify(exportSettings, null, 2);
const blob = new Blob([json], { type: 'application/json' });

View file

@ -225,7 +225,7 @@ export class DeepgramSettings {
.setName(UI_CONSTANTS.MESSAGES.MODEL_LABEL)
.setDesc(UI_CONSTANTS.MESSAGES.MODEL_DESC);
const _dropdown = setting.addDropdown((dropdown) => {
setting.addDropdown((dropdown) => {
this.populateModelDropdown(dropdown);
this.setupModelDropdownHandlers(dropdown);
return dropdown;

View file

@ -1,4 +1,4 @@
import { Notice } from 'obsidian';
import { Notice, Platform } from 'obsidian';
import type { App } from 'obsidian';
/**
@ -381,7 +381,7 @@ export class ErrorBoundary {
message: this.getSafeErrorMessage(error),
type: error instanceof SettingsError ? error.type : 'UNKNOWN',
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
platform: Platform.isDesktopApp ? 'desktop' : 'mobile',
};
// 로컬 스토리지에 에러 로그 저장

View file

@ -232,7 +232,7 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent {
providers.forEach((provider) => {
const hasKey = this.hasApiKey(provider);
const isConnected = state.connectionStatus.get(provider) || false;
const _lastValidated = state.lastValidation.get(provider);
state.lastValidation.get(provider);
UIComponentFactory.createCard(
containerEl,
@ -309,13 +309,13 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent {
);
// Provider 선택 UI
this.renderProviderSelectionContent(contentEl);
this.renderProviderSelectionContent();
}
/**
* Provider
*/
private renderProviderSelectionContent(_containerEl: HTMLElement): void {
private renderProviderSelectionContent(): void {
// 여기에 기존 Provider 선택 UI 로직
// 코드 간결성을 위해 생략
}
@ -353,13 +353,13 @@ export class ProviderSettingsContainerRefactored extends BaseSettingsComponent {
const metrics = this.memoized('metrics', () => this.calculateMetrics());
// 차트나 그래프로 표시
this.renderMetricsCharts(section, metrics);
this.renderMetricsCharts();
}
/**
*
*/
private renderMetricsCharts(_containerEl: HTMLElement, _metrics: any): void {
private renderMetricsCharts(): void {
// 여기에 차트 렌더링 로직
// 실제 구현은 차트 라이브러리 사용
}

View file

@ -1,7 +1,7 @@
import { Setting, Notice, ButtonComponent, Modal, requestUrl } from 'obsidian';
import type SpeechToTextPlugin from '../../../../main';
import { TranscriptionProvider } from '../../../../infrastructure/api/providers/ITranscriber';
import { Encryptor } from '../../../../infrastructure/security/Encryptor';
import { Encryptor, isEncryptedData } from '../../../../infrastructure/security/Encryptor';
/**
* API Key Manager Component
@ -142,7 +142,7 @@ export class APIKeyManager {
}
// 가시성 토글 버튼
const _visibilityBtn = this.createVisibilityToggle(inputContainer, inputEl, provider);
this.createVisibilityToggle(inputContainer, inputEl, provider);
// 검증 버튼
const validateBtn = this.createValidationButton(
@ -153,10 +153,10 @@ export class APIKeyManager {
);
// 복사 버튼
const _copyBtn = this.createCopyButton(inputContainer, provider);
this.createCopyButton(inputContainer, provider);
// 삭제 버튼
const _deleteBtn = this.createDeleteButton(inputContainer, provider);
this.createDeleteButton(inputContainer, provider);
// 입력 이벤트 핸들러
this.attachInputHandlers(inputEl, provider, validationRegex, validateBtn);
@ -424,33 +424,40 @@ export class APIKeyManager {
if (value && !value.includes('*')) {
if (validationRegex.test(value)) {
inputEl.removeClass('invalid');
inputEl.addClass('valid-format');
inputEl.classList.remove('invalid');
inputEl.classList.add('valid-format');
validateBtn.disabled = false;
} else {
inputEl.removeClass('valid-format');
inputEl.addClass('invalid');
inputEl.classList.remove('valid-format');
inputEl.classList.add('invalid');
validateBtn.disabled = true;
}
}
});
// 포커스 아웃시 저장
inputEl.addEventListener('blur', async () => {
const value = inputEl.value;
inputEl.addEventListener('blur', () => {
void (async () => {
try {
const value = inputEl.value;
// 마스킹된 값이거나 빈 값이면 무시
if (value.includes('*') || !value) return;
// 마스킹된 값이거나 빈 값이면 무시
if (value.includes('*') || !value) return;
if (validationRegex.test(value)) {
await this.saveApiKey(provider, value);
inputEl.addClass('has-value');
if (validationRegex.test(value)) {
await this.saveApiKey(provider, value);
inputEl.classList.add('has-value');
// 자동 검증 (옵션)
if (this.plugin.settings.autoValidateKeys) {
await this.validateApiKey(provider, value, validateBtn);
// 자동 검증 (옵션)
if (this.plugin.settings.autoValidateKeys) {
await this.validateApiKey(provider, value, validateBtn);
}
}
} catch (error) {
console.error('APIKeyManager: Error saving API key on blur:', error);
new Notice('Failed to save API key. Please check console for details.');
}
}
})();
});
// Enter 키로 검증
@ -467,7 +474,7 @@ export class APIKeyManager {
private renderLastValidation(containerEl: HTMLElement, provider: TranscriptionProvider): void {
const status = this.validationStatus.get(provider);
if (status && status.lastValidated) {
const _timeEl = containerEl.createEl('div', {
containerEl.createEl('div', {
cls: 'last-validation-time',
text: `Last verified: ${this.formatTime(status.lastValidated)}`,
});
@ -788,7 +795,7 @@ export class APIKeyManager {
modal.titleEl.setText('Environment variable setup');
const contentEl = modal.contentEl;
const instructions = contentEl.createDiv('env-var-instructions');
const instructions = contentEl.createDiv('env-var-instructions') as HTMLDivElement;
instructions.createEl('p', { text: 'To use environment variables for API keys:' });
const orderedList = instructions.createEl('ol');
@ -896,9 +903,11 @@ 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!);
const decrypted = await this.encryptor.decrypt(encryptedData);
this.apiKeys.set('whisper', decrypted);
const encryptedData = JSON.parse(key!) as unknown;
if (isEncryptedData(encryptedData)) {
const decrypted = await this.encryptor.decrypt(encryptedData);
this.apiKeys.set('whisper', decrypted);
}
} catch (error) {
// 암호화되지 않은 키일 수 있음 (마이그레이션)
const key = this.plugin.settings.apiKey;
@ -910,9 +919,11 @@ export class APIKeyManager {
if (this.plugin.settings.deepgramApiKey) {
try {
const encryptedData = JSON.parse(this.plugin.settings.deepgramApiKey);
const decrypted = await this.encryptor.decrypt(encryptedData);
this.apiKeys.set('deepgram', decrypted);
const encryptedData = JSON.parse(this.plugin.settings.deepgramApiKey) as unknown;
if (isEncryptedData(encryptedData)) {
const decrypted = await this.encryptor.decrypt(encryptedData);
this.apiKeys.set('deepgram', decrypted);
}
} catch (error) {
// 암호화되지 않은 키일 수 있음
const key = this.plugin.settings.deepgramApiKey;

View file

@ -1,7 +1,7 @@
import { App, Setting, Notice, ButtonComponent, Modal, requestUrl } from 'obsidian';
import type SpeechToTextPlugin from '../../../../main';
import { TranscriptionProvider } from '../../../../infrastructure/api/providers/ITranscriber';
import { Encryptor } from '../../../../infrastructure/security/Encryptor';
import { Encryptor, isEncryptedData } from '../../../../infrastructure/security/Encryptor';
import { BaseSettingsComponent } from '../../base/BaseSettingsComponent';
import { UIComponentFactory } from '../../base/CommonUIComponents';
import { isPlainRecord } from '../../../../types/guards';
@ -274,13 +274,13 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
if (value.includes('*')) return;
// 디바운스된 검증
setTimeout(() => {
window.setTimeout(() => {
this.validateInputFormat(inputEl, value, config.pattern);
}, 500);
});
// 포커스 아웃시 저장
inputEl.addEventListener('blur', async (e) => {
inputEl.addEventListener('blur', (e) => {
const target = e.target;
if (!(target instanceof HTMLInputElement)) {
return;
@ -288,7 +288,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
const value = target.value;
if (value && !value.includes('*') && config.pattern.test(value)) {
await this.saveApiKey(provider, value);
void this.saveApiKey(provider, value);
}
});
@ -434,8 +434,8 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
},
});
btn.onclick = async () => {
await this.deleteApiKey(provider);
btn.onclick = () => {
void this.deleteApiKey(provider);
};
return btn;
@ -456,7 +456,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
const status = state?.status || 'unverified';
const config = statusMap[status];
const _statusEl = containerEl.createSpan({
containerEl.createSpan({
cls: `key-status ${config.class}`,
text: `${config.icon} ${config.text}`,
attr: {
@ -472,7 +472,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
private renderValidationMessage(containerEl: HTMLElement, state: ValidationState): void {
if (!state.message) return;
const _messageEl = containerEl.createDiv({
containerEl.createDiv({
cls: `validation-message message-${state.status}`,
text: state.message,
attr: {
@ -486,7 +486,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
*
*/
private renderLastValidated(containerEl: HTMLElement, date: Date): void {
const _timeEl = containerEl.createDiv({
containerEl.createDiv({
cls: 'last-validated',
text: `최근 검증: ${this.formatRelativeTime(date)}`,
attr: {
@ -533,7 +533,9 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
const actions = [
{
text: '모든 키 검증',
onClick: () => this.verifyAllKeys(),
onClick: () => {
void this.verifyAllKeys();
},
primary: true,
},
{
@ -542,7 +544,9 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
},
{
text: '키 내보내기',
onClick: () => this.exportKeys(),
onClick: () => {
void this.exportKeys();
},
},
];
@ -812,7 +816,7 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
await this.withErrorHandling(async () => {
const content = await file.text();
const parsed = JSON.parse(content);
const parsed = JSON.parse(content) as unknown;
const keys: Record<string, string> = {};
if (isPlainRecord(parsed)) {
Object.entries(parsed).forEach(([key, value]) => {
@ -951,13 +955,15 @@ export class APIKeyManagerRefactored extends BaseSettingsComponent {
for (const { provider, key } of providers) {
if (key) {
try {
const encryptedData = JSON.parse(key);
const decrypted = await this.encryptor.decrypt(encryptedData);
this.apiKeys.set(provider, {
provider,
key: decrypted,
isValid: false,
});
const encryptedData = JSON.parse(key) as unknown;
if (isEncryptedData(encryptedData)) {
const decrypted = await this.encryptor.decrypt(encryptedData);
this.apiKeys.set(provider, {
provider,
key: decrypted,
isValid: false,
});
}
} catch (error) {
// 암호화되지 않은 키일 수 있음
if (!key.includes('*')) {

View file

@ -12,10 +12,12 @@ export class SimpleEventEmitter {
* Register an event listener
*/
on(event: string, callback: EventCallback): this {
if (!this.events.has(event)) {
this.events.set(event, new Set());
let set = this.events.get(event);
if (!set) {
set = new Set();
this.events.set(event, set);
}
this.events.get(event)!.add(callback);
set.add(callback);
return this;
}

View file

@ -1,325 +0,0 @@
/* Speech-to-Text Plugin Styles */
/* Import settings styles */
@import url('./src/ui/settings/settings.css');
/* Utility Helpers */
.sn-hidden {
display: none !important;
}
.sn-flex {
display: flex !important;
}
.sn-block {
display: block !important;
}
.sn-fade {
transition: opacity 0.3s ease;
}
.sn-fade-hidden {
opacity: 0 !important;
}
.sn-fade-visible {
opacity: 1 !important;
}
.sn-info-box {
margin: 10px 0;
padding: 10px;
background: var(--background-secondary);
border-radius: 4px;
}
/* Support Section */
.speech-to-text-separator {
margin: 2em 0 1.5em 0;
border: none;
border-top: 1px solid var(--background-modifier-border);
}
/* File Picker Modal */
.speech-to-text-file-list {
max-height: 400px;
overflow-y: auto;
margin-top: 1em;
}
.speech-to-text-file-item {
padding: 0.5em 1em;
margin: 0.25em 0;
background-color: var(--background-secondary);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.speech-to-text-file-item:hover {
background-color: var(--background-modifier-hover);
}
.speech-to-text-file-item:active {
background-color: var(--background-modifier-active-hover);
}
/* Transcription Modal */
.speech-to-text-modal {
min-width: 400px;
}
.speech-to-text-progress {
margin: 1em 0;
}
.speech-to-text-progress-bar {
width: 100%;
height: 20px;
background-color: var(--background-secondary);
border-radius: 10px;
overflow: hidden;
}
.speech-to-text-progress-fill {
height: 100%;
background-color: var(--interactive-accent);
transition: width 0.3s ease;
}
.speech-to-text-status {
display: flex;
align-items: center;
gap: 0.5em;
margin: 1em 0;
padding: 0.75em;
background-color: var(--background-secondary);
border-radius: 4px;
}
.speech-to-text-status-icon {
font-size: 1.2em;
}
.speech-to-text-status-text {
flex: 1;
}
/* Settings */
.speech-to-text-settings {
padding: 1em 0;
}
.speech-to-text-api-key-input {
font-family: monospace;
}
/* Error Messages */
.speech-to-text-error {
color: var(--text-error);
background-color: var(--background-modifier-error);
padding: 0.75em;
border-radius: 4px;
margin: 1em 0;
}
.speech-to-text-warning {
color: var(--text-warning);
background-color: var(--background-modifier-warning);
padding: 0.75em;
border-radius: 4px;
margin: 1em 0;
}
.speech-to-text-success {
color: var(--text-success);
background-color: var(--background-modifier-success);
padding: 0.75em;
border-radius: 4px;
margin: 1em 0;
}
/* Loading Animation */
.speech-to-text-loading {
display: inline-block;
position: relative;
width: 80px;
height: 20px;
}
.speech-to-text-loading div {
position: absolute;
top: 8px;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--interactive-accent);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.speech-to-text-loading div:nth-child(1) {
left: 8px;
animation: speech-to-text-loading1 0.6s infinite;
}
.speech-to-text-loading div:nth-child(2) {
left: 8px;
animation: speech-to-text-loading2 0.6s infinite;
}
.speech-to-text-loading div:nth-child(3) {
left: 32px;
animation: speech-to-text-loading2 0.6s infinite;
}
.speech-to-text-loading div:nth-child(4) {
left: 56px;
animation: speech-to-text-loading3 0.6s infinite;
}
@keyframes speech-to-text-loading1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes speech-to-text-loading3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes speech-to-text-loading2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
/* Timestamp Styles */
.speech-to-text-timestamp {
color: var(--text-muted);
font-size: 0.9em;
font-family: monospace;
margin-right: 0.5em;
}
.speech-to-text-timestamp-inline {
display: inline-block;
background-color: var(--background-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.speech-to-text-timestamp-sidebar {
position: absolute;
left: -100px;
width: 90px;
text-align: right;
}
/* History View */
.speech-to-text-history {
padding: 1em;
}
.speech-to-text-history-item {
display: flex;
align-items: center;
gap: 1em;
padding: 0.75em;
margin: 0.5em 0;
background-color: var(--background-secondary);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.speech-to-text-history-item:hover {
background-color: var(--background-modifier-hover);
}
.speech-to-text-history-icon {
font-size: 1.5em;
}
.speech-to-text-history-info {
flex: 1;
}
.speech-to-text-history-file {
font-weight: 500;
}
.speech-to-text-history-date {
font-size: 0.9em;
color: var(--text-muted);
}
.speech-to-text-history-status {
padding: 0.2em 0.5em;
border-radius: 3px;
font-size: 0.85em;
}
.speech-to-text-history-status.success {
background-color: var(--background-modifier-success);
color: var(--text-success);
}
.speech-to-text-history-status.error {
background-color: var(--background-modifier-error);
color: var(--text-error);
}
/* Progress components - CSS variables */
.progress-bar__fill,
.toast__progress-fill,
.notification__progress-fill,
.progress-fill {
width: var(--sn-progress-width, 0%);
transition: width 0.3s ease-in-out;
}
.progress-bar__fill--indeterminate {
width: 100%;
}
.bar-chart__bar {
height: var(--sn-bar-height, 0%);
}
.weight-segment,
.spending-progress,
.split-a,
.split-b {
width: var(--sn-width-pct, 0%);
}
.circular-progress,
.semi-circular-progress {
width: var(--cp-size, 100px);
}
.circular-progress {
height: var(--cp-size, 100px);
}
.semi-circular-progress {
height: calc(var(--cp-size, 100px) / 2);
}
.circular-progress__circle,
.semi-circular-progress__circle {
transition: stroke-dashoffset var(--cp-animation-duration, 500ms) ease-in-out;
}