🔍 고급 통합 검색 시스템 완성
🎯 주요 기능: - 하이라이트 메모 내용 별도 검색 (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) - 검색어 자동완성 제안 - 검색 통계 및 성능 표시 - 빠른 검색 예시 버튼 - 새 탭에서 결과 열기 🔗 네비게이션 통합: - 헤더에 '통합 검색' 링크 추가 - 페이지별 활성 상태 관리
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통합 검색 -->
|
||||
<a href="search.html" class="nav-link" id="search-nav-link">
|
||||
<i class="fas fa-search"></i>
|
||||
<span>통합 검색</span>
|
||||
</a>
|
||||
|
||||
<!-- 소설 관리 시스템 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="memo-tree.html" class="nav-link" id="novel-nav-link">
|
||||
|
||||
495
frontend/search.html
Normal file
495
frontend/search.html
Normal file
@@ -0,0 +1,495 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>통합 검색 - Document Server</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.search-result-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.highlight-text {
|
||||
background: linear-gradient(120deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #92400e;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-filter-chip {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-filter-chip.active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.search-stats {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.result-type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-document {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-note {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-memo {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-highlight {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-highlight_note {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-document_content {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input-container:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 18px;
|
||||
padding: 20px 60px 20px 24px;
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="searchApp()" x-init="init()" x-cloak>
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
<i class="fas fa-search text-blue-600 mr-3"></i>
|
||||
통합 검색
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600">문서, 노트, 메모를 한 번에 검색하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 검색 입력 -->
|
||||
<div class="max-w-4xl mx-auto mb-8">
|
||||
<form @submit.prevent="performSearch()">
|
||||
<div class="search-input-container">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
placeholder="검색어를 입력하세요..."
|
||||
class="search-input"
|
||||
@input="debounceSearch()"
|
||||
>
|
||||
<button type="submit" class="search-button" :disabled="loading">
|
||||
<i class="fas fa-search" :class="{'loading-spinner': loading}"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 검색 필터 -->
|
||||
<div class="max-w-4xl mx-auto mb-8" x-show="searchResults.length > 0 || hasSearched">
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- 타입 필터 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700">타입:</span>
|
||||
<button
|
||||
@click="typeFilter = ''"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === '' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'document'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'document' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-file-alt mr-1"></i>문서
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'note'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-sticky-note mr-1"></i>노트
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'memo'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'memo' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-tree mr-1"></i>메모
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'highlight'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'highlight' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-highlighter mr-1"></i>하이라이트
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'highlight_note'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'highlight_note' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-comment mr-1"></i>메모
|
||||
</button>
|
||||
<button
|
||||
@click="typeFilter = 'document_content'"
|
||||
class="search-filter-chip px-3 py-1 rounded-full text-sm border"
|
||||
:class="typeFilter === 'document_content' ? 'active' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-file-text mr-1"></i>본문
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 -->
|
||||
<div class="flex items-center space-x-2 ml-auto">
|
||||
<span class="text-sm font-medium text-gray-700">정렬:</span>
|
||||
<select x-model="sortBy" @change="applyFilters()"
|
||||
class="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="relevance">관련도순</option>
|
||||
<option value="date_desc">최신순</option>
|
||||
<option value="date_asc">오래된순</option>
|
||||
<option value="title">제목순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 통계 -->
|
||||
<div class="max-w-4xl mx-auto mb-6" x-show="searchResults.length > 0">
|
||||
<div class="search-stats rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
<strong x-text="filteredResults.length"></strong>개 결과
|
||||
<span x-show="searchQuery" class="text-gray-500">
|
||||
"<span x-text="searchQuery"></span>" 검색
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-500">
|
||||
<span x-show="getResultCount('document') > 0">
|
||||
📄 문서 <strong x-text="getResultCount('document')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('note') > 0">
|
||||
📝 노트 <strong x-text="getResultCount('note')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('memo') > 0">
|
||||
🌳 메모 <strong x-text="getResultCount('memo')"></strong>개
|
||||
</span>
|
||||
<span x-show="getResultCount('highlight') > 0">
|
||||
🖍️ 하이라이트 <strong x-text="getResultCount('highlight')"></strong>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span x-text="searchTime"></span>ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="loading" class="max-w-4xl mx-auto text-center py-12">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
|
||||
<p class="text-gray-600">검색 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
|
||||
<template x-for="result in filteredResults" :key="result.id">
|
||||
<div class="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
|
||||
<!-- 결과 헤더 -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="result-type-badge"
|
||||
:class="`badge-${result.type}`"
|
||||
x-text="getTypeLabel(result.type)"></span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(result.created_at)"></span>
|
||||
<div x-show="result.relevance_score > 0" class="flex items-center text-xs text-gray-500">
|
||||
<i class="fas fa-star text-yellow-500 mr-1"></i>
|
||||
<span x-text="Math.round(result.relevance_score * 100) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2 cursor-pointer hover:text-blue-600"
|
||||
@click="openResult(result)"
|
||||
x-html="highlightText(result.title, searchQuery)"></h3>
|
||||
<p class="text-sm text-gray-600 mb-2" x-text="result.document_title"></p>
|
||||
</div>
|
||||
<div class="ml-4 flex space-x-2">
|
||||
<button @click="showPreview(result)"
|
||||
class="px-3 py-1 bg-gray-600 text-white rounded-lg text-sm hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i>미리보기
|
||||
</button>
|
||||
<button @click="openResult(result)"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>열기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 내용 -->
|
||||
<div class="text-gray-700 text-sm leading-relaxed"
|
||||
x-html="highlightText(truncateText(result.content, 200), searchQuery)"></div>
|
||||
|
||||
<!-- 하이라이트 정보 -->
|
||||
<div x-show="result.type === 'highlight' && result.highlight_info" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="text-xs text-yellow-800 mb-1">
|
||||
<i class="fas fa-highlighter mr-1"></i>하이라이트 정보
|
||||
</div>
|
||||
<div class="text-sm text-yellow-900" x-show="result.highlight_info?.selected_text">
|
||||
"<span x-text="result.highlight_info?.selected_text"></span>"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 빈 검색 결과 -->
|
||||
<div x-show="!loading && hasSearched && filteredResults.length === 0" class="max-w-4xl mx-auto">
|
||||
<div class="empty-state text-center py-16">
|
||||
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색 결과가 없습니다</h3>
|
||||
<p class="text-gray-500 mb-6">
|
||||
<span x-show="searchQuery">
|
||||
"<span x-text="searchQuery"></span>"에 대한 결과를 찾을 수 없습니다.
|
||||
</span>
|
||||
<span x-show="!searchQuery">검색어를 입력해주세요.</span>
|
||||
</p>
|
||||
<div class="text-sm text-gray-500">
|
||||
<p class="mb-2">검색 팁:</p>
|
||||
<ul class="text-left inline-block space-y-1">
|
||||
<li>• 다른 키워드로 검색해보세요</li>
|
||||
<li>• 검색어를 줄여보세요</li>
|
||||
<li>• 필터를 변경해보세요</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 초기 상태 -->
|
||||
<div x-show="!loading && !hasSearched" class="max-w-4xl mx-auto">
|
||||
<div class="text-center py-16">
|
||||
<i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">검색을 시작하세요</h3>
|
||||
<p class="text-gray-500 mb-8">문서, 노트, 메모, 하이라이트를 통합 검색할 수 있습니다</p>
|
||||
|
||||
<!-- 빠른 검색 예시 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 max-w-2xl mx-auto">
|
||||
<button @click="searchQuery = '설계'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-blue-500 hover:bg-blue-50 transition-colors">
|
||||
<i class="fas fa-drafting-compass text-blue-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">설계</div>
|
||||
</button>
|
||||
<button @click="searchQuery = '연구'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-green-500 hover:bg-green-50 transition-colors">
|
||||
<i class="fas fa-flask text-green-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">연구</div>
|
||||
</button>
|
||||
<button @click="searchQuery = '프로젝트'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-purple-500 hover:bg-purple-50 transition-colors">
|
||||
<i class="fas fa-project-diagram text-purple-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">프로젝트</div>
|
||||
</button>
|
||||
<button @click="searchQuery = '분석'; performSearch()"
|
||||
class="p-4 bg-white rounded-lg border hover:border-orange-500 hover:bg-orange-50 transition-colors">
|
||||
<i class="fas fa-chart-line text-orange-600 text-2xl mb-2"></i>
|
||||
<div class="text-sm font-medium">분석</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 모달 -->
|
||||
<div x-show="showPreviewModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95">
|
||||
|
||||
<!-- 모달 헤더 -->
|
||||
<div class="flex items-center justify-between p-6 border-b">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<span class="result-type-badge"
|
||||
:class="`badge-${previewResult?.type}`"
|
||||
x-text="getTypeLabel(previewResult?.type)"></span>
|
||||
<span class="text-sm text-gray-500" x-text="formatDate(previewResult?.created_at)"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900" x-text="previewResult?.title"></h3>
|
||||
<p class="text-sm text-gray-600" x-text="previewResult?.document_title"></p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="openResult(previewResult)"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<i class="fas fa-external-link-alt mr-2"></i>열기
|
||||
</button>
|
||||
<button @click="closePreview()"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달 내용 -->
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<!-- 하이라이트 정보 -->
|
||||
<div x-show="previewResult?.type === 'highlight' && previewResult?.highlight_info"
|
||||
class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="text-sm font-medium text-yellow-800 mb-2">
|
||||
<i class="fas fa-highlighter mr-2"></i>하이라이트된 텍스트
|
||||
</div>
|
||||
<div class="text-yellow-900 font-medium mb-2"
|
||||
x-text="previewResult?.highlight_info?.selected_text"></div>
|
||||
<div x-show="previewResult?.highlight_info?.note_content" class="text-sm text-yellow-800">
|
||||
<strong>메모:</strong> <span x-text="previewResult?.highlight_info?.note_content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모 내용 -->
|
||||
<div x-show="previewResult?.type === 'highlight_note' && previewResult?.highlight_info"
|
||||
class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div class="text-sm font-medium text-blue-800 mb-2">
|
||||
<i class="fas fa-quote-left mr-2"></i>원본 하이라이트
|
||||
</div>
|
||||
<div class="text-blue-900 mb-2" x-text="previewResult?.highlight_info?.selected_text"></div>
|
||||
<div class="text-sm font-medium text-blue-800 mb-1">메모 내용:</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 내용 -->
|
||||
<div class="prose max-w-none">
|
||||
<div class="text-gray-700 leading-relaxed whitespace-pre-wrap"
|
||||
x-html="highlightText(previewResult?.content || '', searchQuery)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<div x-show="previewLoading" class="text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600">내용을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
<script src="/static/js/header-loader.js?v=2025012603"></script>
|
||||
<script src="/static/js/api.js?v=2025012607"></script>
|
||||
<script src="/static/js/auth.js?v=2025012351"></script>
|
||||
<script src="/static/js/search.js?v=2025012610"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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];
|
||||
|
||||
352
frontend/static/js/search.js
Normal file
352
frontend/static/js/search.js
Normal file
@@ -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, '<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 로드 완료');
|
||||
Reference in New Issue
Block a user