feat: 노트북-서적 간 양방향 링크/백링크 시스템 완성
✨ 주요 기능 - 노트 ↔ 서적 문서 간 양방향 링크 생성 및 이동 - 링크 대상 타입 선택 UI (서적 문서/노트북 노트) - 통합 백링크 시스템 (일반 문서에서 노트 백링크도 표시) - 링크 목록 UI 개선 (상세 정보 표시, 타입 구분) 🔧 백엔드 개선 - NoteLink 모델 및 API 추가 (/note-documents/{id}/links, /note-documents/{id}/backlinks) - 일반 문서 백링크 API에서 노트 링크도 함께 조회 - target_content_type, source_content_type 필드 추가 - 노트 문서 콘텐츠 API 추가 (/note-documents/{id}/content) 🎨 프론트엔드 개선 - text-selector.html에서 노트 문서 지원 - 링크 이동 시 contentType에 따른 올바른 URL 생성 - URL 파라미터 파싱 수정 (contentType 지원) - 링크 타입 자동 추론 로직 - 링크 목록 UI 대폭 개선 (출발점/도착점 텍스트, 타입 배지 등) 🐛 버그 수정 - 서적 목록 로드 실패 문제 해결 - 노트에서 링크 생성 시 대상 문서 열기 문제 해결 - 더미 문서로 이동하는 문제 해결 - 캐시 관련 문제 해결
This commit is contained in:
277
backend/src/api/routes/note_links.py
Normal file
277
backend/src/api/routes/note_links.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
노트 문서 링크 관련 API 엔드포인트
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import uuid
|
||||
|
||||
from ...core.database import get_sync_db
|
||||
from ..dependencies import get_current_user
|
||||
from ...models.user import User
|
||||
from ...models.note_document import NoteDocument
|
||||
from ...models.document import Document
|
||||
from ...models.note_link import NoteLink
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class NoteLinkCreate(BaseModel):
|
||||
target_note_id: Optional[str] = None
|
||||
target_document_id: Optional[str] = None
|
||||
selected_text: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
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] = "note"
|
||||
|
||||
|
||||
class NoteLinkUpdate(BaseModel):
|
||||
target_note_id: Optional[str] = None
|
||||
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 NoteLinkResponse(BaseModel):
|
||||
id: str
|
||||
source_note_id: Optional[str] = None
|
||||
source_document_id: Optional[str] = None
|
||||
target_note_id: Optional[str] = None
|
||||
target_document_id: Optional[str] = None
|
||||
target_content_type: Optional[str] = None # "document" or "note"
|
||||
selected_text: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
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: str
|
||||
created_at: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
# 추가 정보
|
||||
target_note_title: Optional[str] = None
|
||||
target_document_title: Optional[str] = None
|
||||
source_note_title: Optional[str] = None
|
||||
source_document_title: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/note-documents/{note_id}/links", response_model=List[NoteLinkResponse])
|
||||
def get_note_links(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트에서 나가는 링크 목록 조회"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 노트에서 나가는 링크들 조회
|
||||
links = db.query(NoteLink).filter(
|
||||
NoteLink.source_note_id == note_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for link in links:
|
||||
link_data = {
|
||||
"id": str(link.id),
|
||||
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
|
||||
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
|
||||
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
|
||||
"target_document_id": str(link.target_document_id) if link.target_document_id 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,
|
||||
"target_text": link.target_text,
|
||||
"target_start_offset": link.target_start_offset,
|
||||
"target_end_offset": link.target_end_offset,
|
||||
"link_type": link.link_type,
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
|
||||
}
|
||||
|
||||
# 대상 제목 및 타입 추가
|
||||
if link.target_note_id:
|
||||
target_note = db.query(NoteDocument).filter(NoteDocument.id == link.target_note_id).first()
|
||||
if target_note:
|
||||
link_data["target_note_title"] = target_note.title
|
||||
link_data["target_content_type"] = "note"
|
||||
elif link.target_document_id:
|
||||
target_doc = db.query(Document).filter(Document.id == link.target_document_id).first()
|
||||
if target_doc:
|
||||
link_data["target_document_title"] = target_doc.title
|
||||
link_data["target_content_type"] = "document"
|
||||
|
||||
result.append(NoteLinkResponse(**link_data))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/note-documents/{note_id}/backlinks", response_model=List[NoteLinkResponse])
|
||||
def get_note_backlinks(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트로 들어오는 백링크 목록 조회"""
|
||||
# 노트 존재 확인
|
||||
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# 노트로 들어오는 백링크들 조회
|
||||
backlinks = db.query(NoteLink).filter(
|
||||
NoteLink.target_note_id == note_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for link in backlinks:
|
||||
link_data = {
|
||||
"id": str(link.id),
|
||||
"source_note_id": str(link.source_note_id) if link.source_note_id else None,
|
||||
"source_document_id": str(link.source_document_id) if link.source_document_id else None,
|
||||
"target_note_id": str(link.target_note_id) if link.target_note_id else None,
|
||||
"target_document_id": str(link.target_document_id) if link.target_document_id 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,
|
||||
"target_text": link.target_text,
|
||||
"target_start_offset": link.target_start_offset,
|
||||
"target_end_offset": link.target_end_offset,
|
||||
"link_type": link.link_type,
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
"updated_at": link.updated_at.isoformat() if link.updated_at else None,
|
||||
}
|
||||
|
||||
# 출발지 제목 추가
|
||||
if link.source_note_id:
|
||||
source_note = db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
|
||||
if source_note:
|
||||
link_data["source_note_title"] = source_note.title
|
||||
elif link.source_document_id:
|
||||
source_doc = db.query(Document).filter(Document.id == link.source_document_id).first()
|
||||
if source_doc:
|
||||
link_data["source_document_title"] = source_doc.title
|
||||
|
||||
result.append(NoteLinkResponse(**link_data))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/note-documents/{note_id}/links", response_model=NoteLinkResponse)
|
||||
def create_note_link(
|
||||
note_id: str,
|
||||
link_data: NoteLinkCreate,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트에서 다른 노트/문서로의 링크 생성"""
|
||||
# 출발지 노트 존재 확인
|
||||
source_note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
|
||||
if not source_note:
|
||||
raise HTTPException(status_code=404, detail="Source note not found")
|
||||
|
||||
# 대상 확인 (노트 또는 문서 중 하나는 반드시 있어야 함)
|
||||
if not link_data.target_note_id and not link_data.target_document_id:
|
||||
raise HTTPException(status_code=400, detail="Either target_note_id or target_document_id is required")
|
||||
|
||||
if link_data.target_note_id and link_data.target_document_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot specify both target_note_id and target_document_id")
|
||||
|
||||
# 대상 존재 확인
|
||||
if link_data.target_note_id:
|
||||
target_note = db.query(NoteDocument).filter(NoteDocument.id == link_data.target_note_id).first()
|
||||
if not target_note:
|
||||
raise HTTPException(status_code=404, detail="Target note not found")
|
||||
|
||||
if link_data.target_document_id:
|
||||
target_doc = db.query(Document).filter(Document.id == link_data.target_document_id).first()
|
||||
if not target_doc:
|
||||
raise HTTPException(status_code=404, detail="Target document not found")
|
||||
|
||||
# 링크 생성
|
||||
note_link = NoteLink(
|
||||
source_note_id=note_id,
|
||||
target_note_id=link_data.target_note_id,
|
||||
target_document_id=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 or "note",
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(note_link)
|
||||
db.commit()
|
||||
db.refresh(note_link)
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = {
|
||||
"id": str(note_link.id),
|
||||
"source_note_id": str(note_link.source_note_id) if note_link.source_note_id else None,
|
||||
"source_document_id": str(note_link.source_document_id) if note_link.source_document_id else None,
|
||||
"target_note_id": str(note_link.target_note_id) if note_link.target_note_id else None,
|
||||
"target_document_id": str(note_link.target_document_id) if note_link.target_document_id else None,
|
||||
"selected_text": note_link.selected_text,
|
||||
"start_offset": note_link.start_offset,
|
||||
"end_offset": note_link.end_offset,
|
||||
"link_text": note_link.link_text,
|
||||
"description": note_link.description,
|
||||
"target_text": note_link.target_text,
|
||||
"target_start_offset": note_link.target_start_offset,
|
||||
"target_end_offset": note_link.target_end_offset,
|
||||
"link_type": note_link.link_type,
|
||||
"created_at": note_link.created_at.isoformat() if note_link.created_at else None,
|
||||
"updated_at": note_link.updated_at.isoformat() if note_link.updated_at else None,
|
||||
}
|
||||
|
||||
return NoteLinkResponse(**response_data)
|
||||
|
||||
|
||||
@router.delete("/note-links/{link_id}")
|
||||
def delete_note_link(
|
||||
link_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""노트 링크 삭제"""
|
||||
link = db.query(NoteLink).filter(NoteLink.id == link_id).first()
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
|
||||
# 권한 확인 (링크 생성자 또는 관리자만 삭제 가능)
|
||||
if link.created_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
db.delete(link)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Link deleted successfully"}
|
||||
Reference in New Issue
Block a user