diff --git a/backend/migrations/011_create_note_links.sql b/backend/migrations/011_create_note_links.sql new file mode 100644 index 0000000..2a3e48a --- /dev/null +++ b/backend/migrations/011_create_note_links.sql @@ -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'; diff --git a/backend/src/api/routes/document_links.py b/backend/src/api/routes/document_links.py index 7d0199b..da016c9 100644 --- a/backend/src/api/routes/document_links.py +++ b/backend/src/api/routes/document_links.py @@ -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 diff --git a/backend/src/api/routes/note_documents.py b/backend/src/api/routes/note_documents.py new file mode 100644 index 0000000..d074c70 --- /dev/null +++ b/backend/src/api/routes/note_documents.py @@ -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 "" diff --git a/backend/src/api/routes/note_links.py b/backend/src/api/routes/note_links.py new file mode 100644 index 0000000..ef372f8 --- /dev/null +++ b/backend/src/api/routes/note_links.py @@ -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"} diff --git a/backend/src/main.py b/backend/src/main.py index 2195666..04dbdad 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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=["노트 메모"]) diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index 21e0049..0d7090a 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -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" ] diff --git a/backend/src/models/note_link.py b/backend/src/models/note_link.py new file mode 100644 index 0000000..ea2c900 --- /dev/null +++ b/backend/src/models/note_link.py @@ -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"" diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index 2788ea1..bee881a 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -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) { diff --git a/frontend/static/js/viewer/features/link-manager.js b/frontend/static/js/viewer/features/link-manager.js index 67ea192..33a38bc 100644 --- a/frontend/static/js/viewer/features/link-manager.js +++ b/frontend/static/js/viewer/features/link-manager.js @@ -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); } diff --git a/frontend/static/js/viewer/viewer-core.js b/frontend/static/js/viewer/viewer-core.js index 9af4386..7fa700b 100644 --- a/frontend/static/js/viewer/viewer-core.js +++ b/frontend/static/js/viewer/viewer-core.js @@ -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('✅ 백링크 새로고침 완료'); diff --git a/frontend/text-selector.html b/frontend/text-selector.html index 8826bf4..7d708c3 100644 --- a/frontend/text-selector.html +++ b/frontend/text-selector.html @@ -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' diff --git a/frontend/viewer.html b/frontend/viewer.html index 8c506ec..f7f528e 100644 --- a/frontend/viewer.html +++ b/frontend/viewer.html @@ -311,29 +311,45 @@