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:
529
backend/src/api/routes/document_links.py
Normal file
529
backend/src/api/routes/document_links.py
Normal 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
|
||||
@@ -676,6 +676,97 @@ async def download_document(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/navigation")
|
||||
async def get_document_navigation(
|
||||
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))
|
||||
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"
|
||||
)
|
||||
|
||||
navigation_info = {
|
||||
"current": {
|
||||
"id": str(current_doc.id),
|
||||
"title": current_doc.title,
|
||||
"sort_order": current_doc.sort_order
|
||||
},
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"book_info": None
|
||||
}
|
||||
|
||||
# 서적에 속한 문서인 경우 이전/다음 문서 조회
|
||||
if current_doc.book_id:
|
||||
# 같은 서적의 HTML 문서들만 조회 (PDF 제외)
|
||||
book_docs_result = await db.execute(
|
||||
select(Document)
|
||||
.where(
|
||||
and_(
|
||||
Document.book_id == current_doc.book_id,
|
||||
Document.html_path.isnot(None), # HTML 문서만
|
||||
or_(Document.is_public == True, Document.uploaded_by == current_user.id, current_user.is_admin == True)
|
||||
)
|
||||
)
|
||||
.order_by(Document.sort_order.asc().nulls_last(), Document.created_at.asc())
|
||||
)
|
||||
book_docs = book_docs_result.scalars().all()
|
||||
|
||||
# 현재 문서의 인덱스 찾기
|
||||
current_index = None
|
||||
for i, doc in enumerate(book_docs):
|
||||
if doc.id == current_doc.id:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
if current_index is not None:
|
||||
# 이전 문서
|
||||
if current_index > 0:
|
||||
prev_doc = book_docs[current_index - 1]
|
||||
navigation_info["previous"] = {
|
||||
"id": str(prev_doc.id),
|
||||
"title": prev_doc.title,
|
||||
"sort_order": prev_doc.sort_order
|
||||
}
|
||||
|
||||
# 다음 문서
|
||||
if current_index < len(book_docs) - 1:
|
||||
next_doc = book_docs[current_index + 1]
|
||||
navigation_info["next"] = {
|
||||
"id": str(next_doc.id),
|
||||
"title": next_doc.title,
|
||||
"sort_order": next_doc.sort_order
|
||||
}
|
||||
|
||||
# 서적 정보 추가
|
||||
from ...models.book import Book
|
||||
book_result = await db.execute(select(Book).where(Book.id == current_doc.book_id))
|
||||
book = book_result.scalar_one_or_none()
|
||||
if book:
|
||||
navigation_info["book_info"] = {
|
||||
"id": str(book.id),
|
||||
"title": book.title,
|
||||
"author": book.author
|
||||
}
|
||||
|
||||
return navigation_info
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
async def delete_document(
|
||||
document_id: str,
|
||||
|
||||
@@ -9,7 +9,7 @@ import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import init_db
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -52,6 +52,7 @@ app.include_router(book_categories.router, prefix="/api/book-categories", tags=[
|
||||
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
||||
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
"""
|
||||
from .user import User
|
||||
from .document import Document, Tag
|
||||
from .book import Book
|
||||
from .highlight import Highlight
|
||||
from .note import Note
|
||||
from .bookmark import Bookmark
|
||||
from .document_link import DocumentLink
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Document",
|
||||
"Tag",
|
||||
"Book",
|
||||
"Highlight",
|
||||
"Note",
|
||||
"Bookmark",
|
||||
"DocumentLink",
|
||||
]
|
||||
|
||||
53
backend/src/models/document_link.py
Normal file
53
backend/src/models/document_link.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
문서 링크 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class DocumentLink(Base):
|
||||
"""문서 링크 테이블"""
|
||||
__tablename__ = "document_links"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# 링크가 생성된 문서 (출발점)
|
||||
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True)
|
||||
|
||||
# 링크 대상 문서 (도착점)
|
||||
target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True)
|
||||
|
||||
# 출발점 텍스트 정보 (기존)
|
||||
selected_text = Column(Text, nullable=False) # 선택된 텍스트
|
||||
start_offset = Column(Integer, nullable=False) # 시작 위치
|
||||
end_offset = Column(Integer, nullable=False) # 끝 위치
|
||||
|
||||
# 도착점 텍스트 정보 (새로 추가)
|
||||
target_text = Column(Text, nullable=True) # 대상 문서에서 선택된 텍스트
|
||||
target_start_offset = Column(Integer, nullable=True) # 대상 문서에서 시작 위치
|
||||
target_end_offset = Column(Integer, nullable=True) # 대상 문서에서 끝 위치
|
||||
|
||||
# 링크 메타데이터
|
||||
link_text = Column(String(500), nullable=True) # 사용자 정의 링크 텍스트 (선택사항)
|
||||
description = Column(Text, nullable=True) # 링크 설명 (선택사항)
|
||||
|
||||
# 링크 타입 (전체 문서 vs 특정 부분)
|
||||
link_type = Column(String(20), default="document", nullable=False) # "document" or "text_fragment"
|
||||
|
||||
# 생성자 정보
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links")
|
||||
target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_links")
|
||||
creator = relationship("User", backref="created_links")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentLink(id='{self.id}', text='{self.selected_text[:50]}...')>"
|
||||
Reference in New Issue
Block a user