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:
74
backend/migrations/011_create_note_links.sql
Normal file
74
backend/migrations/011_create_note_links.sql
Normal 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';
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
270
backend/src/api/routes/note_documents.py
Normal file
270
backend/src/api/routes/note_documents.py
Normal 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 ""
|
||||
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"}
|
||||
@@ -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=["노트 메모"])
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
57
backend/src/models/note_link.py
Normal file
57
backend/src/models/note_link.py
Normal 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})>"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('✅ 백링크 새로고침 완료');
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 메모 내용 -->
|
||||
|
||||
Reference in New Issue
Block a user