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

View File

@@ -617,6 +617,11 @@ class DocumentServerAPI {
return await this.get(`/note-documents/${noteId}`);
}
// 특정 노트북의 노트들 조회
async getNotesInNotebook(notebookId) {
return await this.get('/note-documents/', { notebook_id: notebookId });
}
// === 노트 문서 (Note Document) 관련 API ===
// 용어 정의: 독립적인 문서 작성 (HTML 기반)
async createNoteDocument(noteData) {

View File

@@ -22,14 +22,28 @@ class LinkManager {
}
/**
* 문서 링크 데이터 로드
* 문서/노트 링크 데이터 로드
*/
async loadDocumentLinks(documentId) {
async loadDocumentLinks(documentId, contentType = 'document') {
try {
console.log('📡 링크 API 호출:', `/documents/${documentId}/links`);
console.log('📡 사용 중인 documentId:', documentId);
console.log('🔍 loadDocumentLinks 호출됨 - documentId:', documentId, 'contentType:', contentType);
let apiEndpoint;
if (contentType === 'note') {
// 노트 문서의 경우 노트 전용 링크 API 사용
apiEndpoint = `/note-documents/${documentId}/links`;
console.log('✅ 노트 API 엔드포인트 선택:', apiEndpoint);
} else {
// 일반 문서의 경우 기존 API 사용
apiEndpoint = `/documents/${documentId}/links`;
console.log('✅ 문서 API 엔드포인트 선택:', apiEndpoint);
}
console.log('📡 링크 API 호출:', apiEndpoint);
console.log('📡 사용 중인 documentId:', documentId, 'contentType:', contentType);
console.log('📡 cachedApi 객체:', this.cachedApi);
const response = await this.cachedApi.get(`/documents/${documentId}/links`, {}, { category: 'links' });
const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' });
console.log('📡 원본 API 응답:', response);
console.log('📡 응답 타입:', typeof response);
console.log('📡 응답이 배열인가?', Array.isArray(response));
@@ -53,7 +67,21 @@ class LinkManager {
this.documentLinks = [];
}
console.log('📡 최종 링크 데이터:', this.documentLinks);
// target_content_type이 없는 링크들에 대해 추론 로직 적용
this.documentLinks = this.documentLinks.map(link => {
if (!link.target_content_type) {
if (link.target_note_id) {
link.target_content_type = 'note';
console.log('🔍 링크 타입 추론: note -', link.id);
} else if (link.target_document_id) {
link.target_content_type = 'document';
console.log('🔍 링크 타입 추론: document -', link.id);
}
}
return link;
});
console.log('📡 최종 링크 데이터 (타입 추론 완료):', this.documentLinks);
console.log('📡 최종 링크 개수:', this.documentLinks.length);
return this.documentLinks;
} catch (error) {
@@ -66,10 +94,25 @@ class LinkManager {
/**
* 백링크 데이터 로드
*/
async loadBacklinks(documentId) {
async loadBacklinks(documentId, contentType = 'document') {
try {
console.log('📡 백링크 API 호출:', `/documents/${documentId}/backlinks`);
const response = await this.cachedApi.get(`/documents/${documentId}/backlinks`, {}, { category: 'links' });
console.log('🔍 loadBacklinks 호출됨 - documentId:', documentId, 'contentType:', contentType);
let apiEndpoint;
if (contentType === 'note') {
// 노트 문서의 경우 노트 전용 백링크 API 사용
apiEndpoint = `/note-documents/${documentId}/backlinks`;
console.log('✅ 노트 백링크 API 엔드포인트 선택:', apiEndpoint);
} else {
// 일반 문서의 경우 기존 API 사용
apiEndpoint = `/documents/${documentId}/backlinks`;
console.log('✅ 문서 백링크 API 엔드포인트 선택:', apiEndpoint);
}
console.log('📡 백링크 API 호출:', apiEndpoint);
console.log('📡 사용 중인 documentId:', documentId, 'contentType:', contentType);
const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' });
console.log('📡 원본 백링크 응답:', response);
console.log('📡 백링크 응답 타입:', typeof response);
console.log('📡 백링크 응답이 배열인가?', Array.isArray(response));
@@ -753,15 +796,40 @@ class LinkManager {
* 링크된 문서로 이동
*/
navigateToLinkedDocument(targetDocumentId, linkInfo) {
let targetUrl = `/viewer.html?id=${targetDocumentId}`;
console.log('🔗 navigateToLinkedDocument 호출됨');
console.log('📋 전달받은 파라미터:', {
targetDocumentId: targetDocumentId,
linkInfo: linkInfo
});
if (!targetDocumentId) {
console.error('❌ targetDocumentId가 없습니다!');
alert('대상 문서 ID가 없습니다.');
return;
}
// contentType에 따라 적절한 URL 생성
let targetUrl;
if (linkInfo.target_content_type === 'note') {
// 노트 문서로 이동
targetUrl = `/viewer.html?id=${targetDocumentId}&contentType=note`;
console.log('📝 노트 문서로 이동:', targetDocumentId);
} else {
// 일반 문서로 이동
targetUrl = `/viewer.html?id=${targetDocumentId}`;
console.log('📄 일반 문서로 이동:', targetDocumentId);
}
// 특정 텍스트 위치가 있는 경우 URL에 추가
if (linkInfo.target_text && linkInfo.target_start_offset !== undefined) {
targetUrl += `&highlight_text=${encodeURIComponent(linkInfo.target_text)}`;
targetUrl += `&start_offset=${linkInfo.target_start_offset}`;
targetUrl += `&end_offset=${linkInfo.target_end_offset}`;
console.log('🎯 텍스트 하이라이트 추가:', linkInfo.target_text);
}
console.log('🚀 최종 이동할 URL:', targetUrl);
window.location.href = targetUrl;
}
@@ -769,15 +837,40 @@ class LinkManager {
* 원본 문서로 이동 (백링크)
*/
navigateToSourceDocument(sourceDocumentId, backlinkInfo) {
let targetUrl = `/viewer.html?id=${sourceDocumentId}`;
console.log('🔙 navigateToSourceDocument 호출됨');
console.log('📋 전달받은 파라미터:', {
sourceDocumentId: sourceDocumentId,
backlinkInfo: backlinkInfo
});
if (!sourceDocumentId) {
console.error('❌ sourceDocumentId가 없습니다!');
alert('소스 문서 ID가 없습니다.');
return;
}
// source_content_type에 따라 적절한 URL 생성
let targetUrl;
if (backlinkInfo.source_content_type === 'note') {
// 노트 문서로 이동
targetUrl = `/viewer.html?id=${sourceDocumentId}&contentType=note`;
console.log('📝 노트 문서로 이동 (백링크):', sourceDocumentId);
} else {
// 일반 문서로 이동
targetUrl = `/viewer.html?id=${sourceDocumentId}`;
console.log('📄 일반 문서로 이동 (백링크):', sourceDocumentId);
}
// 원본 텍스트 위치가 있는 경우 URL에 추가
if (backlinkInfo.selected_text && backlinkInfo.start_offset !== undefined) {
targetUrl += `&highlight_text=${encodeURIComponent(backlinkInfo.selected_text)}`;
targetUrl += `&start_offset=${backlinkInfo.start_offset}`;
targetUrl += `&end_offset=${backlinkInfo.end_offset}`;
console.log('🎯 텍스트 하이라이트 추가 (백링크):', backlinkInfo.selected_text);
}
console.log('🚀 최종 이동할 URL (백링크):', targetUrl);
window.location.href = targetUrl;
}
@@ -1050,7 +1143,34 @@ class LinkManager {
const link = this.documentLinks.find(l => l.id === elementId);
if (link) {
this.navigateToLinkedDocument(link.target_document_id, link);
console.log('🔗 메뉴에서 링크 클릭:', link);
// target_content_type이 없으면 ID로 추론
let targetContentType = link.target_content_type;
if (!targetContentType) {
if (link.target_note_id) {
targetContentType = 'note';
} else if (link.target_document_id) {
targetContentType = 'document';
}
console.log('🔍 메뉴에서 target_content_type 추론됨:', targetContentType);
}
const targetId = link.target_document_id || link.target_note_id;
if (!targetId) {
console.error('❌ 메뉴에서 대상 ID가 없습니다!', link);
alert('링크 대상을 찾을 수 없습니다.');
return;
}
// 링크 객체에 추론된 타입 추가
const linkWithType = {
...link,
target_content_type: targetContentType
};
console.log('🚀 메뉴에서 최종 링크 데이터:', linkWithType);
this.navigateToLinkedDocument(targetId, linkWithType);
} else {
console.warn('링크 데이터를 찾을 수 없음:', elementId);
}

View File

@@ -34,6 +34,7 @@ window.documentViewer = () => ({
description: ''
},
linkForm: {
target_type: 'document', // 'document' 또는 'note'
target_document_id: '',
selected_text: '',
start_offset: 0,
@@ -246,11 +247,13 @@ window.documentViewer = () => ({
parseUrlParameters() {
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
this.contentType = urlParams.get('type') || 'document';
// contentType 파라미터를 올바르게 가져오기 (type과 contentType 둘 다 지원)
this.contentType = urlParams.get('contentType') || urlParams.get('type') || 'document';
console.log('🔍 URL 파싱 결과:', {
documentId: this.documentId,
contentType: this.contentType
contentType: this.contentType,
fullURL: window.location.href
});
if (!this.documentId) {
@@ -304,8 +307,8 @@ window.documentViewer = () => ({
this.highlightManager.loadHighlights(this.documentId, this.contentType),
this.highlightManager.loadNotes(this.documentId, this.contentType),
this.bookmarkManager.loadBookmarks(this.documentId),
this.linkManager.loadDocumentLinks(this.documentId),
this.linkManager.loadBacklinks(this.documentId)
this.linkManager.loadDocumentLinks(this.documentId, this.contentType),
this.linkManager.loadBacklinks(this.documentId, this.contentType)
]);
// 데이터 저장 및 모듈 동기화
@@ -475,33 +478,72 @@ window.documentViewer = () => ({
async loadBacklinks() {
console.log('🔗 백링크 로드 시작');
if (this.linkManager) {
await this.linkManager.loadBacklinks(this.documentId);
await this.linkManager.loadBacklinks(this.documentId, this.contentType);
// UI 상태 동기화
this.backlinks = this.linkManager.backlinks || [];
}
},
async loadAvailableBooks() {
// 링크 대상 타입 변경 시 호출
async onTargetTypeChange() {
console.log('🔄 링크 대상 타입 변경:', this.linkForm.target_type);
// 기존 선택 초기화
this.linkForm.target_book_id = '';
this.linkForm.target_document_id = '';
this.availableBooks = [];
this.filteredDocuments = [];
// 선택된 타입에 따라 데이터 로드
if (this.linkForm.target_type === 'note') {
await this.loadNotebooks();
} else {
await this.loadBooks();
}
},
// 노트북 목록 로드
async loadNotebooks() {
try {
console.log('📚 노트북 목록 로딩 시작...');
const notebooks = await this.api.get('/notebooks/', { active_only: true });
this.availableBooks = notebooks.map(notebook => ({
id: notebook.id,
title: notebook.title
})) || [];
console.log('📚 로드된 노트북 목록:', this.availableBooks.length, '개');
} catch (error) {
console.error('노트북 목록 로드 실패:', error);
this.availableBooks = [];
}
},
// 서적 목록 로드
async loadBooks() {
try {
console.log('📚 서적 목록 로딩 시작...');
// 문서 목록에서 서적 정보 추출
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
console.log('📄 모든 문서들 (총 개수):', allDocuments.length);
let allDocuments;
// 소스 문서의 서적 정보 찾기
const sourceBookInfo = this.getSourceBookInfo(allDocuments);
console.log('📖 소스 문서 서적 정보:', sourceBookInfo);
// contentType에 따라 다른 API 사용
if (this.contentType === 'note') {
// 노트의 경우 전체 문서 목록에서 서적 정보 추출
console.log('📝 노트 모드: 전체 문서 목록에서 서적 정보 추출');
allDocuments = await this.api.getDocuments();
console.log('📄 전체 문서들 (총 개수):', allDocuments.length);
} else {
// 일반 문서의 경우 linkable-documents API 사용
console.log('📄 문서 모드: linkable-documents API 사용');
allDocuments = await this.api.getLinkableDocuments(this.documentId);
console.log('📄 링크 가능한 문서들 (총 개수):', allDocuments.length);
}
// 서적별로 그룹화
const bookMap = new Map();
allDocuments.forEach(doc => {
if (doc.book_id && doc.book_title) {
console.log('📖 문서 서적 정보:', {
docId: doc.id,
bookId: doc.book_id,
bookTitle: doc.book_title
});
bookMap.set(doc.book_id, {
id: doc.book_id,
title: doc.book_title
@@ -509,18 +551,28 @@ window.documentViewer = () => ({
}
});
console.log('📚 그룹화된 모든 서적들:', Array.from(bookMap.values()));
// 모든 서적 표시 (소스 서적 포함)
this.availableBooks = Array.from(bookMap.values());
console.log('📚 최종 사용 가능한 서적들 (모든 서적):', this.availableBooks);
console.log('📖 소스 서적 정보 (포함됨):', sourceBookInfo);
console.log('📚 로드된 서적 목록:', this.availableBooks.length, '개');
} catch (error) {
console.error('서적 목록 로드 실패:', error);
this.availableBooks = [];
}
},
async loadAvailableBooks() {
try {
// 기본값으로 문서 타입 설정 (기존 호환성)
if (this.linkForm.target_type === 'note') {
await this.loadNotebooks();
} else {
await this.loadBooks();
}
} catch (error) {
console.error('목록 로드 실패:', error);
this.availableBooks = [];
}
},
getSourceBookInfo(allDocuments = null) {
// 여러 소스에서 현재 문서의 서적 정보 찾기
let sourceBookId = this.navigation?.book_info?.id ||
@@ -548,6 +600,30 @@ window.documentViewer = () => ({
async loadSameBookDocuments() {
try {
if (this.contentType === 'note') {
console.log('📚 같은 노트북의 노트들 로드 시작...');
// 현재 노트의 노트북 정보 가져오기
const currentNote = this.document;
const notebookId = currentNote?.notebook_id;
if (notebookId) {
// 같은 노트북의 노트들 로드 (현재 노트 제외)
const notes = await this.api.getNotesInNotebook(notebookId);
this.filteredDocuments = notes.filter(note => note.id !== this.documentId);
console.log('📚 같은 노트북 노트들:', {
count: this.filteredDocuments.length,
notebookId: notebookId,
notes: this.filteredDocuments.map(note => ({ id: note.id, title: note.title }))
});
} else {
console.warn('⚠️ 현재 노트의 노트북 정보를 찾을 수 없습니다');
this.filteredDocuments = [];
}
return;
}
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
// 소스 문서의 서적 정보 가져오기
@@ -574,7 +650,7 @@ window.documentViewer = () => ({
this.filteredDocuments = [];
}
} catch (error) {
console.error('같은 서적 문서 로드 실패:', error);
console.error('같은 서적/노트북 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
@@ -620,12 +696,30 @@ window.documentViewer = () => ({
async loadDocumentsFromBook() {
try {
if (this.linkForm.target_book_id) {
// 선택된 서적의 문서들만 가져오기
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.linkForm.target_book_id
);
console.log('📚 선택된 서적 문서들:', this.filteredDocuments);
if (this.linkForm.target_type === 'note') {
// 노트북 선택: 선택된 노트북의 노트들 가져오기
const notes = await this.api.getNotesInNotebook(this.linkForm.target_book_id);
this.filteredDocuments = notes.filter(note => note.id !== this.documentId);
console.log('📚 선택된 노트북 노트들:', this.filteredDocuments);
} else {
// 서적 선택: 선택된 서적의 문서들만 가져오기
let allDocuments;
if (this.contentType === 'note') {
// 노트에서 서적 문서를 선택하는 경우: 전체 문서 목록에서 필터링
console.log('📝 노트에서 서적 문서 선택: 전체 문서 목록 사용');
allDocuments = await this.api.getDocuments();
} else {
// 일반 문서에서 서적 문서를 선택하는 경우: linkable-documents API 사용
console.log('📄 문서에서 서적 문서 선택: linkable-documents API 사용');
allDocuments = await this.api.getLinkableDocuments(this.documentId);
}
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.linkForm.target_book_id
);
console.log('📚 선택된 서적 문서들:', this.filteredDocuments);
}
} else {
this.filteredDocuments = [];
}
@@ -633,7 +727,7 @@ window.documentViewer = () => ({
// 문서 선택 초기화
this.linkForm.target_document_id = '';
} catch (error) {
console.error('서적별 문서 로드 실패:', error);
console.error('서적/노트북별 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
@@ -662,8 +756,9 @@ window.documentViewer = () => ({
}
// 새 창에서 대상 문서 열기 (텍스트 선택 모드 전용 페이지)
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl);
const targetContentType = this.linkForm.target_type === 'note' ? 'note' : 'document';
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}&contentType=${targetContentType}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl, 'contentType:', targetContentType);
const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes');
if (!popup) {
@@ -1087,6 +1182,62 @@ window.documentViewer = () => ({
return this.linkManager.navigateToBacklinkDocument(documentId, backlinkData);
},
// HTML에서 사용되는 링크 이동 함수들
navigateToLink(link) {
console.log('🔗 링크 클릭:', link);
console.log('📋 링크 상세 정보:', {
target_document_id: link.target_document_id,
target_note_id: link.target_note_id,
target_content_type: link.target_content_type,
target_document_title: link.target_document_title,
target_note_title: link.target_note_title
});
// target_content_type이 없으면 ID로 추론
let targetContentType = link.target_content_type;
if (!targetContentType) {
if (link.target_note_id) {
targetContentType = 'note';
} else if (link.target_document_id) {
targetContentType = 'document';
}
console.log('🔍 target_content_type 추론됨:', targetContentType);
}
const targetId = link.target_document_id || link.target_note_id;
if (!targetId) {
console.error('❌ 대상 문서/노트 ID가 없습니다!', link);
alert('링크 대상을 찾을 수 없습니다.');
return;
}
// 링크 객체에 추론된 타입 추가
const linkWithType = {
...link,
target_content_type: targetContentType
};
console.log('🚀 최종 링크 데이터:', linkWithType);
return this.linkManager.navigateToLinkedDocument(targetId, linkWithType);
},
navigateToBacklink(backlink) {
console.log('🔙 백링크 클릭:', backlink);
console.log('📋 백링크 상세 정보:', {
source_document_id: backlink.source_document_id,
source_content_type: backlink.source_content_type,
source_document_title: backlink.source_document_title
});
if (!backlink.source_document_id) {
console.error('❌ 소스 문서 ID가 없습니다!', backlink);
alert('백링크 소스를 찾을 수 없습니다.');
return;
}
return this.linkManager.navigateToSourceDocument(backlink.source_document_id, backlink);
},
// 북마크 관련
scrollToBookmark(bookmark) {
return this.bookmarkManager.scrollToBookmark(bookmark);
@@ -1144,8 +1295,29 @@ window.documentViewer = () => ({
console.log('✅ 모든 필수 필드 확인됨');
// API 호출
await this.api.createDocumentLink(this.documentId, linkData);
// API 호출 (출발지와 대상에 따라 다른 API 사용)
if (this.contentType === 'note') {
// 노트에서 출발하는 링크
if (this.linkForm.target_type === 'note') {
// 노트 → 노트: 노트 링크 API 사용
linkData.target_note_id = linkData.target_document_id;
delete linkData.target_document_id;
await this.api.post(`/note-documents/${this.documentId}/links`, linkData);
} else {
// 노트 → 문서: 노트 링크 API 사용 (target_document_id 유지)
await this.api.post(`/note-documents/${this.documentId}/links`, linkData);
}
} else {
// 문서에서 출발하는 링크
if (this.linkForm.target_type === 'note') {
// 문서 → 노트: 문서 링크 API에 노트 대상 지원 필요 (향후 구현)
// 현재는 기존 API 사용
await this.api.createDocumentLink(this.documentId, linkData);
} else {
// 문서 → 문서: 기존 문서 링크 API 사용
await this.api.createDocumentLink(this.documentId, linkData);
}
}
console.log('✅ 링크 생성됨');
// 성공 알림
@@ -1157,13 +1329,17 @@ window.documentViewer = () => ({
// 캐시 무효화 (새 링크가 반영되도록)
console.log('🗑️ 링크 캐시 무효화 시작...');
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']);
if (this.contentType === 'note') {
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/links`, ['links']);
} else {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']);
}
console.log('✅ 링크 캐시 무효화 완료');
}
// 링크 목록 새로고침
console.log('🔄 링크 목록 새로고침 시작...');
await this.linkManager.loadDocumentLinks(this.documentId);
await this.linkManager.loadDocumentLinks(this.documentId, this.contentType);
this.documentLinks = this.linkManager.documentLinks || [];
console.log('📊 로드된 링크 개수:', this.documentLinks.length);
console.log('📊 링크 데이터:', this.documentLinks);
@@ -1177,10 +1353,14 @@ window.documentViewer = () => ({
console.log('🔄 백링크 새로고침 시작...');
// 백링크 캐시도 무효화
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']);
if (this.contentType === 'note') {
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']);
} else {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']);
}
console.log('✅ 백링크 캐시도 무효화 완료');
}
await this.linkManager.loadBacklinks(this.documentId);
await this.linkManager.loadBacklinks(this.documentId, this.contentType);
this.backlinks = this.linkManager.backlinks || [];
this.linkManager.renderBacklinks();
console.log('✅ 백링크 새로고침 완료');

View File

@@ -78,11 +78,14 @@
// URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
this.contentType = urlParams.get('contentType') || 'document'; // 기본값은 document
if (!this.documentId) {
this.showError('문서 ID가 없습니다');
return;
}
console.log('🔧 초기화:', { documentId: this.documentId, contentType: this.contentType });
// 인증 확인
if (!api.token) {
@@ -100,16 +103,28 @@
}
async loadDocument() {
console.log('📄 문서 로드 중:', this.documentId);
console.log('📄 문서 로드 중:', this.documentId, 'contentType:', this.contentType);
try {
// 문서 메타데이터 조회
const docResponse = await api.getDocument(this.documentId);
// contentType에 따라 적절한 API 호출
let docResponse, contentEndpoint;
if (this.contentType === 'note') {
// 노트 문서 메타데이터 조회
docResponse = await api.getNoteDocument(this.documentId);
contentEndpoint = `/note-documents/${this.documentId}/content`;
console.log('📝 노트 메타데이터:', docResponse);
} else {
// 일반 문서 메타데이터 조회
docResponse = await api.getDocument(this.documentId);
contentEndpoint = `/documents/${this.documentId}/content`;
console.log('📋 문서 메타데이터:', docResponse);
}
this.document = docResponse;
console.log('📋 문서 메타데이터:', docResponse);
// 문서 HTML 콘텐츠 조회
const contentResponse = await fetch(`${api.baseURL}/documents/${this.documentId}/content`, {
const contentResponse = await fetch(`${api.baseURL}${contentEndpoint}`, {
headers: {
'Authorization': `Bearer ${api.token}`,
'Content-Type': 'application/json'

View File

@@ -311,29 +311,45 @@
<template x-for="link in documentLinks" :key="link.id">
<div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors"
@click="navigateToLink(link)">
<div class="flex items-center justify-between">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="font-medium text-purple-700 mb-1" x-text="link.target_document_title"></div>
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
<div x-show="link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 bg-gray-100 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
<!-- 대상 문서 제목 -->
<div class="font-medium text-purple-700 mb-2 flex items-center">
<span x-text="link.target_document_title || link.target_note_title"></span>
<span x-show="link.target_content_type === 'note'" class="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">노트</span>
<span x-show="link.target_content_type === 'document'" class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-1 rounded">문서</span>
</div>
<div x-show="!link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 italic">📄 문서 전체 링크</div>
<!-- 현재 문서에서 선택한 텍스트 (출발점) -->
<div x-show="link.selected_text" class="mb-3">
<div class="text-xs text-gray-500 mb-1">📍 현재 문서에서 선택한 텍스트:</div>
<div class="text-sm text-gray-700 bg-purple-50 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
</div>
<!-- 대상 문서의 텍스트 (도착점) -->
<div x-show="link.target_text" class="mb-3">
<div class="text-xs text-gray-500 mb-1">🎯 대상 문서의 텍스트:</div>
<div class="text-sm text-gray-700 bg-blue-50 px-3 py-2 rounded border-l-4 border-blue-500" x-text="link.target_text"></div>
</div>
<!-- 문서 전체 링크인 경우 -->
<div x-show="!link.selected_text && !link.target_text" class="mb-3">
<div class="text-sm text-gray-600 italic bg-gray-50 px-3 py-2 rounded">📄 문서 전체 링크</div>
</div>
<!-- 설명 -->
<div x-show="link.description" class="text-sm text-gray-600 mb-2" x-text="link.description"></div>
<div x-show="link.description" class="mb-3">
<div class="text-xs text-gray-500 mb-1">💬 설명:</div>
<div class="text-sm text-gray-600 bg-yellow-50 px-3 py-2 rounded" x-text="link.description"></div>
</div>
<!-- 링크 타입과 날짜 -->
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500"
x-text="link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'"></span>
<span class="text-xs text-gray-500" x-text="formatDate(link.created_at)"></span>
<div class="flex items-center justify-between text-xs text-gray-500">
<span x-text="link.link_type === 'text_fragment' ? '🔗 텍스트 조각 링크' : '📄 문서 링크'"></span>
<span x-text="formatDate(link.created_at)"></span>
</div>
</div>
<div class="ml-3">
<div class="ml-3 flex-shrink-0">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
@@ -376,15 +392,39 @@
<p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p>
</div>
<!-- 서적 선택 -->
<!-- 링크 대상 타입 선택 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-3">서적 선택</label>
<label class="block text-sm font-semibold text-gray-700 mb-3">링크 대상 타입</label>
<div class="flex space-x-4">
<label class="flex items-center">
<input type="radio"
x-model="linkForm.target_type"
value="document"
@change="onTargetTypeChange()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">📄 서적 문서</span>
</label>
<label class="flex items-center">
<input type="radio"
x-model="linkForm.target_type"
value="note"
@change="onTargetTypeChange()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">📝 노트북 노트</span>
</label>
</div>
</div>
<!-- 서적/노트북 선택 -->
<div class="mb-6" x-show="linkForm.target_type">
<label class="block text-sm font-semibold text-gray-700 mb-3"
x-text="linkForm.target_type === 'note' ? '노트북 선택' : '서적 선택'"></label>
<select
x-model="linkForm.target_book_id"
@change="loadDocumentsFromBook()"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">서적을 선택하세요</option>
<option value="" x-text="linkForm.target_type === 'note' ? '노트북을 선택하세요' : '서적을 선택하세요'"></option>
<template x-for="book in availableBooks" :key="book.id">
<option :value="book.id" x-text="book.title"></option>
</template>
@@ -404,8 +444,10 @@
:disabled="!linkForm.target_book_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
<option value="">
<span x-show="!linkForm.target_book_id">먼저 서적을 선택하세요</span>
<span x-show="linkForm.target_book_id">문서를 선택하세요</span>
<span x-show="!linkForm.target_book_id"
x-text="linkForm.target_type === 'note' ? '먼저 노트북을 선택하세요' : '먼저 서적을 선택하세요'"></span>
<span x-show="linkForm.target_book_id"
x-text="linkForm.target_type === 'note' ? '노트를 선택하세요' : '문서를 선택하세요'"></span>
</option>
<template x-for="doc in filteredDocuments" :key="doc.id">
<option :value="doc.id" x-text="doc.title"></option>
@@ -501,8 +543,8 @@
<div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors cursor-pointer"
@click="scrollToHighlight(note.highlight.id)">
<!-- 선택된 텍스트 -->
<div class="bg-blue-50 rounded-md p-2 mb-3">
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight.selected_text"></p>
<div class="bg-blue-50 rounded-md p-2 mb-3" x-show="note.highlight?.selected_text">
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight?.selected_text || ''"></p>
</div>
<!-- 메모 내용 -->