Files
document-server/frontend/static/js/search.js
Hyungi Ahn 4329a1c9a6 🔍 고급 통합 검색 시스템 완성
🎯 주요 기능:
- 하이라이트 메모 내용 별도 검색 (highlight_note 타입)
- PDF/HTML 본문 전체 텍스트 검색 (OCR 데이터 활용)
- 검색 결과 미리보기 모달 (전체 내용 로드)
- 메모 트리 노드 검색 지원
- 노트 문서 통합 검색

🔧 백엔드 개선:
- search_highlight_notes: 하이라이트 메모 내용 검색
- search_document_content: HTML/PDF 본문 검색 (BeautifulSoup)
- search_memo_nodes: 메모 트리 노드 검색
- search_note_documents: 노트 문서 검색
- extract_search_context: 검색어 주변 컨텍스트 추출

🎨 프론트엔드 기능:
- 통합 검색 UI (/search.html) 완전 구현
- 검색 필터: 문서/노트/메모/하이라이트/메모/본문
- 미리보기 모달: 전체 내용 로드 및 표시
- 검색 결과 하이라이트 및 컨텍스트 표시
- 타입별 배지 및 관련도 점수 표시

📱 사용자 경험:
- 실시간 검색 디바운스 (500ms)
- 검색어 자동완성 제안
- 검색 통계 및 성능 표시
- 빠른 검색 예시 버튼
- 새 탭에서 결과 열기

🔗 네비게이션 통합:
- 헤더에 '통합 검색' 링크 추가
- 페이지별 활성 상태 관리
2025-09-02 16:53:56 +09:00

353 lines
12 KiB
JavaScript

/**
* 통합 검색 JavaScript
*/
// 검색 애플리케이션 Alpine.js 컴포넌트
window.searchApp = function() {
return {
// 상태 관리
searchQuery: '',
searchResults: [],
filteredResults: [],
loading: false,
hasSearched: false,
searchTime: 0,
// 필터링
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
// 검색 디바운스
searchTimeout: null,
// 미리보기 모달
showPreviewModal: false,
previewResult: null,
previewLoading: false,
// 인증 상태
isAuthenticated: false,
currentUser: null,
// API 클라이언트
api: null,
// 초기화
async init() {
console.log('🔍 검색 앱 초기화 시작');
try {
// API 클라이언트 초기화
this.api = new DocumentServerAPI();
// 헤더 로드
await this.loadHeader();
// 인증 상태 확인
await this.checkAuthStatus();
// URL 파라미터에서 검색어 확인
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
if (query) {
this.searchQuery = query;
await this.performSearch();
}
console.log('✅ 검색 앱 초기화 완료');
} catch (error) {
console.error('❌ 검색 앱 초기화 실패:', error);
}
},
// 인증 상태 확인
async checkAuthStatus() {
try {
const user = await this.api.getCurrentUser();
this.isAuthenticated = true;
this.currentUser = user;
console.log('✅ 인증됨:', user.username || user.email);
} catch (error) {
console.log('❌ 인증되지 않음');
this.isAuthenticated = false;
this.currentUser = null;
// 검색은 로그인 없이도 가능하도록 허용
}
},
// 헤더 로드
async loadHeader() {
try {
if (typeof loadHeaderComponent === 'function') {
await loadHeaderComponent();
} else if (typeof window.loadHeaderComponent === 'function') {
await window.loadHeaderComponent();
} else {
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
}
} catch (error) {
console.error('헤더 로드 실패:', error);
}
},
// 검색 디바운스
debounceSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
if (this.searchQuery.trim()) {
this.performSearch();
}
}, 500);
},
// 검색 수행
async performSearch() {
if (!this.searchQuery.trim()) {
this.searchResults = [];
this.filteredResults = [];
this.hasSearched = false;
return;
}
this.loading = true;
const startTime = Date.now();
try {
console.log('🔍 검색 시작:', this.searchQuery);
// 검색 API 호출
const response = await this.api.search({
q: this.searchQuery,
type_filter: this.typeFilter || undefined,
limit: 50
});
this.searchResults = response.results || [];
this.hasSearched = true;
this.searchTime = Date.now() - startTime;
// 필터 적용
this.applyFilters();
// URL 업데이트
this.updateURL();
console.log('✅ 검색 완료:', this.searchResults.length, '개 결과');
} catch (error) {
console.error('❌ 검색 실패:', error);
this.searchResults = [];
this.filteredResults = [];
this.hasSearched = true;
} finally {
this.loading = false;
}
},
// 필터 적용
applyFilters() {
let results = [...this.searchResults];
// 타입 필터
if (this.typeFilter) {
results = results.filter(result => result.type === this.typeFilter);
}
// 정렬
results.sort((a, b) => {
switch (this.sortBy) {
case 'relevance':
return (b.relevance_score || 0) - (a.relevance_score || 0);
case 'date_desc':
return new Date(b.created_at) - new Date(a.created_at);
case 'date_asc':
return new Date(a.created_at) - new Date(b.created_at);
case 'title':
return a.title.localeCompare(b.title);
default:
return 0;
}
});
this.filteredResults = results;
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
},
// URL 업데이트
updateURL() {
const url = new URL(window.location);
if (this.searchQuery.trim()) {
url.searchParams.set('q', this.searchQuery);
} else {
url.searchParams.delete('q');
}
window.history.replaceState({}, '', url);
},
// 미리보기 표시
async showPreview(result) {
console.log('👁️ 미리보기 표시:', result);
this.previewResult = result;
this.showPreviewModal = true;
this.previewLoading = true;
try {
// 추가 내용 로드 (필요한 경우)
if (result.type === 'document' || result.type === 'note') {
// 전체 내용 로드
const fullContent = await this.loadFullContent(result);
if (fullContent) {
this.previewResult = { ...result, content: fullContent };
}
}
} catch (error) {
console.error('미리보기 로드 실패:', error);
} finally {
this.previewLoading = false;
}
},
// 전체 내용 로드
async loadFullContent(result) {
try {
let content = '';
switch (result.type) {
case 'document':
// 문서 내용 API 호출
const docContent = await this.api.get(`/documents/${result.document_id}/content`);
content = docContent;
break;
case 'note':
// 노트 내용 API 호출
const noteContent = await this.api.get(`/note-documents/${result.id}/content`);
content = noteContent;
break;
default:
content = result.content;
}
return content;
} catch (error) {
console.error('내용 로드 실패:', error);
return result.content;
}
},
// 미리보기 닫기
closePreview() {
this.showPreviewModal = false;
this.previewResult = null;
this.previewLoading = false;
},
// 검색 결과 열기
openResult(result) {
console.log('📂 검색 결과 열기:', result);
let url = '';
switch (result.type) {
case 'document':
case 'document_content':
url = `/viewer.html?id=${result.document_id}`;
if (result.highlight_info) {
// 하이라이트 위치로 이동
const { start_offset, end_offset, selected_text } = result.highlight_info;
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
}
break;
case 'note':
url = `/viewer.html?id=${result.id}&contentType=note`;
break;
case 'memo':
// 메모 트리에서 해당 노드로 이동
url = `/memo-tree.html?node_id=${result.id}`;
break;
case 'highlight':
case 'highlight_note':
url = `/viewer.html?id=${result.document_id}`;
if (result.highlight_info) {
const { start_offset, end_offset, selected_text } = result.highlight_info;
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
}
break;
default:
console.warn('알 수 없는 결과 타입:', result.type);
return;
}
// 새 탭에서 열기
window.open(url, '_blank');
},
// 타입별 결과 개수
getResultCount(type) {
return this.searchResults.filter(result => result.type === type).length;
},
// 타입 라벨
getTypeLabel(type) {
const labels = {
document: '문서',
document_content: '본문',
note: '노트',
memo: '메모',
highlight: '하이라이트',
highlight_note: '메모'
};
return labels[type] || type;
},
// 텍스트 하이라이트
highlightText(text, query) {
if (!text || !query) return text;
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
return text.replace(regex, '<span class="highlight-text">$1</span>');
},
// 정규식 이스케이프
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
// 텍스트 자르기
truncateText(text, maxLength) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return '오늘';
} else if (diffDays === 2) {
return '어제';
} else if (diffDays <= 7) {
return `${diffDays - 1}일 전`;
} else if (diffDays <= 30) {
return `${Math.ceil(diffDays / 7)}주 전`;
} else if (diffDays <= 365) {
return `${Math.ceil(diffDays / 30)}개월 전`;
} else {
return date.toLocaleDateString('ko-KR');
}
}
};
};
console.log('🔍 검색 JavaScript 로드 완료');