mirror of
https://github.com/asyouplz/SpeechNote.git
synced 2026-07-22 16:30:31 +00:00
Apply repository formatting to satisfy CI format check. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
import { Notice } from 'obsidian';
|
|
import { EditorService } from './EditorService';
|
|
import { EventManager } from './EventManager';
|
|
import { Logger } from '../infrastructure/logging/Logger';
|
|
import { TextFormat } from '../ui/formatting/FormatOptions';
|
|
|
|
/**
|
|
* 텍스트 삽입 핸들러 - 변환된 텍스트를 에디터에 삽입하는 로직 처리
|
|
*
|
|
* 주요 기능:
|
|
* - 다양한 삽입 모드 지원
|
|
* - 포맷팅 적용
|
|
* - 삽입 전 프리뷰
|
|
* - 다국어 텍스트 처리
|
|
* - 템플릿 적용
|
|
*/
|
|
export class TextInsertionHandler {
|
|
private readonly logger: Logger;
|
|
private previewMode = false;
|
|
private lastInsertedText = '';
|
|
private insertionHistory: InsertionRecord[] = [];
|
|
private readonly maxHistorySize = 20;
|
|
|
|
private normalizeError(error: unknown): Error {
|
|
return error instanceof Error ? error : new Error('Unknown error');
|
|
}
|
|
|
|
constructor(
|
|
private editorService: EditorService,
|
|
private eventManager: EventManager,
|
|
logger?: Logger
|
|
) {
|
|
this.logger = logger || new Logger('TextInsertionHandler');
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
/**
|
|
* 이벤트 리스너 설정
|
|
*/
|
|
private setupEventListeners(): void {
|
|
// 텍스트 삽입 이벤트 리스닝
|
|
this.eventManager.on('transcription:complete', async (data) => {
|
|
if (data.autoInsert) {
|
|
await this.handleTranscriptionComplete(data.text, data.options);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 변환 완료 텍스트 처리
|
|
*/
|
|
private async handleTranscriptionComplete(
|
|
text: string,
|
|
options?: InsertionOptions
|
|
): Promise<void> {
|
|
const defaultOptions: InsertionOptions = {
|
|
mode: 'cursor',
|
|
format: 'plain',
|
|
addTimestamp: false,
|
|
...options,
|
|
};
|
|
|
|
await this.insertText(text, defaultOptions);
|
|
}
|
|
|
|
/**
|
|
* 텍스트 삽입 메인 메서드
|
|
*/
|
|
async insertText(text: string, options: InsertionOptions): Promise<boolean> {
|
|
try {
|
|
// 에디터 체크
|
|
if (!this.editorService.hasActiveEditor()) {
|
|
// 새 노트 생성 옵션
|
|
if (options.createNewNote) {
|
|
return await this.createNewNoteWithText(text, options);
|
|
}
|
|
|
|
new Notice('No active editor. Please open a note first.');
|
|
return false;
|
|
}
|
|
|
|
// 텍스트 포맷팅
|
|
const formattedText = await this.formatText(text, options);
|
|
|
|
// 프리뷰 모드 처리
|
|
if (this.previewMode || options.preview) {
|
|
return await this.showPreview(formattedText, options);
|
|
}
|
|
|
|
// 실제 삽입 실행
|
|
const success = await this.executeInsertion(formattedText, options);
|
|
|
|
if (success) {
|
|
// 히스토리 기록
|
|
this.recordInsertion({
|
|
text: formattedText,
|
|
originalText: text,
|
|
options,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// 이벤트 발생
|
|
this.eventManager.emit('text:inserted', {
|
|
text: formattedText,
|
|
options,
|
|
});
|
|
|
|
this.logger.debug('Text inserted successfully', {
|
|
mode: options.mode,
|
|
textLength: formattedText.length,
|
|
});
|
|
}
|
|
|
|
return success;
|
|
} catch (error) {
|
|
this.logger.error('Failed to insert text', this.normalizeError(error));
|
|
new Notice('Failed to insert text. Please try again.');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 텍스트 포맷팅 적용
|
|
*/
|
|
private async formatText(text: string, options: InsertionOptions): Promise<string> {
|
|
await Promise.resolve();
|
|
|
|
let formatted = text;
|
|
|
|
// 기본 정리
|
|
formatted = this.cleanupText(formatted);
|
|
|
|
// 포맷 적용
|
|
switch (options.format) {
|
|
case 'markdown':
|
|
formatted = this.applyMarkdownFormat(formatted, options);
|
|
break;
|
|
case 'quote':
|
|
formatted = this.applyQuoteFormat(formatted, options);
|
|
break;
|
|
case 'bullet':
|
|
formatted = this.applyBulletFormat(formatted, options);
|
|
break;
|
|
case 'heading':
|
|
formatted = this.applyHeadingFormat(formatted, options);
|
|
break;
|
|
case 'code':
|
|
formatted = this.applyCodeFormat(formatted, options);
|
|
break;
|
|
case 'callout':
|
|
formatted = this.applyCalloutFormat(formatted, options);
|
|
break;
|
|
}
|
|
|
|
// 타임스탬프 추가
|
|
if (options.addTimestamp) {
|
|
formatted = this.addTimestamp(formatted, options.timestampFormat);
|
|
}
|
|
|
|
// 템플릿 적용
|
|
if (options.template) {
|
|
formatted = this.applyTemplate(formatted, options.template);
|
|
}
|
|
|
|
// 언어별 처리
|
|
if (options.language) {
|
|
formatted = this.processLanguageSpecific(formatted, options.language);
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
/**
|
|
* 텍스트 정리
|
|
*/
|
|
private cleanupText(text: string): string {
|
|
return text
|
|
.trim()
|
|
.replace(/\r\n/g, '\n') // 줄바꿈 정규화
|
|
.replace(/\n{3,}/g, '\n\n') // 과도한 줄바꿈 제거
|
|
.replace(/^\s+|\s+$/gm, ''); // 각 줄 앞뒤 공백 제거
|
|
}
|
|
|
|
/**
|
|
* 마크다운 포맷 적용
|
|
*/
|
|
private applyMarkdownFormat(text: string, options: InsertionOptions): string {
|
|
let formatted = text;
|
|
|
|
// 문단 구분
|
|
if (options.paragraphBreaks) {
|
|
formatted = formatted
|
|
.split(/\n+/)
|
|
.filter((p) => p.trim())
|
|
.join('\n\n');
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
/**
|
|
* 인용구 포맷 적용
|
|
*/
|
|
private applyQuoteFormat(text: string, options: InsertionOptions): string {
|
|
const lines = text.split('\n');
|
|
const quoted = lines.map((line) => `> ${line}`).join('\n');
|
|
|
|
if (options.quoteAuthor) {
|
|
return `${quoted}\n> \n> — ${options.quoteAuthor}`;
|
|
}
|
|
|
|
return quoted;
|
|
}
|
|
|
|
/**
|
|
* 불릿 포맷 적용
|
|
*/
|
|
private applyBulletFormat(text: string, options: InsertionOptions): string {
|
|
const lines = text.split('\n').filter((line) => line.trim());
|
|
const bulletChar = options.bulletChar || '-';
|
|
|
|
return lines.map((line) => `${bulletChar} ${line}`).join('\n');
|
|
}
|
|
|
|
/**
|
|
* 제목 포맷 적용
|
|
*/
|
|
private applyHeadingFormat(text: string, options: InsertionOptions): string {
|
|
const level = options.headingLevel || 2;
|
|
const prefix = '#'.repeat(level);
|
|
|
|
// 첫 줄만 제목으로
|
|
const lines = text.split('\n');
|
|
if (lines.length > 0) {
|
|
lines[0] = `${prefix} ${lines[0]}`;
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* 코드 포맷 적용
|
|
*/
|
|
private applyCodeFormat(text: string, options: InsertionOptions): string {
|
|
const language = options.codeLanguage || '';
|
|
return `\`\`\`${language}\n${text}\n\`\`\``;
|
|
}
|
|
|
|
/**
|
|
* 콜아웃 포맷 적용
|
|
*/
|
|
private applyCalloutFormat(text: string, options: InsertionOptions): string {
|
|
const type = options.calloutType || 'info';
|
|
const title = options.calloutTitle || '';
|
|
const foldable = options.calloutFoldable ? '+' : '';
|
|
|
|
return `> [!${type}]${foldable} ${title}\n> ${text.replace(/\n/g, '\n> ')}`;
|
|
}
|
|
|
|
/**
|
|
* 타임스탬프 추가
|
|
*/
|
|
private addTimestamp(text: string, format?: string): string {
|
|
const now = new Date();
|
|
const timestamp = format ? this.formatDate(now, format) : now.toISOString();
|
|
|
|
return `[${timestamp}]\n${text}`;
|
|
}
|
|
|
|
/**
|
|
* 날짜 포맷팅
|
|
*/
|
|
private formatDate(date: Date, format: string): string {
|
|
const replacements: Record<string, string> = {
|
|
YYYY: date.getFullYear().toString(),
|
|
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
DD: date.getDate().toString().padStart(2, '0'),
|
|
HH: date.getHours().toString().padStart(2, '0'),
|
|
mm: date.getMinutes().toString().padStart(2, '0'),
|
|
ss: date.getSeconds().toString().padStart(2, '0'),
|
|
};
|
|
|
|
let formatted = format;
|
|
for (const [key, value] of Object.entries(replacements)) {
|
|
formatted = formatted.replace(new RegExp(key, 'g'), value);
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 적용
|
|
*/
|
|
private applyTemplate(text: string, template: string): string {
|
|
return template
|
|
.replace('{{content}}', text)
|
|
.replace('{{date}}', new Date().toLocaleDateString())
|
|
.replace('{{time}}', new Date().toLocaleTimeString())
|
|
.replace('{{datetime}}', new Date().toLocaleString());
|
|
}
|
|
|
|
/**
|
|
* 언어별 특수 처리
|
|
*/
|
|
private processLanguageSpecific(text: string, language: string): string {
|
|
switch (language) {
|
|
case 'ko':
|
|
// 한국어 특수 처리
|
|
return this.processKoreanText(text);
|
|
case 'ja':
|
|
// 일본어 특수 처리
|
|
return this.processJapaneseText(text);
|
|
case 'zh':
|
|
// 중국어 특수 처리
|
|
return this.processChineseText(text);
|
|
default:
|
|
return text;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 한국어 텍스트 처리
|
|
*/
|
|
private processKoreanText(text: string): string {
|
|
// 한국어 맞춤법 교정이나 특수 처리
|
|
return text
|
|
.replace(/\s+([.,!?])/g, '$1') // 구두점 앞 공백 제거
|
|
.replace(/([.,!?])\s*/g, '$1 ') // 구두점 뒤 공백 추가
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* 일본어 텍스트 처리
|
|
*/
|
|
private processJapaneseText(text: string): string {
|
|
// 일본어 특수 처리
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* 중국어 텍스트 처리
|
|
*/
|
|
private processChineseText(text: string): string {
|
|
// 중국어 특수 처리
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* 실제 삽입 실행
|
|
*/
|
|
private async executeInsertion(text: string, options: InsertionOptions): Promise<boolean> {
|
|
switch (options.mode) {
|
|
case 'cursor':
|
|
return await this.editorService.insertAtCursor(text);
|
|
|
|
case 'replace':
|
|
return await this.editorService.replaceSelection(text);
|
|
|
|
case 'append':
|
|
return await this.editorService.appendToDocument(text, true);
|
|
|
|
case 'prepend':
|
|
return await this.editorService.prependToDocument(text, true);
|
|
|
|
case 'line-end': {
|
|
const lineNumber = this.editorService.getCurrentLineNumber();
|
|
if (lineNumber !== null) {
|
|
return await this.editorService.appendToLine(lineNumber, text);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
case 'new-line': {
|
|
const cursor = this.editorService.getCursorPosition();
|
|
if (cursor) {
|
|
return await this.editorService.insertAtPosition(`\n${text}`, cursor);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
default:
|
|
return await this.editorService.insertAtCursor(text);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 프리뷰 표시
|
|
*/
|
|
private async showPreview(text: string, options: InsertionOptions): Promise<boolean> {
|
|
await Promise.resolve();
|
|
|
|
// 프리뷰 모달 표시 (별도 구현 필요)
|
|
new Notice(`Preview:\n${text.substring(0, 100)}...`);
|
|
|
|
this.eventManager.emit('text:preview', {
|
|
text,
|
|
options,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 새 노트 생성 및 텍스트 삽입
|
|
*/
|
|
private async createNewNoteWithText(text: string, options: InsertionOptions): Promise<boolean> {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const fileName = options.noteTitle || `Transcription-${timestamp}.md`;
|
|
const folder = options.noteFolder || '';
|
|
|
|
const content = await this.formatText(text, options);
|
|
|
|
return await this.editorService.createAndOpenNote(fileName, content, folder);
|
|
}
|
|
|
|
/**
|
|
* 삽입 기록
|
|
*/
|
|
private recordInsertion(record: InsertionRecord): void {
|
|
this.insertionHistory.push(record);
|
|
|
|
// 히스토리 크기 제한
|
|
if (this.insertionHistory.length > this.maxHistorySize) {
|
|
this.insertionHistory.shift();
|
|
}
|
|
|
|
this.lastInsertedText = record.text;
|
|
}
|
|
|
|
/**
|
|
* 마지막 삽입 텍스트 가져오기
|
|
*/
|
|
getLastInsertedText(): string {
|
|
return this.lastInsertedText;
|
|
}
|
|
|
|
/**
|
|
* 삽입 히스토리 가져오기
|
|
*/
|
|
getInsertionHistory(): InsertionRecord[] {
|
|
return [...this.insertionHistory];
|
|
}
|
|
|
|
/**
|
|
* 프리뷰 모드 설정
|
|
*/
|
|
setPreviewMode(enabled: boolean): void {
|
|
this.previewMode = enabled;
|
|
this.logger.debug('Preview mode changed', { enabled });
|
|
}
|
|
|
|
/**
|
|
* 프리뷰 모드 상태
|
|
*/
|
|
isPreviewMode(): boolean {
|
|
return this.previewMode;
|
|
}
|
|
|
|
/**
|
|
* 히스토리 초기화
|
|
*/
|
|
clearHistory(): void {
|
|
this.insertionHistory = [];
|
|
this.lastInsertedText = '';
|
|
this.logger.debug('Insertion history cleared');
|
|
}
|
|
|
|
/**
|
|
* 클린업
|
|
*/
|
|
destroy(): void {
|
|
this.clearHistory();
|
|
this.logger.debug('TextInsertionHandler destroyed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 삽입 옵션 인터페이스
|
|
*/
|
|
export interface InsertionOptions {
|
|
mode: InsertionMode;
|
|
format: TextFormat;
|
|
addTimestamp?: boolean;
|
|
timestampFormat?: string;
|
|
template?: string;
|
|
language?: string;
|
|
preview?: boolean;
|
|
createNewNote?: boolean;
|
|
noteTitle?: string;
|
|
noteFolder?: string;
|
|
paragraphBreaks?: boolean;
|
|
quoteAuthor?: string;
|
|
bulletChar?: string;
|
|
headingLevel?: number;
|
|
codeLanguage?: string;
|
|
calloutType?: string;
|
|
calloutTitle?: string;
|
|
calloutFoldable?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 삽입 모드
|
|
*/
|
|
export type InsertionMode =
|
|
| 'cursor' // 커서 위치
|
|
| 'replace' // 선택 영역 대체
|
|
| 'append' // 문서 끝
|
|
| 'prepend' // 문서 시작
|
|
| 'line-end' // 현재 줄 끝
|
|
| 'new-line'; // 새 줄
|
|
|
|
/**
|
|
* 삽입 기록
|
|
*/
|
|
interface InsertionRecord {
|
|
text: string;
|
|
originalText: string;
|
|
options: InsertionOptions;
|
|
timestamp: number;
|
|
}
|