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:
Hyungi Ahn
2025-09-02 16:22:03 +09:00
parent f711998ce9
commit d01cdeb2f5
12 changed files with 1188 additions and 82 deletions

View File

@@ -0,0 +1,74 @@
-- 노트 링크 테이블 생성
-- 노트 문서 간 또는 노트-문서 간 링크를 관리하는 테이블
CREATE TABLE IF NOT EXISTS note_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- 링크 출발점 (노트 또는 문서 중 하나)
source_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
source_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
-- 링크 도착점 (노트 또는 문서 중 하나)
target_note_id UUID REFERENCES notes_documents(id) ON DELETE CASCADE,
target_document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
-- 출발점 텍스트 정보
selected_text TEXT NOT NULL,
start_offset INTEGER NOT NULL,
end_offset INTEGER NOT NULL,
-- 도착점 텍스트 정보 (선택사항)
target_text TEXT,
target_start_offset INTEGER,
target_end_offset INTEGER,
-- 링크 메타데이터
link_text VARCHAR(500),
description TEXT,
link_type VARCHAR(20) DEFAULT 'note' NOT NULL,
-- 생성자 및 시간 정보
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE,
-- 제약 조건
CONSTRAINT note_links_source_check CHECK (
(source_note_id IS NOT NULL AND source_document_id IS NULL) OR
(source_note_id IS NULL AND source_document_id IS NOT NULL)
),
CONSTRAINT note_links_target_check CHECK (
(target_note_id IS NOT NULL AND target_document_id IS NULL) OR
(target_note_id IS NULL AND target_document_id IS NOT NULL)
)
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_note_links_source_note ON note_links(source_note_id);
CREATE INDEX IF NOT EXISTS idx_note_links_source_document ON note_links(source_document_id);
CREATE INDEX IF NOT EXISTS idx_note_links_target_note ON note_links(target_note_id);
CREATE INDEX IF NOT EXISTS idx_note_links_target_document ON note_links(target_document_id);
CREATE INDEX IF NOT EXISTS idx_note_links_created_by ON note_links(created_by);
CREATE INDEX IF NOT EXISTS idx_note_links_created_at ON note_links(created_at);
-- updated_at 자동 업데이트 트리거
CREATE OR REPLACE FUNCTION update_note_links_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_note_links_updated_at
BEFORE UPDATE ON note_links
FOR EACH ROW
EXECUTE FUNCTION update_note_links_updated_at();
-- 코멘트 추가
COMMENT ON TABLE note_links IS '노트 문서 간 링크 관리 테이블';
COMMENT ON COLUMN note_links.source_note_id IS '출발점 노트 ID (노트에서 시작하는 링크)';
COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서에서 시작하는 링크)';
COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID';
COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID';
COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment';

View File

@@ -419,6 +419,7 @@ class BacklinkResponse(BaseModel):
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 # 소스 문서에서 선택한 텍스트
@@ -467,8 +468,12 @@ async def get_document_backlinks(
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
from ...models import Book
from ...models.note_link import NoteLink
from ...models.note_document import NoteDocument
from ...models.notebook import Notebook
query = select(DocumentLink, Document, Book).join(
# 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_(
@@ -478,12 +483,13 @@ async def get_document_backlinks(
)
).order_by(DocumentLink.created_at.desc())
result = await db.execute(query)
doc_result = await db.execute(doc_query)
backlinks = []
print(f"🔍 백링크 쿼리 실행 완료")
print(f"🔍 문서 백링크 쿼리 실행 완료")
for link, source_doc, book in result.fetchall():
# 일반 문서 백링크 처리
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}")
@@ -495,6 +501,7 @@ async def get_document_backlinks(
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, # 소스 문서에서 선택한 텍스트 (참고용)
@@ -509,7 +516,57 @@ async def get_document_backlinks(
created_at=link.created_at.isoformat()
))
print(f"✅ 총 {len(backlinks)}개의 백링크 반환")
# 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

View File

@@ -0,0 +1,270 @@
"""
노트 문서 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, asc
from typing import List, Optional
import html
from ...core.database import get_sync_db
from ..dependencies import get_current_user
from ...models.user import User
from ...models.note_document import (
NoteDocument,
NoteDocumentCreate,
NoteDocumentUpdate,
NoteDocumentResponse,
NoteDocumentListItem,
NoteStats
)
from ...models.notebook import Notebook
router = APIRouter()
def calculate_reading_time(content: str) -> int:
"""HTML 내용에서 예상 읽기 시간 계산 (분)"""
if not content:
return 0
# HTML 태그 제거
text_content = html.unescape(content)
# 간단한 HTML 태그 제거 (정확하지 않지만 대략적인 계산용)
import re
text_content = re.sub(r'<[^>]+>', '', text_content)
# 단어 수 계산 (한국어 + 영어)
words = len(text_content.split())
korean_chars = len([c for c in text_content if '\uac00' <= c <= '\ud7af'])
# 대략적인 읽기 속도: 영어 200단어/분, 한국어 300자/분
english_time = words / 200
korean_time = korean_chars / 300
return max(1, int(english_time + korean_time))
@router.get("/", response_model=List[NoteDocumentListItem])
def get_note_documents(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
search: Optional[str] = Query(None),
note_type: Optional[str] = Query(None),
published_only: bool = Query(False),
notebook_id: Optional[str] = Query(None),
sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|word_count)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 문서 목록 조회"""
query = db.query(NoteDocument)
# 필터링
if search:
search_term = f"%{search}%"
query = query.filter(
(NoteDocument.title.ilike(search_term)) |
(NoteDocument.content.ilike(search_term))
)
if note_type:
query = query.filter(NoteDocument.note_type == note_type)
if published_only:
query = query.filter(NoteDocument.is_published == True)
if notebook_id:
query = query.filter(NoteDocument.notebook_id == notebook_id)
# 정렬
if sort_by == 'title':
query = query.order_by(asc(NoteDocument.title) if order == 'asc' else desc(NoteDocument.title))
elif sort_by == 'created_at':
query = query.order_by(asc(NoteDocument.created_at) if order == 'asc' else desc(NoteDocument.created_at))
elif sort_by == 'word_count':
query = query.order_by(asc(NoteDocument.word_count) if order == 'asc' else desc(NoteDocument.word_count))
else:
query = query.order_by(desc(NoteDocument.updated_at))
# 페이지네이션
notes = query.offset(skip).limit(limit).all()
# 자식 노트 개수 계산
result = []
for note in notes:
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
note_item = NoteDocumentListItem.from_orm(note, child_count)
result.append(note_item)
return result
@router.get("/stats", response_model=NoteStats)
def get_note_stats(
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""노트 통계 정보"""
total_notes = db.query(func.count(NoteDocument.id)).scalar()
published_notes = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.is_published == True
).scalar()
draft_notes = total_notes - published_notes
# 노트 타입별 통계
type_stats = db.query(
NoteDocument.note_type,
func.count(NoteDocument.id)
).group_by(NoteDocument.note_type).all()
note_types = {note_type: count for note_type, count in type_stats}
# 총 단어 수와 읽기 시간
total_words = db.query(func.sum(NoteDocument.word_count)).scalar() or 0
total_reading_time = db.query(func.sum(NoteDocument.reading_time)).scalar() or 0
# 최근 노트들
recent_notes_query = db.query(NoteDocument).order_by(
desc(NoteDocument.updated_at)
).limit(5).all()
recent_notes = [NoteDocumentListItem.from_orm(note) for note in recent_notes_query]
return NoteStats(
total_notes=total_notes,
published_notes=published_notes,
draft_notes=draft_notes,
note_types=note_types,
total_words=total_words,
total_reading_time=total_reading_time,
recent_notes=recent_notes
)
@router.get("/{note_id}", response_model=NoteDocumentResponse)
def get_note_document(
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")
return NoteDocumentResponse.from_orm(note)
@router.post("/", response_model=NoteDocumentResponse)
def create_note_document(
note_data: NoteDocumentCreate,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""새 노트 문서 생성"""
# 단어 수 및 읽기 시간 계산
word_count = len(note_data.content or '') if note_data.content else 0
reading_time = calculate_reading_time(note_data.content or '')
note = NoteDocument(
title=note_data.title,
content=note_data.content,
note_type=note_data.note_type,
tags=note_data.tags,
is_published=note_data.is_published,
parent_note_id=note_data.parent_note_id,
sort_order=note_data.sort_order,
created_by=current_user.email,
word_count=word_count,
reading_time=reading_time
)
db.add(note)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.put("/{note_id}", response_model=NoteDocumentResponse)
def update_note_document(
note_id: str,
note_data: NoteDocumentUpdate,
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")
# 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 업데이트할 필드만 적용
update_data = note_data.dict(exclude_unset=True)
# 내용이 변경되면 단어 수와 읽기 시간 재계산
if 'content' in update_data:
update_data['word_count'] = len(update_data['content'] or '')
update_data['reading_time'] = calculate_reading_time(update_data['content'] or '')
for field, value in update_data.items():
setattr(note, field, value)
db.commit()
db.refresh(note)
return NoteDocumentResponse.from_orm(note)
@router.delete("/{note_id}")
def delete_note_document(
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")
# 권한 확인
if note.created_by != current_user.email and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Permission denied")
# 자식 노트들이 있는지 확인
child_count = db.query(func.count(NoteDocument.id)).filter(
NoteDocument.parent_note_id == note.id
).scalar()
if child_count > 0:
raise HTTPException(
status_code=400,
detail=f"Cannot delete note with {child_count} child notes"
)
db.delete(note)
db.commit()
return {"message": "Note deleted successfully"}
@router.get("/{note_id}/content")
def get_note_document_content(
note_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_sync_db)
):
"""노트 문서의 HTML 콘텐츠만 반환"""
note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note document not found")
return note.content or ""

View 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"}

View File

@@ -11,6 +11,7 @@ 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, document_links, notebooks, note_highlights, note_notes
from .api.routes.notes import router as note_documents_router
from .api.routes import note_documents, note_links
@asynccontextmanager
@@ -55,6 +56,8 @@ 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.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"])
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서 관리"])
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])

View File

@@ -12,6 +12,8 @@ from .note_document import NoteDocument
from .notebook import Notebook
from .note_highlight import NoteHighlight
from .note_note import NoteNote
from .note_link import NoteLink
from .memo_tree import MemoTree, MemoNode, MemoTreeShare
__all__ = [
"User",
@@ -25,5 +27,9 @@ __all__ = [
"NoteDocument",
"Notebook",
"NoteHighlight",
"NoteNote"
"NoteNote",
"NoteLink",
"MemoTree",
"MemoNode",
"MemoTreeShare"
]

View File

@@ -0,0 +1,57 @@
"""
노트 문서 링크 모델
"""
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 NoteLink(Base):
"""노트 문서 링크 테이블"""
__tablename__ = "note_links"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 링크가 생성된 노트 (출발점) - 노트 문서 또는 일반 문서 가능
source_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, index=True)
# 링크 대상 노트 (도착점) - 노트 문서 또는 일반 문서 가능
target_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True, index=True)
target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True, 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) # 링크 설명
# 링크 타입
link_type = Column(String(20), default="note", nullable=False) # "note", "document", "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_note = relationship("NoteDocument", foreign_keys=[source_note_id], backref="outgoing_note_links")
source_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_note_links")
target_note = relationship("NoteDocument", foreign_keys=[target_note_id], backref="incoming_note_links")
target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_note_links")
creator = relationship("User", backref="created_note_links")
def __repr__(self):
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"