diff --git a/backend/src/api/routes/search.py b/backend/src/api/routes/search.py index 9c11df1..149e50a 100644 --- a/backend/src/api/routes/search.py +++ b/backend/src/api/routes/search.py @@ -13,6 +13,8 @@ from ...models.user import User from ...models.document import Document, Tag from ...models.highlight import Highlight from ...models.note import Note +from ...models.memo_tree import MemoTree, MemoNode +from ...models.note_document import NoteDocument from ..dependencies import get_current_active_user from pydantic import BaseModel @@ -47,7 +49,7 @@ router = APIRouter() @router.get("/", response_model=SearchResponse) async def search_all( q: str = Query(..., description="검색어"), - type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, highlight"), + type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, memo, highlight"), document_id: Optional[str] = Query(None, description="특정 문서 내 검색"), tag: Optional[str] = Query(None, description="태그 필터"), skip: int = Query(0, ge=0), @@ -63,16 +65,36 @@ async def search_all( document_results = await search_documents(q, document_id, tag, current_user, db) results.extend(document_results) - # 2. 메모 검색 + # 2. 노트 문서 검색 if not type_filter or type_filter == "note": - note_results = await search_notes(q, document_id, tag, current_user, db) + note_results = await search_note_documents(q, current_user, db) results.extend(note_results) - # 3. 하이라이트 검색 + # 3. 메모 트리 노드 검색 + if not type_filter or type_filter == "memo": + memo_results = await search_memo_nodes(q, current_user, db) + results.extend(memo_results) + + # 4. 기존 메모 검색 (하위 호환성) + if not type_filter or type_filter == "note": + old_note_results = await search_notes(q, document_id, tag, current_user, db) + results.extend(old_note_results) + + # 5. 하이라이트 검색 if not type_filter or type_filter == "highlight": highlight_results = await search_highlights(q, document_id, current_user, db) results.extend(highlight_results) + # 6. 하이라이트 메모 검색 + if not type_filter or type_filter == "highlight_note": + highlight_note_results = await search_highlight_notes(q, document_id, current_user, db) + results.extend(highlight_note_results) + + # 7. 문서 본문 검색 (OCR 데이터) + if not type_filter or type_filter == "document_content": + content_results = await search_document_content(q, document_id, current_user, db) + results.extend(content_results) + # 관련성 점수로 정렬 results.sort(key=lambda x: x.relevance_score, reverse=True) @@ -352,3 +374,239 @@ async def get_search_suggestions( suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]]) return {"suggestions": suggestions[:10]} + + +async def search_highlight_notes( + query: str, + document_id: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """하이라이트 메모 내용 검색""" + query_obj = select(Note).options( + selectinload(Note.highlight).selectinload(Highlight.document) + ) + + # 하이라이트가 있는 노트만 + query_obj = query_obj.where(Note.highlight_id.isnot(None)) + + # 권한 필터링 - 사용자의 노트만 + query_obj = query_obj.where(Note.created_by == current_user.id) + + # 특정 문서 필터 + if document_id: + query_obj = query_obj.join(Highlight).where(Highlight.document_id == document_id) + + # 메모 내용에서 검색 + query_obj = query_obj.where(Note.content.ilike(f"%{query}%")) + + result = await db.execute(query_obj) + notes = result.scalars().all() + + search_results = [] + for note in notes: + if not note.highlight or not note.highlight.document: + continue + + # 관련성 점수 계산 + score = 1.5 # 메모 내용 매치는 높은 점수 + content_lower = (note.content or "").lower() + if query.lower() in content_lower: + score += 2.0 + + search_results.append(SearchResult( + type="highlight_note", + id=str(note.id), + title=f"하이라이트 메모: {note.highlight.selected_text[:30]}...", + content=note.content or "", + document_id=str(note.highlight.document.id), + document_title=note.highlight.document.title, + created_at=note.created_at, + relevance_score=score, + highlight_info={ + "highlight_id": str(note.highlight.id), + "selected_text": note.highlight.selected_text, + "start_offset": note.highlight.start_offset, + "end_offset": note.highlight.end_offset, + "note_content": note.content + } + )) + + return search_results + + +async def search_note_documents( + query: str, + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """노트 문서 검색""" + query_obj = select(NoteDocument).where( + or_( + NoteDocument.title.ilike(f"%{query}%"), + NoteDocument.content.ilike(f"%{query}%") + ) + ) + + # 권한 필터링 - 사용자의 노트만 + query_obj = query_obj.where(NoteDocument.created_by == current_user.email) + + result = await db.execute(query_obj) + notes = result.scalars().all() + + search_results = [] + for note in notes: + # 관련성 점수 계산 + score = 1.0 + if query.lower() in note.title.lower(): + score += 2.0 + if note.content and query.lower() in note.content.lower(): + score += 1.0 + + search_results.append(SearchResult( + type="note", + id=str(note.id), + title=note.title, + content=note.content or "", + document_id=str(note.id), # 노트 자체가 문서 + document_title=note.title, + created_at=note.created_at, + relevance_score=score + )) + + return search_results + + +async def search_memo_nodes( + query: str, + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """메모 트리 노드 검색""" + query_obj = select(MemoNode).options( + selectinload(MemoNode.tree) + ).where( + or_( + MemoNode.title.ilike(f"%{query}%"), + MemoNode.content.ilike(f"%{query}%") + ) + ) + + # 권한 필터링 - 사용자의 트리에 속한 노드만 + query_obj = query_obj.join(MemoTree).where(MemoTree.user_id == current_user.id) + + result = await db.execute(query_obj) + nodes = result.scalars().all() + + search_results = [] + for node in nodes: + # 관련성 점수 계산 + score = 1.0 + if query.lower() in node.title.lower(): + score += 2.0 + if node.content and query.lower() in node.content.lower(): + score += 1.0 + + search_results.append(SearchResult( + type="memo", + id=str(node.id), + title=node.title, + content=node.content or "", + document_id=str(node.tree.id), # 트리 ID를 문서 ID로 사용 + document_title=f"📚 {node.tree.title}", + created_at=node.created_at, + relevance_score=score + )) + + return search_results + + +async def search_document_content( + query: str, + document_id: Optional[str], + current_user: User, + db: AsyncSession +) -> List[SearchResult]: + """문서 본문 내용 검색 (OCR 데이터 포함)""" + # 문서 권한 확인 + doc_query = select(Document) + if not current_user.is_admin: + doc_query = doc_query.where( + or_( + Document.is_public == True, + Document.uploaded_by == current_user.id + ) + ) + + if document_id: + doc_query = doc_query.where(Document.id == document_id) + + result = await db.execute(doc_query) + documents = result.scalars().all() + + search_results = [] + + for doc in documents: + # HTML 파일에서 텍스트 검색 + if doc.html_path: + try: + import os + from bs4 import BeautifulSoup + + html_file_path = os.path.join("/app/data/documents", doc.html_path) + if os.path.exists(html_file_path): + with open(html_file_path, 'r', encoding='utf-8') as f: + html_content = f.read() + + # HTML에서 텍스트 추출 + soup = BeautifulSoup(html_content, 'html.parser') + text_content = soup.get_text() + + # 검색어가 포함된 경우 + if query.lower() in text_content.lower(): + # 검색어 주변 컨텍스트 추출 + context = extract_search_context(text_content, query) + + # 관련성 점수 계산 + score = 2.0 # 본문 매치는 높은 점수 + + search_results.append(SearchResult( + type="document_content", + id=str(doc.id), + title=f"📄 {doc.title} (본문)", + content=context, + document_id=str(doc.id), + document_title=doc.title, + created_at=doc.created_at, + relevance_score=score + )) + except Exception as e: + print(f"문서 본문 검색 오류: {e}") + continue + + return search_results + + +def extract_search_context(text: str, query: str, context_length: int = 200) -> str: + """검색어 주변 컨텍스트 추출""" + text_lower = text.lower() + query_lower = query.lower() + + # 첫 번째 매치 위치 찾기 + match_pos = text_lower.find(query_lower) + if match_pos == -1: + return text[:context_length] + "..." + + # 컨텍스트 시작/끝 위치 계산 + start = max(0, match_pos - context_length // 2) + end = min(len(text), match_pos + len(query) + context_length // 2) + + context = text[start:end] + + # 앞뒤에 ... 추가 + if start > 0: + context = "..." + context + if end < len(text): + context = context + "..." + + return context diff --git a/frontend/components/header.html b/frontend/components/header.html index b80a0e0..4881080 100644 --- a/frontend/components/header.html +++ b/frontend/components/header.html @@ -27,6 +27,12 @@ + + + + 통합 검색 + +
diff --git a/frontend/search.html b/frontend/search.html new file mode 100644 index 0000000..3538500 --- /dev/null +++ b/frontend/search.html @@ -0,0 +1,495 @@ + + + + + + 통합 검색 - Document Server + + + + + + + + +
+ + +
+ +
+

+ + 통합 검색 +

+

문서, 노트, 메모를 한 번에 검색하세요

+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+ 타입: + + + + + + + +
+ + +
+ 정렬: + +
+
+
+
+ + +
+
+
+
+ + 개 결과 + + "" 검색 + + +
+ + 📄 문서 개 + + + 📝 노트 개 + + + 🌳 메모 개 + + + 🖍️ 하이라이트 개 + +
+
+
+ + ms +
+
+
+
+ + +
+ +

검색 중...

+
+ + +
+ +
+ + +
+
+ +

검색 결과가 없습니다

+

+ + ""에 대한 결과를 찾을 수 없습니다. + + 검색어를 입력해주세요. +

+
+

검색 팁:

+
    +
  • • 다른 키워드로 검색해보세요
  • +
  • • 검색어를 줄여보세요
  • +
  • • 필터를 변경해보세요
  • +
+
+
+
+ + +
+
+ +

검색을 시작하세요

+

문서, 노트, 메모, 하이라이트를 통합 검색할 수 있습니다

+ + +
+ + + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+

+

+
+
+ + +
+
+ + +
+ +
+
+ 하이라이트된 텍스트 +
+
+
+ 메모: +
+
+ + +
+
+ 원본 하이라이트 +
+
+
메모 내용:
+
+ + +
+
+
+ + +
+ +

내용을 불러오는 중...

+
+
+
+
+ + + + + + + + diff --git a/frontend/static/js/header-loader.js b/frontend/static/js/header-loader.js index a316ec5..f35d8e4 100644 --- a/frontend/static/js/header-loader.js +++ b/frontend/static/js/header-loader.js @@ -124,7 +124,11 @@ class HeaderLoader { 'index': 'index-nav-item', 'hierarchy': 'hierarchy-nav-item', 'memo-tree': 'memo-tree-nav-item', - 'story-view': 'story-view-nav-item' + 'story-view': 'story-view-nav-item', + 'search': 'search-nav-link', + 'notes': 'notes-nav-link', + 'notebooks': 'notebooks-nav-item', + 'note-editor': 'note-editor-nav-item' }; const itemId = pageItemMap[pageInfo.filename]; diff --git a/frontend/static/js/search.js b/frontend/static/js/search.js new file mode 100644 index 0000000..a401bd6 --- /dev/null +++ b/frontend/static/js/search.js @@ -0,0 +1,352 @@ +/** + * 통합 검색 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, '$1'); + }, + + // 정규식 이스케이프 + 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 로드 완료');