feat: PDF 매칭 필터링 및 서적 정보 UI 개선

- 서적 편집 페이지에서 PDF 매칭 드롭다운이 현재 서적의 PDF만 표시하도록 수정
- PDF 관리 페이지에 서적 정보 표시 UI 추가
- 타입 안전한 비교로 book_id 필터링 개선
- PDF 통계 카드에 서적별 분류 추가
- 필터 기능에 '서적 포함' 옵션 추가
- 디버깅 로그 추가로 문제 추적 개선

주요 변경사항:
- book-editor.js: String() 타입 변환으로 안전한 book_id 비교
- pdf-manager.html/js: 서적 정보 배지 및 통계 카드 추가
- book-documents.js: HTML 문서 필터링 로직 개선
This commit is contained in:
Hyungi Ahn
2025-08-26 15:32:46 +09:00
parent 04ae64fc4d
commit 8d7f4c04bb
17 changed files with 3398 additions and 400 deletions

View File

@@ -0,0 +1,529 @@
"""
문서 링크 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List, Optional
from pydantic import BaseModel
import uuid
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models import User, Document, DocumentLink
router = APIRouter()
# Pydantic 모델들
class DocumentLinkCreate(BaseModel):
target_document_id: str
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
# 고급 링크 기능 (모두 Optional로 설정)
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = "document" # "document" or "text_fragment"
class DocumentLinkUpdate(BaseModel):
target_document_id: Optional[str] = None
link_text: Optional[str] = None
description: Optional[str] = None
# 고급 링크 기능
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = None
class DocumentLinkResponse(BaseModel):
id: str
source_document_id: str
target_document_id: str
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str]
description: Optional[str]
created_at: str
updated_at: Optional[str]
# 고급 링크 기능
target_text: Optional[str]
target_start_offset: Optional[int]
target_end_offset: Optional[int]
link_type: Optional[str] = "document"
# 대상 문서 정보
target_document_title: str
target_document_book_id: Optional[str]
class Config:
from_attributes = True
class LinkableDocumentResponse(BaseModel):
id: str
title: str
book_id: Optional[str]
book_title: Optional[str]
sort_order: int
class Config:
from_attributes = True
@router.post("/{document_id}/links", response_model=DocumentLinkResponse)
async def create_document_link(
document_id: str,
link_data: DocumentLinkCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 생성"""
# 출발 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
source_doc = result.scalar_one_or_none()
if not source_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Source document not found"
)
# 권한 확인
if not source_doc.is_public and source_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to source document"
)
# 대상 문서 확인
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none()
if not target_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Target document not found"
)
# 대상 문서 권한 확인
if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target document"
)
# HTML 문서만 링크 가능
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
# 링크 생성
new_link = DocumentLink(
source_document_id=uuid.UUID(document_id),
target_document_id=uuid.UUID(link_data.target_document_id),
selected_text=link_data.selected_text,
start_offset=link_data.start_offset,
end_offset=link_data.end_offset,
link_text=link_data.link_text,
description=link_data.description,
# 고급 링크 기능
target_text=link_data.target_text,
target_start_offset=link_data.target_start_offset,
target_end_offset=link_data.target_end_offset,
link_type=link_data.link_type,
created_by=current_user.id
)
db.add(new_link)
await db.commit()
await db.refresh(new_link)
# 응답 데이터 구성
return DocumentLinkResponse(
id=str(new_link.id),
source_document_id=str(new_link.source_document_id),
target_document_id=str(new_link.target_document_id),
selected_text=new_link.selected_text,
start_offset=new_link.start_offset,
end_offset=new_link.end_offset,
link_text=new_link.link_text,
description=new_link.description,
# 고급 링크 기능
target_text=new_link.target_text,
target_start_offset=new_link.target_start_offset,
target_end_offset=new_link.target_end_offset,
link_type=new_link.link_type,
created_at=new_link.created_at.isoformat(),
updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None,
target_document_title=target_doc.title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
)
@router.get("/{document_id}/links", response_model=List[DocumentLinkResponse])
async def get_document_links(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서의 모든 링크 조회"""
# 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 링크 조회 (JOIN으로 대상 문서 정보도 함께)
result = await db.execute(
select(DocumentLink, Document)
.join(Document, DocumentLink.target_document_id == Document.id)
.where(DocumentLink.source_document_id == document_id)
.order_by(DocumentLink.start_offset.asc())
)
links_with_targets = result.all()
# 응답 데이터 구성
response_links = []
for link, target_doc in links_with_targets:
response_links.append(DocumentLinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
target_document_id=str(link.target_document_id),
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat() if link.updated_at else None,
# 고급 링크 기능 (기존 링크는 None일 수 있음)
target_text=getattr(link, 'target_text', None),
target_start_offset=getattr(link, 'target_start_offset', None),
target_end_offset=getattr(link, 'target_end_offset', None),
link_type=getattr(link, 'link_type', 'document'),
# 대상 문서 정보
target_document_title=target_doc.title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
))
return response_links
@router.get("/{document_id}/linkable-documents", response_model=List[LinkableDocumentResponse])
async def get_linkable_documents(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""링크 가능한 문서 목록 조회 (같은 서적 우선, 전체 HTML 문서)"""
# 현재 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
current_doc = result.scalar_one_or_none()
if not current_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 링크 가능한 HTML 문서들 조회
# 1. 같은 서적의 문서들 (우선순위)
# 2. 다른 서적의 문서들
from ...models import Book
query = select(Document, Book).outerjoin(Book, Document.book_id == Book.id).where(
and_(
Document.html_path.isnot(None), # HTML 문서만
Document.id != document_id, # 자기 자신 제외
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(
# 같은 서적 우선, 그 다음 정렬 순서
(Document.book_id == current_doc.book_id).desc(),
Document.sort_order.asc().nulls_last(),
Document.created_at.asc()
)
result = await db.execute(query)
documents_with_books = result.all()
# 응답 데이터 구성
linkable_docs = []
for doc, book in documents_with_books:
linkable_docs.append(LinkableDocumentResponse(
id=str(doc.id),
title=doc.title,
book_id=str(doc.book_id) if doc.book_id else None,
book_title=book.title if book else None,
sort_order=doc.sort_order or 0
))
return linkable_docs
@router.put("/links/{link_id}", response_model=DocumentLinkResponse)
async def update_document_link(
link_id: str,
link_data: DocumentLinkUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 수정"""
# 링크 조회
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
link = result.scalar_one_or_none()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Link not found"
)
# 권한 확인 (생성자만 수정 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 대상 문서 변경 시 검증
if link_data.target_document_id:
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none()
if not target_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Target document not found"
)
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
link.target_document_id = uuid.UUID(link_data.target_document_id)
# 필드 업데이트
if link_data.link_text is not None:
link.link_text = link_data.link_text
if link_data.description is not None:
link.description = link_data.description
await db.commit()
await db.refresh(link)
# 대상 문서 정보 조회
result = await db.execute(select(Document).where(Document.id == link.target_document_id))
target_doc = result.scalar_one()
return DocumentLinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
target_document_id=str(link.target_document_id),
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat() if link.updated_at else None,
target_document_title=target_doc.title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
)
@router.delete("/links/{link_id}")
async def delete_document_link(
link_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 삭제"""
# 링크 조회
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
link = result.scalar_one_or_none()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Link not found"
)
# 권한 확인 (생성자만 삭제 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
await db.delete(link)
await db.commit()
return {"message": "Link deleted successfully"}
# 백링크 관련 모델
class BacklinkResponse(BaseModel):
id: str
source_document_id: str
source_document_title: str
source_document_book_id: Optional[str]
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str]
description: Optional[str]
link_type: str
created_at: str
class Config:
from_attributes = True
@router.get("/{document_id}/backlinks", response_model=List[BacklinkResponse])
async def get_document_backlinks(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서의 백링크 조회 (이 문서를 참조하는 모든 링크)"""
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
from ...models import Book
query = select(DocumentLink, Document, Book).join(
Document, DocumentLink.source_document_id == Document.id
).outerjoin(Book, Document.book_id == Book.id).where(
and_(
DocumentLink.target_document_id == document_id,
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(DocumentLink.created_at.desc())
result = await db.execute(query)
backlinks = []
for link, source_doc, book in result.fetchall():
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
source_document_title=source_doc.title,
source_document_book_id=str(book.id) if book else None,
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
link_type=link.link_type,
created_at=link.created_at.isoformat()
))
return backlinks
@router.get("/{document_id}/link-fragments")
async def get_document_link_fragments(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 내 모든 링크된 텍스트 조각 조회 (중복 링크 관리용)"""
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 이 문서에서 출발하는 모든 링크 조회
query = select(DocumentLink, Document).join(
Document, DocumentLink.target_document_id == Document.id
).where(
and_(
DocumentLink.source_document_id == document_id,
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(DocumentLink.start_offset.asc())
result = await db.execute(query)
fragments = []
for link, target_doc in result.fetchall():
fragments.append({
"link_id": str(link.id),
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"selected_text": link.selected_text,
"target_document_id": str(link.target_document_id),
"target_document_title": target_doc.title,
"link_text": link.link_text,
"description": link.description,
"link_type": link.link_type,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset
})
return fragments