mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 06:43:33 +00:00
* 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:
parent
e436b94a97
commit
5cf1effdeb
24 changed files with 256 additions and 745 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,6 +7,8 @@ node_modules/
|
|||
dist/
|
||||
build/
|
||||
out/
|
||||
main.js
|
||||
styles.css
|
||||
*.js
|
||||
*.js.map
|
||||
*.d.ts
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/asyouplz/SpeechNote/releases)
|
||||
[](https://github.com/asyouplz/SpeechNote/releases)
|
||||
[](LICENSE)
|
||||
[](https://obsidian.md)
|
||||
[](https://platform.openai.com/docs/guides/speech-to-text)
|
||||
|
|
|
|||
121
main.js
121
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; // 임시 구현
|
||||
}
|
||||
|
|
|
|||
12
src/infrastructure/cache/MemoryCache.ts
vendored
12
src/infrastructure/cache/MemoryCache.ts
vendored
|
|
@ -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)}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || '');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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%');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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处理',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 || ''))
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
// 로컬 스토리지에 에러 로그 저장
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// 여기에 차트 렌더링 로직
|
||||
// 실제 구현은 차트 라이브러리 사용
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('*')) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
325
styles.css
325
styles.css
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue