""" 문서 링크 관련 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] target_content_type: Optional[str] = "document" # "document" 또는 "note" 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) ): """문서 링크 생성""" print(f"🔗 링크 생성 요청 - 문서 ID: {document_id}") print(f"📋 링크 데이터: {link_data}") print(f"🎯 target_text: '{link_data.target_text}'") print(f"🎯 target_start_offset: {link_data.target_start_offset}") print(f"🎯 target_end_offset: {link_data.target_end_offset}") print(f"🎯 link_type: {link_data.link_type}") if link_data.link_type == 'text_fragment' and not link_data.target_text: print("🚨 CRITICAL: text_fragment 링크인데 target_text가 없습니다!") # 출발 문서 확인 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 (current_user.is_admin or current_user.can_manage_books) and not source_doc.is_public and source_doc.uploaded_by != current_user.id: 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() target_note = None if not target_doc: # 문서에서 찾지 못하면 노트에서 찾기 print(f"🔍 문서에서 찾지 못함, 노트에서 검색: {link_data.target_document_id}") from ...models.note_document import NoteDocument result = await db.execute(select(NoteDocument).where(NoteDocument.id == link_data.target_document_id)) target_note = result.scalar_one_or_none() if target_note: print(f"✅ 노트 찾음: {target_note.title}") else: print(f"❌ 노트도 찾지 못함: {link_data.target_document_id}") # 디버깅: 실제 존재하는 노트들 확인 all_notes_result = await db.execute(select(NoteDocument).limit(5)) all_notes = all_notes_result.scalars().all() print(f"🔍 존재하는 노트 예시 (최대 5개):") for note in all_notes: print(f" - ID: {note.id}, 제목: {note.title}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Target document or note not found" ) # 대상 문서/노트 권한 확인 if target_doc: if not (current_user.is_admin or current_user.can_manage_books) and not target_doc.is_public and target_doc.uploaded_by != current_user.id: 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" ) elif target_note: # 노트 권한 확인 (노트는 기본적으로 생성자만 접근 가능) if target_note.created_by != current_user.id and not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to target note" ) # 링크 생성 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) target_title = target_doc.title if target_doc else target_note.title target_type = "document" if target_doc else "note" print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_title} ({target_type})") print(f" - 링크 타입: {new_link.link_type}") print(f" - 선택된 텍스트: {new_link.selected_text}") print(f" - 대상 텍스트: {new_link.target_text}") # 백링크는 자동으로 생성되지 않음 - 기존 링크를 역방향으로 조회하는 방식 사용 # 응답 데이터 구성 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_title, target_document_book_id=str(target_doc.book_id) if target_doc and target_doc.book_id else (str(target_note.notebook_id) if target_note and target_note.notebook_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 (current_user.is_admin or current_user.can_manage_books) and not document.is_public and document.uploaded_by != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) # 모든 링크 조회 (문서→문서 + 문서→노트) result = await db.execute( select(DocumentLink) .where(DocumentLink.source_document_id == document_id) .order_by(DocumentLink.start_offset.asc()) ) all_links = result.scalars().all() print(f"🔍 문서 링크 조회 완료: {len(all_links)}개 발견") # 응답 데이터 구성 response_links = [] for link in all_links: print(f"🔗 링크 처리 중: {link.id} -> {link.target_document_id}") # 대상이 문서인지 노트인지 확인 target_doc = None target_note = None # 먼저 Document 테이블에서 찾기 doc_result = await db.execute(select(Document).where(Document.id == link.target_document_id)) target_doc = doc_result.scalar_one_or_none() if target_doc: print(f"✅ 대상 문서 찾음: {target_doc.title}") target_title = target_doc.title target_book_id = str(target_doc.book_id) if target_doc.book_id else None target_content_type = "document" else: # Document에서 찾지 못하면 NoteDocument에서 찾기 from ...models.note_document import NoteDocument note_result = await db.execute(select(NoteDocument).where(NoteDocument.id == link.target_document_id)) target_note = note_result.scalar_one_or_none() if target_note: print(f"✅ 대상 노트 찾음: {target_note.title}") target_title = f"📝 {target_note.title}" # 노트임을 표시 target_book_id = str(target_note.notebook_id) if target_note.notebook_id else None target_content_type = "note" else: print(f"❌ 대상을 찾을 수 없음: {link.target_document_id}") target_title = "Unknown Target" target_book_id = None target_content_type = "document" # 기본값 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_title, target_document_book_id=target_book_id, target_content_type=target_content_type )) 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) | (current_user.can_manage_books == 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}") @router.delete("/document-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] source_content_type: Optional[str] = "document" # "document" or "note" target_document_id: str target_document_title: str selected_text: str # 소스 문서에서 선택한 텍스트 start_offset: int # 소스 문서 오프셋 end_offset: int # 소스 문서 오프셋 link_text: Optional[str] description: Optional[str] link_type: str target_text: Optional[str] # 🎯 타겟 문서의 텍스트 (백링크 렌더링용) target_start_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용) target_end_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용) 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) ): """문서의 백링크 조회 (이 문서를 참조하는 모든 링크)""" print(f"🔍 백링크 API 호출됨 - 문서 ID: {document_id}, 사용자: {current_user.email}") # 문서 존재 확인 result = await db.execute(select(Document).where(Document.id == document_id)) document = result.scalar_one_or_none() if not document: print(f"❌ 문서를 찾을 수 없음: {document_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Document not found" ) print(f"✅ 문서 찾음: {document.title}") # 권한 확인 (관리자 또는 문서 관리 권한이 있으면 모든 문서 접근 가능) if not (current_user.is_admin or current_user.can_manage_books) and not document.is_public and document.uploaded_by != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied" ) # 이 문서를 대상으로 하는 모든 링크 조회 (백링크) from ...models import Book from ...models.note_link import NoteLink from ...models.note_document import NoteDocument from ...models.notebook import Notebook # 1. 일반 문서에서 오는 백링크 (DocumentLink) doc_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) | (current_user.can_manage_books == True) ) ).order_by(DocumentLink.created_at.desc()) doc_result = await db.execute(doc_query) backlinks = [] print(f"🔍 문서 백링크 쿼리 실행 완료") # 일반 문서 백링크 처리 for link, source_doc, book in doc_result.fetchall(): print(f"📋 백링크 발견: {source_doc.title} -> {document.title}") print(f" - 소스 텍스트 (selected_text): {link.selected_text}") print(f" - 타겟 텍스트 (target_text): {link.target_text}") print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}") print(f" - 링크 타입: {link.link_type}") 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, source_content_type="document", # 일반 문서 target_document_id=str(link.target_document_id), target_document_title=document.title, 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, target_text=link.target_text, # 🎯 타겟 문서의 텍스트 (백링크 렌더링용) target_start_offset=link.target_start_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용) target_end_offset=link.target_end_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용) created_at=link.created_at.isoformat() )) # 2. 노트에서 오는 백링크 (NoteLink) - 동기 쿼리 사용 try: from ...core.database import get_sync_db sync_db = next(get_sync_db()) # 노트에서 이 문서를 대상으로 하는 링크들 조회 note_links = sync_db.query(NoteLink).join( NoteDocument, NoteLink.source_note_id == NoteDocument.id ).outerjoin(Notebook, NoteDocument.notebook_id == Notebook.id).filter( NoteLink.target_document_id == document_id ).all() print(f"🔍 노트 백링크 쿼리 실행 완료: {len(note_links)}개 발견") # 노트 백링크 처리 for link in note_links: source_note = sync_db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first() notebook = sync_db.query(Notebook).filter(Notebook.id == source_note.notebook_id).first() if source_note else None if source_note: print(f"📋 노트 백링크 발견: {source_note.title} -> {document.title}") print(f" - 소스 텍스트 (selected_text): {link.selected_text}") print(f" - 타겟 텍스트 (target_text): {link.target_text}") print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}") print(f" - 링크 타입: {link.link_type}") backlinks.append(BacklinkResponse( id=str(link.id), source_document_id=str(link.source_note_id), # 노트 ID를 문서 ID로 사용 source_document_title=f"📝 {source_note.title}", # 노트임을 표시 source_document_book_id=str(notebook.id) if notebook else None, source_content_type="note", # 노트 문서 target_document_id=str(link.target_document_id) if link.target_document_id else document_id, target_document_title=document.title, 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, target_text=link.target_text, target_start_offset=link.target_start_offset, target_end_offset=link.target_end_offset, created_at=link.created_at.isoformat() if link.created_at else None )) sync_db.close() except Exception as e: print(f"❌ 노트 백링크 조회 실패: {e}") print(f"✅ 총 {len(backlinks)}개의 백링크 반환 (문서 + 노트)") 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 (current_user.is_admin or current_user.can_manage_books) and not document.is_public and document.uploaded_by != current_user.id: 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) | (current_user.can_manage_books == 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