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 로드 완료');