diff --git a/backend/migrations/007_add_document_links.sql b/backend/migrations/007_add_document_links.sql new file mode 100644 index 0000000..5579fa9 --- /dev/null +++ b/backend/migrations/007_add_document_links.sql @@ -0,0 +1,34 @@ +-- 문서 링크 테이블 생성 +CREATE TABLE document_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + target_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + selected_text TEXT NOT NULL, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + link_text VARCHAR(500), + description TEXT, + 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 +); + +-- 인덱스 생성 +CREATE INDEX idx_document_links_source_document_id ON document_links(source_document_id); +CREATE INDEX idx_document_links_target_document_id ON document_links(target_document_id); +CREATE INDEX idx_document_links_created_by ON document_links(created_by); +CREATE INDEX idx_document_links_start_offset ON document_links(start_offset); + +-- 업데이트 트리거 생성 +CREATE OR REPLACE FUNCTION update_document_links_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_document_links_updated_at + BEFORE UPDATE ON document_links + FOR EACH ROW + EXECUTE FUNCTION update_document_links_updated_at(); diff --git a/backend/migrations/008_enhance_document_links.sql b/backend/migrations/008_enhance_document_links.sql new file mode 100644 index 0000000..0f0bacf --- /dev/null +++ b/backend/migrations/008_enhance_document_links.sql @@ -0,0 +1,24 @@ +-- 문서 링크 테이블에 고급 기능을 위한 컬럼 추가 + +-- 도착점 텍스트 정보 컬럼 추가 +ALTER TABLE document_links +ADD COLUMN target_text TEXT, +ADD COLUMN target_start_offset INTEGER, +ADD COLUMN target_end_offset INTEGER; + +-- 링크 타입 컬럼 추가 (기본값: document) +ALTER TABLE document_links +ADD COLUMN link_type VARCHAR(20) DEFAULT 'document' NOT NULL; + +-- 기존 데이터의 link_type을 'document'로 설정 (이미 기본값이지만 명시적으로) +UPDATE document_links SET link_type = 'document' WHERE link_type IS NULL; + +-- 인덱스 추가 (성능 향상) +CREATE INDEX idx_document_links_link_type ON document_links(link_type); +CREATE INDEX idx_document_links_target_offset ON document_links(target_document_id, target_start_offset, target_end_offset); + +-- 코멘트 추가 +COMMENT ON COLUMN document_links.target_text IS '대상 문서에서 선택된 텍스트'; +COMMENT ON COLUMN document_links.target_start_offset IS '대상 문서에서 텍스트 시작 위치'; +COMMENT ON COLUMN document_links.target_end_offset IS '대상 문서에서 텍스트 끝 위치'; +COMMENT ON COLUMN document_links.link_type IS '링크 타입: document(전체 문서) 또는 text_fragment(특정 텍스트 부분)'; diff --git a/backend/src/api/routes/document_links.py b/backend/src/api/routes/document_links.py new file mode 100644 index 0000000..cf17276 --- /dev/null +++ b/backend/src/api/routes/document_links.py @@ -0,0 +1,529 @@ +""" +문서 링크 관련 API 엔드포인트 +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from typing import List, Optional +from pydantic import BaseModel +import uuid + +from ...core.database import get_db +from ..dependencies import get_current_active_user +from ...models import User, Document, DocumentLink + +router = APIRouter() + + +# Pydantic 모델들 +class DocumentLinkCreate(BaseModel): + target_document_id: str + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] = None + description: Optional[str] = None + + # 고급 링크 기능 (모두 Optional로 설정) + target_text: Optional[str] = None + target_start_offset: Optional[int] = None + target_end_offset: Optional[int] = None + link_type: Optional[str] = "document" # "document" or "text_fragment" + + +class DocumentLinkUpdate(BaseModel): + 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 DocumentLinkResponse(BaseModel): + id: str + source_document_id: str + target_document_id: str + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] + description: Optional[str] + created_at: str + updated_at: Optional[str] + + # 고급 링크 기능 + target_text: Optional[str] + target_start_offset: Optional[int] + target_end_offset: Optional[int] + link_type: Optional[str] = "document" + + # 대상 문서 정보 + target_document_title: str + target_document_book_id: Optional[str] + + class Config: + from_attributes = True + + +class LinkableDocumentResponse(BaseModel): + id: str + title: str + book_id: Optional[str] + book_title: Optional[str] + sort_order: int + + class Config: + from_attributes = True + + +@router.post("/{document_id}/links", response_model=DocumentLinkResponse) +async def create_document_link( + document_id: str, + link_data: DocumentLinkCreate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 링크 생성""" + # 출발 문서 확인 + result = await db.execute(select(Document).where(Document.id == document_id)) + source_doc = result.scalar_one_or_none() + + if not source_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Source document not found" + ) + + # 권한 확인 + if not source_doc.is_public and source_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to source document" + ) + + # 대상 문서 확인 + result = await db.execute(select(Document).where(Document.id == link_data.target_document_id)) + target_doc = result.scalar_one_or_none() + + if not target_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Target document not found" + ) + + # 대상 문서 권한 확인 + if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to target document" + ) + + # HTML 문서만 링크 가능 + if not target_doc.html_path: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can only link to HTML documents" + ) + + # 링크 생성 + new_link = DocumentLink( + source_document_id=uuid.UUID(document_id), + target_document_id=uuid.UUID(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, + created_by=current_user.id + ) + + db.add(new_link) + await db.commit() + await db.refresh(new_link) + + # 응답 데이터 구성 + return DocumentLinkResponse( + id=str(new_link.id), + source_document_id=str(new_link.source_document_id), + target_document_id=str(new_link.target_document_id), + selected_text=new_link.selected_text, + start_offset=new_link.start_offset, + end_offset=new_link.end_offset, + link_text=new_link.link_text, + description=new_link.description, + # 고급 링크 기능 + target_text=new_link.target_text, + target_start_offset=new_link.target_start_offset, + target_end_offset=new_link.target_end_offset, + link_type=new_link.link_type, + created_at=new_link.created_at.isoformat(), + updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None, + target_document_title=target_doc.title, + target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None + ) + + +@router.get("/{document_id}/links", response_model=List[DocumentLinkResponse]) +async def get_document_links( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서의 모든 링크 조회""" + # 문서 확인 + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # 권한 확인 + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # 링크 조회 (JOIN으로 대상 문서 정보도 함께) + result = await db.execute( + select(DocumentLink, Document) + .join(Document, DocumentLink.target_document_id == Document.id) + .where(DocumentLink.source_document_id == document_id) + .order_by(DocumentLink.start_offset.asc()) + ) + + links_with_targets = result.all() + + # 응답 데이터 구성 + response_links = [] + for link, target_doc in links_with_targets: + response_links.append(DocumentLinkResponse( + id=str(link.id), + source_document_id=str(link.source_document_id), + target_document_id=str(link.target_document_id), + selected_text=link.selected_text, + start_offset=link.start_offset, + end_offset=link.end_offset, + link_text=link.link_text, + description=link.description, + created_at=link.created_at.isoformat(), + updated_at=link.updated_at.isoformat() if link.updated_at else None, + # 고급 링크 기능 (기존 링크는 None일 수 있음) + target_text=getattr(link, 'target_text', None), + target_start_offset=getattr(link, 'target_start_offset', None), + target_end_offset=getattr(link, 'target_end_offset', None), + link_type=getattr(link, 'link_type', 'document'), + # 대상 문서 정보 + target_document_title=target_doc.title, + target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None + )) + + return response_links + + +@router.get("/{document_id}/linkable-documents", response_model=List[LinkableDocumentResponse]) +async def get_linkable_documents( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """링크 가능한 문서 목록 조회 (같은 서적 우선, 전체 HTML 문서)""" + # 현재 문서 확인 + result = await db.execute(select(Document).where(Document.id == document_id)) + current_doc = result.scalar_one_or_none() + + if not current_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # 권한 확인 + if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # 링크 가능한 HTML 문서들 조회 + # 1. 같은 서적의 문서들 (우선순위) + # 2. 다른 서적의 문서들 + from ...models import Book + + query = select(Document, Book).outerjoin(Book, Document.book_id == Book.id).where( + and_( + Document.html_path.isnot(None), # HTML 문서만 + Document.id != document_id, # 자기 자신 제외 + # 권한 확인: 공개 문서이거나 본인이 업로드한 문서 + (Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True) + ) + ).order_by( + # 같은 서적 우선, 그 다음 정렬 순서 + (Document.book_id == current_doc.book_id).desc(), + Document.sort_order.asc().nulls_last(), + Document.created_at.asc() + ) + + result = await db.execute(query) + documents_with_books = result.all() + + # 응답 데이터 구성 + linkable_docs = [] + for doc, book in documents_with_books: + linkable_docs.append(LinkableDocumentResponse( + id=str(doc.id), + title=doc.title, + book_id=str(doc.book_id) if doc.book_id else None, + book_title=book.title if book else None, + sort_order=doc.sort_order or 0 + )) + + return linkable_docs + + +@router.put("/links/{link_id}", response_model=DocumentLinkResponse) +async def update_document_link( + link_id: str, + link_data: DocumentLinkUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 링크 수정""" + # 링크 조회 + result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id)) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Link not found" + ) + + # 권한 확인 (생성자만 수정 가능) + if link.created_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # 대상 문서 변경 시 검증 + if link_data.target_document_id: + result = await db.execute(select(Document).where(Document.id == link_data.target_document_id)) + target_doc = result.scalar_one_or_none() + + if not target_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Target document not found" + ) + + if not target_doc.html_path: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can only link to HTML documents" + ) + + link.target_document_id = uuid.UUID(link_data.target_document_id) + + # 필드 업데이트 + if link_data.link_text is not None: + link.link_text = link_data.link_text + if link_data.description is not None: + link.description = link_data.description + + await db.commit() + await db.refresh(link) + + # 대상 문서 정보 조회 + result = await db.execute(select(Document).where(Document.id == link.target_document_id)) + target_doc = result.scalar_one() + + return DocumentLinkResponse( + id=str(link.id), + source_document_id=str(link.source_document_id), + target_document_id=str(link.target_document_id), + selected_text=link.selected_text, + start_offset=link.start_offset, + end_offset=link.end_offset, + link_text=link.link_text, + description=link.description, + created_at=link.created_at.isoformat(), + updated_at=link.updated_at.isoformat() if link.updated_at else None, + target_document_title=target_doc.title, + target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None + ) + + +@router.delete("/links/{link_id}") +async def delete_document_link( + link_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 링크 삭제""" + # 링크 조회 + result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id)) + link = result.scalar_one_or_none() + + if not link: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Link not found" + ) + + # 권한 확인 (생성자만 삭제 가능) + if link.created_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + await db.delete(link) + await db.commit() + + return {"message": "Link deleted successfully"} + + +# 백링크 관련 모델 +class BacklinkResponse(BaseModel): + id: str + source_document_id: str + source_document_title: str + source_document_book_id: Optional[str] + selected_text: str + start_offset: int + end_offset: int + link_text: Optional[str] + description: Optional[str] + link_type: str + created_at: str + + class Config: + from_attributes = True + + +@router.get("/{document_id}/backlinks", response_model=List[BacklinkResponse]) +async def get_document_backlinks( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서의 백링크 조회 (이 문서를 참조하는 모든 링크)""" + # 문서 존재 확인 + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # 권한 확인 + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # 이 문서를 대상으로 하는 모든 링크 조회 (백링크) + from ...models import Book + + query = select(DocumentLink, Document, Book).join( + Document, DocumentLink.source_document_id == Document.id + ).outerjoin(Book, Document.book_id == Book.id).where( + and_( + DocumentLink.target_document_id == document_id, + # 권한 확인: 공개 문서이거나 본인이 업로드한 문서 + (Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True) + ) + ).order_by(DocumentLink.created_at.desc()) + + result = await db.execute(query) + backlinks = [] + + for link, source_doc, book in result.fetchall(): + backlinks.append(BacklinkResponse( + id=str(link.id), + 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, + 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, + created_at=link.created_at.isoformat() + )) + + return backlinks + + +@router.get("/{document_id}/link-fragments") +async def get_document_link_fragments( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 내 모든 링크된 텍스트 조각 조회 (중복 링크 관리용)""" + # 문서 존재 확인 + result = await db.execute(select(Document).where(Document.id == document_id)) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # 권한 확인 + if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # 이 문서에서 출발하는 모든 링크 조회 + query = select(DocumentLink, Document).join( + Document, DocumentLink.target_document_id == Document.id + ).where( + and_( + DocumentLink.source_document_id == document_id, + # 권한 확인: 공개 문서이거나 본인이 업로드한 문서 + (Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True) + ) + ).order_by(DocumentLink.start_offset.asc()) + + result = await db.execute(query) + fragments = [] + + for link, target_doc in result.fetchall(): + fragments.append({ + "link_id": str(link.id), + "start_offset": link.start_offset, + "end_offset": link.end_offset, + "selected_text": link.selected_text, + "target_document_id": str(link.target_document_id), + "target_document_title": target_doc.title, + "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 + }) + + return fragments diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py index 3831e14..ee9b0da 100644 --- a/backend/src/api/routes/documents.py +++ b/backend/src/api/routes/documents.py @@ -676,6 +676,97 @@ async def download_document( ) +@router.get("/{document_id}/navigation") +async def get_document_navigation( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 네비게이션 정보 조회 (이전/다음 문서)""" + # 현재 문서 조회 + result = await db.execute(select(Document).where(Document.id == document_id)) + current_doc = result.scalar_one_or_none() + + if not current_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # 권한 확인 + if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + navigation_info = { + "current": { + "id": str(current_doc.id), + "title": current_doc.title, + "sort_order": current_doc.sort_order + }, + "previous": None, + "next": None, + "book_info": None + } + + # 서적에 속한 문서인 경우 이전/다음 문서 조회 + if current_doc.book_id: + # 같은 서적의 HTML 문서들만 조회 (PDF 제외) + book_docs_result = await db.execute( + select(Document) + .where( + and_( + Document.book_id == current_doc.book_id, + Document.html_path.isnot(None), # HTML 문서만 + or_(Document.is_public == True, Document.uploaded_by == current_user.id, current_user.is_admin == True) + ) + ) + .order_by(Document.sort_order.asc().nulls_last(), Document.created_at.asc()) + ) + book_docs = book_docs_result.scalars().all() + + # 현재 문서의 인덱스 찾기 + current_index = None + for i, doc in enumerate(book_docs): + if doc.id == current_doc.id: + current_index = i + break + + if current_index is not None: + # 이전 문서 + if current_index > 0: + prev_doc = book_docs[current_index - 1] + navigation_info["previous"] = { + "id": str(prev_doc.id), + "title": prev_doc.title, + "sort_order": prev_doc.sort_order + } + + # 다음 문서 + if current_index < len(book_docs) - 1: + next_doc = book_docs[current_index + 1] + navigation_info["next"] = { + "id": str(next_doc.id), + "title": next_doc.title, + "sort_order": next_doc.sort_order + } + + # 서적 정보 추가 + from ...models.book import Book + book_result = await db.execute(select(Book).where(Book.id == current_doc.book_id)) + book = book_result.scalar_one_or_none() + if book: + navigation_info["book_info"] = { + "id": str(book.id), + "title": book.title, + "author": book.author + } + + return navigation_info + + @router.delete("/{document_id}") async def delete_document( document_id: str, diff --git a/backend/src/main.py b/backend/src/main.py index 102571b..35cf9f6 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -9,7 +9,7 @@ import uvicorn 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 +from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links @asynccontextmanager @@ -52,6 +52,7 @@ app.include_router(book_categories.router, prefix="/api/book-categories", tags=[ app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]) 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.get("/") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index a7ca151..e194446 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -3,15 +3,19 @@ """ from .user import User from .document import Document, Tag +from .book import Book from .highlight import Highlight from .note import Note from .bookmark import Bookmark +from .document_link import DocumentLink __all__ = [ "User", "Document", "Tag", + "Book", "Highlight", "Note", "Bookmark", + "DocumentLink", ] diff --git a/backend/src/models/document_link.py b/backend/src/models/document_link.py new file mode 100644 index 0000000..22ca35d --- /dev/null +++ b/backend/src/models/document_link.py @@ -0,0 +1,53 @@ +""" +문서 링크 모델 +""" +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 DocumentLink(Base): + """문서 링크 테이블""" + __tablename__ = "document_links" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # 링크가 생성된 문서 (출발점) + source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True) + + # 링크 대상 문서 (도착점) + target_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, 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) # 링크 설명 (선택사항) + + # 링크 타입 (전체 문서 vs 특정 부분) + link_type = Column(String(20), default="document", nullable=False) # "document" or "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_document = relationship("Document", foreign_keys=[source_document_id], backref="outgoing_links") + target_document = relationship("Document", foreign_keys=[target_document_id], backref="incoming_links") + creator = relationship("User", backref="created_links") + + def __repr__(self): + return f"" diff --git a/frontend/book-editor.html b/frontend/book-editor.html index 499047b..c6568e4 100644 --- a/frontend/book-editor.html +++ b/frontend/book-editor.html @@ -189,6 +189,6 @@ - + diff --git a/frontend/pdf-manager.html b/frontend/pdf-manager.html index de0d2bf..147005e 100644 --- a/frontend/pdf-manager.html +++ b/frontend/pdf-manager.html @@ -41,7 +41,7 @@ -
+
@@ -54,13 +54,25 @@
+
+
+
+ +
+
+

서적 포함

+

+
+
+
+
-

연결된 PDF

+

HTML 연결

@@ -72,7 +84,7 @@
-

독립 PDF

+

독립 파일

@@ -86,21 +98,26 @@

PDF 파일 목록

-
+
+
@@ -129,26 +146,47 @@

- -
- - - - - -
- + +
+ +
+ + + + + - 연결됨 + HTML 연결됨
-
- + +
+ + + 서적 미분류 + + + + HTML 연결됨 + + 독립 파일
+ + +
+ + + + + + + + +
@@ -179,7 +217,8 @@

PDF 파일이 없습니다

업로드된 PDF 파일이 없습니다 - 연결된 PDF 파일이 없습니다 + 서적에 포함된 PDF 파일이 없습니다 + HTML과 연결된 PDF 파일이 없습니다 독립 PDF 파일이 없습니다

@@ -190,6 +229,6 @@ - + diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index 6e777db..1dad677 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -8,7 +8,7 @@ class DocumentServerAPI { this.token = localStorage.getItem('access_token'); console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL); - console.log('🔧 도커 환경 설정 완료 - 버전 2025012384'); + console.log('🔧 도커 환경 설정 완료 - 버전 2025012415'); } // 토큰 설정 @@ -387,6 +387,11 @@ class DocumentServerAPI { return await this.put(`/books/${bookId}`, bookData); } + // 문서 네비게이션 정보 조회 + async getDocumentNavigation(documentId) { + return await this.get(`/documents/${documentId}/navigation`); + } + async searchBooks(query, limit = 10) { const params = new URLSearchParams({ q: query, limit }); return await this.get(`/books/search/?${params}`); @@ -518,6 +523,36 @@ class DocumentServerAPI { async exportMemoTree(exportData) { return await this.post('/memo-trees/export', exportData); } + + // 문서 링크 관련 API + async createDocumentLink(documentId, linkData) { + return await this.post(`/documents/${documentId}/links`, linkData); + } + + async getDocumentLinks(documentId) { + return await this.get(`/documents/${documentId}/links`); + } + + async getLinkableDocuments(documentId) { + return await this.get(`/documents/${documentId}/linkable-documents`); + } + + async updateDocumentLink(linkId, linkData) { + return await this.put(`/documents/links/${linkId}`, linkData); + } + + async deleteDocumentLink(linkId) { + return await this.delete(`/documents/links/${linkId}`); + } + + // 백링크 관련 API + async getDocumentBacklinks(documentId) { + return await this.get(`/documents/${documentId}/backlinks`); + } + + async getDocumentLinkFragments(documentId) { + return await this.get(`/documents/${documentId}/link-fragments`); + } } // 전역 API 인스턴스 diff --git a/frontend/static/js/book-documents.js b/frontend/static/js/book-documents.js index d23907b..c2cfdda 100644 --- a/frontend/static/js/book-documents.js +++ b/frontend/static/js/book-documents.js @@ -78,7 +78,7 @@ window.bookDocumentsApp = () => ({ this.documents = allDocuments.filter(doc => !doc.book_id && doc.html_path && - !doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 + doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨 ); // 서적 미분류 PDF 문서들 (매칭용) @@ -97,7 +97,7 @@ window.bookDocumentsApp = () => ({ this.documents = allDocuments.filter(doc => doc.book_id === this.bookId && doc.html_path && - !doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 + doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨 ); // 특정 서적의 PDF 문서들 (매칭용) @@ -132,6 +132,8 @@ window.bookDocumentsApp = () => ({ console.log('📚 서적 문서 로드 완료:', this.documents.length, '개'); console.log('📕 사용 가능한 PDF:', this.availablePDFs.length, '개'); + console.log('📎 PDF 목록:', this.availablePDFs.map(pdf => ({ title: pdf.title, book_id: pdf.book_id }))); + console.log('🔍 현재 서적 ID:', this.bookId); // 디버깅: 문서들의 original_filename 확인 console.log('🔍 문서들 확인:'); diff --git a/frontend/static/js/book-editor.js b/frontend/static/js/book-editor.js index b2d2094..371d1eb 100644 --- a/frontend/static/js/book-editor.js +++ b/frontend/static/js/book-editor.js @@ -84,20 +84,49 @@ window.bookEditorApp = () => ({ .filter(doc => doc.book_id === this.bookId && doc.html_path && - !doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 + doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨 ) .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // 순서대로 정렬 console.log('📄 서적 문서들:', this.documents.length, '개'); - // 사용 가능한 PDF 문서들 로드 (PDF 타입 문서들) - // PDF 문서들만 필터링 (폴더 경로 기준) - this.availablePDFs = allDocuments.filter(doc => + // 사용 가능한 PDF 문서들 로드 (현재 서적의 PDF만) + console.log('🔍 현재 서적 ID:', this.bookId); + console.log('🔍 전체 문서 수:', allDocuments.length); + + // PDF 문서들 먼저 필터링 + const allPDFs = allDocuments.filter(doc => doc.pdf_path && doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨 ); + console.log('🔍 전체 PDF 문서 수:', allPDFs.length); - console.log('📎 사용 가능한 PDF:', this.availablePDFs.length, '개'); + // 같은 서적의 PDF 문서들만 필터링 + this.availablePDFs = allPDFs.filter(doc => { + const match = String(doc.book_id) === String(this.bookId); + if (!match && allPDFs.indexOf(doc) < 5) { + console.log(`🔍 PDF "${doc.title}": book_id="${doc.book_id}" (${typeof doc.book_id}) vs bookId="${this.bookId}" (${typeof this.bookId})`); + } + return match; + }); + + console.log('📎 현재 서적의 PDF:', this.availablePDFs.length, '개'); + console.log('📎 현재 서적 PDF 목록:', this.availablePDFs.map(pdf => ({ + title: pdf.title, + book_id: pdf.book_id, + book_title: pdf.book_title + }))); + + // 디버깅: 다른 서적의 PDF들도 확인 + const otherBookPDFs = allPDFs.filter(doc => doc.book_id !== this.bookId); + console.log('🔍 다른 서적의 PDF:', otherBookPDFs.length, '개'); + if (otherBookPDFs.length > 0) { + console.log('🔍 다른 서적 PDF 예시:', otherBookPDFs.slice(0, 3).map(pdf => ({ + title: pdf.title, + book_id: pdf.book_id, + book_title: pdf.book_title + }))); + } } catch (error) { console.error('서적 데이터 로드 실패:', error); diff --git a/frontend/static/js/pdf-manager.js b/frontend/static/js/pdf-manager.js index d48bdbb..1cc3186 100644 --- a/frontend/static/js/pdf-manager.js +++ b/frontend/static/js/pdf-manager.js @@ -5,7 +5,7 @@ window.pdfManagerApp = () => ({ allDocuments: [], loading: false, error: '', - filterType: 'all', // 'all', 'linked', 'standalone' + filterType: 'all', // 'all', 'book', 'linked', 'standalone' // 인증 상태 isAuthenticated: false, @@ -66,7 +66,7 @@ window.pdfManagerApp = () => ({ (doc.html_path === null && doc.pdf_path) // PDF만 업로드된 경우 ); - // 연결 상태 확인 + // 연결 상태 및 서적 정보 확인 this.pdfDocuments.forEach(pdf => { // 이 PDF를 참조하는 다른 문서가 있는지 확인 const linkedDocuments = this.allDocuments.filter(doc => @@ -74,9 +74,27 @@ window.pdfManagerApp = () => ({ ); pdf.isLinked = linkedDocuments.length > 0; pdf.linkedDocuments = linkedDocuments; + + // 서적 정보 추가 (PDF가 속한 서적 또는 연결된 문서의 서적) + if (pdf.book_title) { + // PDF 자체가 서적에 속한 경우 + pdf.book_title = pdf.book_title; + } else if (linkedDocuments.length > 0) { + // 연결된 문서가 있는 경우, 첫 번째 연결 문서의 서적 정보 사용 + const firstLinked = linkedDocuments[0]; + pdf.book_title = firstLinked.book_title; + } }); console.log('📕 PDF 문서들:', this.pdfDocuments.length, '개'); + console.log('📚 서적 포함 PDF:', this.bookPDFs, '개'); + console.log('🔗 HTML 연결 PDF:', this.linkedPDFs, '개'); + console.log('📄 독립 PDF:', this.standalonePDFs, '개'); + + // 디버깅: PDF 서적 정보 확인 + this.pdfDocuments.slice(0, 5).forEach(pdf => { + console.log(`📋 ${pdf.title}: 서적=${pdf.book_title || '없음'}, 연결=${pdf.isLinked ? '예' : '아니오'}`); + }); } catch (error) { console.error('PDF 로드 실패:', error); @@ -90,22 +108,28 @@ window.pdfManagerApp = () => ({ // 필터링된 PDF 목록 get filteredPDFs() { switch (this.filterType) { + case 'book': + return this.pdfDocuments.filter(pdf => pdf.book_title); case 'linked': return this.pdfDocuments.filter(pdf => pdf.isLinked); case 'standalone': - return this.pdfDocuments.filter(pdf => !pdf.isLinked); + return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title); default: return this.pdfDocuments; } }, // 통계 계산 + get bookPDFs() { + return this.pdfDocuments.filter(pdf => pdf.book_title).length; + }, + get linkedPDFs() { return this.pdfDocuments.filter(pdf => pdf.isLinked).length; }, get standalonePDFs() { - return this.pdfDocuments.filter(pdf => !pdf.isLinked).length; + return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title).length; }, // PDF 새로고침 diff --git a/frontend/static/js/viewer.js b/frontend/static/js/viewer.js index 7e08c36..a7c4a47 100644 --- a/frontend/static/js/viewer.js +++ b/frontend/static/js/viewer.js @@ -7,6 +7,7 @@ window.documentViewer = () => ({ error: null, document: null, documentId: null, + navigation: null, // 네비게이션 정보 // 하이라이트 및 메모 highlights: [], @@ -18,9 +19,18 @@ window.documentViewer = () => ({ // 책갈피 bookmarks: [], + // 문서 링크 + documentLinks: [], + linkableDocuments: [], + backlinks: [], + + // 텍스트 선택 모드 플래그 + textSelectorUISetup: false, + // UI 상태 showNotesPanel: false, showBookmarksPanel: false, + showBacklinks: false, activePanel: 'notes', // 검색 @@ -34,10 +44,22 @@ window.documentViewer = () => ({ // 모달 showNoteModal: false, showBookmarkModal: false, + showLinkModal: false, + showNotesModal: false, + showBookmarksModal: false, + showBacklinksModal: false, + showLinksModal: false, + activeFeatureMenu: null, + activeMode: null, // 'link', 'memo', 'bookmark' 등 + textSelectionHandler: null, + availableBooks: [], // 사용 가능한 서적 목록 + filteredDocuments: [], // 필터링된 문서 목록 editingNote: null, editingBookmark: null, + editingLink: null, noteLoading: false, bookmarkLoading: false, + linkLoading: false, // 폼 데이터 noteForm: { @@ -48,6 +70,19 @@ window.documentViewer = () => ({ title: '', description: '' }, + linkForm: { + target_document_id: '', + selected_text: '', + start_offset: 0, + end_offset: 0, + link_text: '', + description: '', + // 고급 링크 기능 + link_type: 'document', + target_text: '', + target_start_offset: 0, + target_end_offset: 0 + }, // 초기화 async init() { @@ -57,6 +92,23 @@ window.documentViewer = () => ({ // URL에서 문서 ID 추출 const urlParams = new URLSearchParams(window.location.search); this.documentId = urlParams.get('id'); + const mode = urlParams.get('mode'); + const isParentWindow = urlParams.get('parent_window') === 'true'; + + console.log('🔍 URL 파싱 결과:', { + documentId: this.documentId, + mode: mode, + parent_window: urlParams.get('parent_window'), + isParentWindow: isParentWindow, + fullUrl: window.location.href + }); + + // 함수들이 제대로 바인딩되었는지 확인 + console.log('🔧 Alpine.js 컴포넌트 로드됨'); + console.log('🔗 activateLinkMode 함수:', typeof this.activateLinkMode); + console.log('📝 activateNoteMode 함수:', typeof this.activateNoteMode); + console.log('🔖 activateBookmarkMode 함수:', typeof this.activateBookmarkMode); + console.log('🎯 toggleFeatureMenu 함수:', typeof this.toggleFeatureMenu); if (!this.documentId) { this.error = '문서 ID가 없습니다'; @@ -64,6 +116,14 @@ window.documentViewer = () => ({ return; } + // 텍스트 선택 모드인 경우 특별 처리 + console.log('🔍 URL 파라미터 확인:', { mode, isParentWindow, documentId: this.documentId }); + if (mode === 'text_selector') { + console.log('🎯 텍스트 선택 모드로 진입'); + await this.initTextSelectorMode(); + return; + } + // 인증 확인 if (!api.token) { window.location.href = '/'; @@ -72,7 +132,12 @@ window.documentViewer = () => ({ try { await this.loadDocument(); + await this.loadNavigation(); await this.loadDocumentData(); + + // URL 파라미터 확인해서 특정 텍스트로 스크롤 + this.checkForTextHighlight(); + } catch (error) { console.error('Failed to load document:', error); this.error = error.message; @@ -221,21 +286,29 @@ window.documentViewer = () => ({ async loadDocumentData() { try { console.log('Loading document data for:', this.documentId); - const [highlights, notes, bookmarks] = await Promise.all([ + const [highlights, notes, bookmarks, documentLinks] = await Promise.all([ api.getDocumentHighlights(this.documentId).catch(() => []), api.getDocumentNotes(this.documentId).catch(() => []), - api.getDocumentBookmarks(this.documentId).catch(() => []) + api.getDocumentBookmarks(this.documentId).catch(() => []), + api.getDocumentLinks(this.documentId).catch(() => []) ]); this.highlights = highlights || []; this.notes = notes || []; this.bookmarks = bookmarks || []; + this.documentLinks = documentLinks || []; console.log('Loaded data:', { highlights: this.highlights.length, notes: this.notes.length, bookmarks: this.bookmarks.length }); // 하이라이트 렌더링 this.renderHighlights(); + // 문서 링크 렌더링 + this.renderDocumentLinks(); + + // 백링크 렌더링 (이 문서를 참조하는 링크들) + this.renderBacklinkHighlights(); + } catch (error) { console.warn('Some document data failed to load, continuing with empty data:', error); this.highlights = []; @@ -1056,19 +1129,117 @@ window.documentViewer = () => ({ toggleLanguage() { this.isKorean = !this.isKorean; - // 문서 내 언어별 요소 토글 (더 범용적으로) - const primaryLangElements = document.querySelectorAll('[lang="ko"], .korean, .kr, .primary-lang'); - const secondaryLangElements = document.querySelectorAll('[lang="en"], .english, .en, [lang="ja"], .japanese, .jp, [lang="zh"], .chinese, .cn, .secondary-lang'); + console.log(`🌐 언어 전환 시작 (isKorean: ${this.isKorean})`); - primaryLangElements.forEach(el => { - el.style.display = this.isKorean ? 'block' : 'none'; - }); + // 문서 내 언어별 요소 토글 (HTML, HEAD, BODY 태그 제외) + const primaryLangElements = document.querySelectorAll('[lang="ko"]:not(html):not(head):not(body), .korean, .kr, .primary-lang'); + const secondaryLangElements = document.querySelectorAll('[lang="en"]:not(html):not(head):not(body), .english, .en, [lang="ja"]:not(html):not(head):not(body), .japanese, .jp, [lang="zh"]:not(html):not(head):not(body), .chinese, .cn, .secondary-lang'); - secondaryLangElements.forEach(el => { - el.style.display = this.isKorean ? 'none' : 'block'; - }); + // 디버깅: 찾은 요소 수 출력 + console.log(`🔍 Primary 요소 수: ${primaryLangElements.length}`); + console.log(`🔍 Secondary 요소 수: ${secondaryLangElements.length}`); - console.log(`🌐 언어 전환됨 (Primary: ${this.isKorean ? '표시' : '숨김'})`); + // 언어별 요소가 있는 경우에만 토글 적용 + if (primaryLangElements.length > 0 || secondaryLangElements.length > 0) { + console.log('✅ 언어별 요소 발견, 토글 적용 중...'); + + primaryLangElements.forEach((el, index) => { + const oldDisplay = el.style.display || getComputedStyle(el).display; + const newDisplay = this.isKorean ? 'block' : 'none'; + console.log(`Primary 요소 ${index + 1}: ${el.tagName}#${el.id || 'no-id'}.${el.className || 'no-class'}`); + console.log(` - 이전 display: ${oldDisplay}`); + console.log(` - 새로운 display: ${newDisplay}`); + console.log(` - 요소 내용 미리보기: "${el.textContent?.substring(0, 50) || 'no-text'}..."`); + console.log(` - 요소 위치:`, el.getBoundingClientRect()); + el.style.display = newDisplay; + console.log(` - 적용 후 display: ${el.style.display}`); + }); + + secondaryLangElements.forEach((el, index) => { + const oldDisplay = el.style.display || getComputedStyle(el).display; + const newDisplay = this.isKorean ? 'none' : 'block'; + console.log(`Secondary 요소 ${index + 1}: ${el.tagName}#${el.id || 'no-id'}.${el.className || 'no-class'}`); + console.log(` - 이전 display: ${oldDisplay}`); + console.log(` - 새로운 display: ${newDisplay}`); + console.log(` - 요소 내용 미리보기: "${el.textContent?.substring(0, 50) || 'no-text'}..."`); + console.log(` - 요소 위치:`, el.getBoundingClientRect()); + el.style.display = newDisplay; + console.log(` - 적용 후 display: ${el.style.display}`); + }); + } else { + // 문서 내 콘텐츠에서 언어별 요소를 더 광범위하게 찾기 + console.log('⚠️ 기본 언어별 요소를 찾을 수 없습니다. 문서 내 콘텐츠를 분석합니다.'); + + // 문서 콘텐츠 영역에서 언어별 요소 찾기 + const contentArea = document.querySelector('#document-content, .document-content, main, .content, #content'); + + if (contentArea) { + console.log('📄 문서 콘텐츠 영역 발견:', contentArea.tagName, contentArea.id || contentArea.className); + + // 콘텐츠 영역의 구조 분석 + console.log('📋 콘텐츠 영역 내 모든 자식 요소들:'); + const allChildren = contentArea.querySelectorAll('*'); + const childrenInfo = Array.from(allChildren).slice(0, 10).map(el => { + return `${el.tagName}${el.id ? '#' + el.id : ''}${el.className ? '.' + el.className.split(' ').join('.') : ''} [lang="${el.lang || 'none'}"]`; + }); + console.log(childrenInfo); + + // 콘텐츠 영역 내에서 언어별 요소 재검색 + const contentPrimary = contentArea.querySelectorAll('[lang="ko"], .korean, .kr, .primary-lang'); + const contentSecondary = contentArea.querySelectorAll('[lang="en"], .english, .en, [lang="ja"], .japanese, .jp, [lang="zh"], .chinese, .cn, .secondary-lang'); + + console.log(`📄 콘텐츠 내 Primary 요소: ${contentPrimary.length}개`); + console.log(`📄 콘텐츠 내 Secondary 요소: ${contentSecondary.length}개`); + + if (contentPrimary.length > 0 || contentSecondary.length > 0) { + // 콘텐츠 영역 내 요소들에 토글 적용 + contentPrimary.forEach(el => { + el.style.display = this.isKorean ? 'block' : 'none'; + }); + contentSecondary.forEach(el => { + el.style.display = this.isKorean ? 'none' : 'block'; + }); + console.log('✅ 콘텐츠 영역 내 언어 토글 적용됨'); + return; + } else { + // 실제 문서 내용에서 언어 패턴 찾기 + console.log('🔍 문서 내용에서 언어 패턴을 찾습니다...'); + + // 문서의 실제 텍스트 내용 확인 + const textContent = contentArea.textContent || ''; + const hasKorean = /[가-힣]/.test(textContent); + const hasEnglish = /[a-zA-Z]/.test(textContent); + + console.log(`📝 문서 언어 분석: 한국어=${hasKorean}, 영어=${hasEnglish}`); + console.log(`📝 문서 내용 미리보기: "${textContent.substring(0, 100)}..."`); + + if (!hasKorean && !hasEnglish) { + console.log('❌ 텍스트 콘텐츠를 찾을 수 없습니다.'); + } + } + } + + // ID 기반 토글 시도 + console.log('🔍 ID 기반 토글을 시도합니다.'); + const koreanContent = document.getElementById('korean-content'); + const englishContent = document.getElementById('english-content'); + + if (koreanContent && englishContent) { + koreanContent.style.display = this.isKorean ? 'block' : 'none'; + englishContent.style.display = this.isKorean ? 'none' : 'block'; + console.log('✅ ID 기반 토글 적용됨'); + } else { + console.log('❌ 언어 전환 가능한 요소를 찾을 수 없습니다.'); + console.log('📋 이 문서는 단일 언어 문서이거나 언어 구분이 없습니다.'); + + // 단일 언어 문서의 경우 아무것도 하지 않음 (흰색 페이지 방지) + console.log('🔄 언어 전환을 되돌립니다.'); + this.isKorean = !this.isKorean; // 상태를 원래대로 되돌림 + return; + } + } + + console.log(`🌐 언어 전환 완료 (Primary: ${this.isKorean ? '표시' : '숨김'})`); }, // 매칭된 PDF 다운로드 @@ -1185,5 +1356,1175 @@ window.documentViewer = () => ({ console.error('❌ 연결된 PDF 다운로드 실패:', error); alert('연결된 PDF 다운로드에 실패했습니다: ' + error.message); } + }, + + // 네비게이션 정보 로드 + async loadNavigation() { + try { + this.navigation = await window.api.getDocumentNavigation(this.documentId); + console.log('📍 네비게이션 정보 로드됨:', this.navigation); + } catch (error) { + console.error('❌ 네비게이션 정보 로드 실패:', error); + // 네비게이션 정보는 필수가 아니므로 에러를 던지지 않음 + } + }, + + // 다른 문서로 네비게이션 + navigateToDocument(documentId) { + if (!documentId) return; + + const currentUrl = new URL(window.location); + currentUrl.searchParams.set('id', documentId); + window.location.href = currentUrl.toString(); + }, + + // 서적 목차로 이동 + goToBookContents() { + if (!this.navigation?.book_info) return; + + window.location.href = `/book-documents.html?bookId=${this.navigation.book_info.id}`; + }, + + // === 문서 링크 관련 함수들 === + + // 문서 링크 생성 + async createDocumentLink() { + console.log('🔗 createDocumentLink 함수 실행'); + + // 이미 설정된 selectedText와 selectedRange 사용 + let selectedText = this.selectedText; + let range = this.selectedRange; + + // 설정되지 않은 경우 현재 선택 확인 + if (!selectedText || !range) { + console.log('📝 기존 선택 없음, 현재 선택 확인'); + const selection = window.getSelection(); + if (!selection.rangeCount || selection.isCollapsed) { + alert('텍스트를 선택한 후 링크를 생성해주세요.'); + return; + } + range = selection.getRangeAt(0); + selectedText = selection.toString().trim(); + } + + console.log('✅ 선택된 텍스트:', selectedText); + + if (selectedText.length === 0) { + alert('텍스트를 선택한 후 링크를 생성해주세요.'); + return; + } + + // 선택된 텍스트의 위치 계산 + const documentContent = document.getElementById('document-content'); + const startOffset = this.getTextOffset(documentContent, range.startContainer, range.startOffset); + const endOffset = startOffset + selectedText.length; + + console.log('📍 텍스트 위치:', { startOffset, endOffset }); + + // 폼 데이터 설정 + this.linkForm = { + target_document_id: '', + selected_text: selectedText, + start_offset: startOffset, + end_offset: endOffset, + link_text: '', + description: '', + link_type: 'document', // 기본값: 문서 전체 링크 + target_text: '', + target_start_offset: null, + target_end_offset: null, + book_scope: 'same', // 기본값: 같은 서적 + target_book_id: '' + }; + + console.log('📋 링크 폼 데이터:', this.linkForm); + + // 링크 가능한 문서 목록 로드 + await this.loadLinkableDocuments(); + + // 모달 열기 + console.log('🔗 링크 모달 열기'); + console.log('🔗 showLinksModal 설정 전:', this.showLinksModal); + this.showLinksModal = true; + this.showLinkModal = true; // 기존 호환성 + console.log('🔗 showLinksModal 설정 후:', this.showLinksModal); + this.editingLink = null; + }, + + // 링크 가능한 문서 목록 로드 + async loadLinkableDocuments() { + try { + this.linkableDocuments = await api.getLinkableDocuments(this.documentId); + console.log('🔗 링크 가능한 문서들:', this.linkableDocuments); + + // 서적 목록도 함께 로드 + await this.loadAvailableBooks(); + + // 기본적으로 같은 서적 문서들 로드 + await this.loadSameBookDocuments(); + } catch (error) { + console.error('❌ 링크 가능한 문서 로드 실패:', error); + this.linkableDocuments = []; + } + }, + + // 문서 링크 저장 (고급 기능 포함) + async saveDocumentLink() { + if (!this.linkForm.target_document_id || !this.linkForm.selected_text) { + alert('필수 정보가 누락되었습니다.'); + return; + } + + // text_fragment 타입인데 target_text가 없으면 경고 + if (this.linkForm.link_type === 'text_fragment' && !this.linkForm.target_text) { + const confirm = window.confirm('특정 부분 링크를 선택했지만 대상 텍스트가 선택되지 않았습니다. 전체 문서 링크로 생성하시겠습니까?'); + if (confirm) { + this.linkForm.link_type = 'document'; + } else { + return; + } + } + + this.linkLoading = true; + + try { + const linkData = { + target_document_id: this.linkForm.target_document_id, + selected_text: this.linkForm.selected_text, + start_offset: this.linkForm.start_offset, + end_offset: this.linkForm.end_offset, + link_text: this.linkForm.link_text || null, + description: this.linkForm.description || null, + link_type: this.linkForm.link_type, + target_text: this.linkForm.target_text || null, + target_start_offset: this.linkForm.target_start_offset || null, + target_end_offset: this.linkForm.target_end_offset || null + }; + + if (this.editingLink) { + await window.api.updateDocumentLink(this.editingLink.id, linkData); + console.log('✅ 링크 수정됨'); + } else { + await window.api.createDocumentLink(this.documentId, linkData); + console.log('✅ 링크 생성됨'); + } + + // 데이터 새로고침 + await this.loadDocumentData(); + this.renderDocumentLinks(); + this.closeLinkModal(); + } catch (error) { + console.error('❌ 링크 저장 실패:', error); + alert('링크 저장에 실패했습니다: ' + error.message); + } finally { + this.linkLoading = false; + } + }, + + // 링크 모달 닫기 (고급 기능 포함) + closeLinkModal() { + this.showLinksModal = false; + this.showLinkModal = false; + this.editingLink = null; + this.linkForm = { + target_document_id: '', + selected_text: '', + start_offset: 0, + end_offset: 0, + link_text: '', + description: '', + link_type: 'document', + target_text: '', + target_start_offset: 0, + target_end_offset: 0, + book_scope: 'same', + target_book_id: '' + }; + + // 필터링된 문서 목록 초기화 + this.filteredDocuments = []; + }, + + // 문서 링크 렌더링 + renderDocumentLinks() { + const documentContent = document.getElementById('document-content'); + if (!documentContent) return; + + // 기존 링크 스타일 제거 + const existingLinks = documentContent.querySelectorAll('.document-link'); + existingLinks.forEach(link => { + const parent = link.parentNode; + parent.replaceChild(document.createTextNode(link.textContent), link); + parent.normalize(); + }); + + // 새 링크 적용 + this.documentLinks.forEach(link => { + this.highlightTextRange( + documentContent, + link.start_offset, + link.end_offset, + 'document-link', + { + 'data-link-id': link.id, + 'data-target-document': link.target_document_id, + 'data-target-title': link.target_document_title, + 'title': `링크: ${link.target_document_title}${link.description ? '\n' + link.description : ''}`, + 'style': 'color: #7C3AED; text-decoration: underline; cursor: pointer; background-color: rgba(124, 58, 237, 0.1);' + } + ); + }); + + // 링크 클릭 이벤트 추가 + const linkElements = documentContent.querySelectorAll('.document-link'); + linkElements.forEach(linkEl => { + linkEl.addEventListener('click', (e) => { + e.preventDefault(); + const linkId = linkEl.getAttribute('data-link-id'); + const targetDocumentId = linkEl.getAttribute('data-target-document'); + const targetTitle = linkEl.getAttribute('data-target-title'); + + // 해당 링크 정보 찾기 + const linkInfo = this.documentLinks.find(link => link.id === linkId); + + if (confirm(`"${targetTitle}" 문서로 이동하시겠습니까?`)) { + this.navigateToLinkedDocument(targetDocumentId, linkInfo); + } + }); + }); + }, + + // 백링크 하이라이트 렌더링 (이 문서를 참조하는 다른 문서의 링크들) + async renderBacklinkHighlights() { + if (!this.documentId) return; + + try { + // 백링크 정보 가져오기 + const backlinks = await api.getDocumentBacklinks(this.documentId); + console.log('🔗 백링크 정보:', backlinks); + + const documentContent = document.getElementById('document-content'); + if (!documentContent) return; + + // 기존 백링크 하이라이트 제거 + const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); + existingBacklinks.forEach(el => { + const parent = el.parentNode; + parent.replaceChild(document.createTextNode(el.textContent), el); + parent.normalize(); + }); + + // 백링크 하이라이트 적용 + backlinks.forEach(backlink => { + // 백링크의 target_text가 있으면 해당 텍스트를 하이라이트 + if (backlink.target_text && backlink.target_start_offset !== undefined && backlink.target_end_offset !== undefined) { + this.highlightTextRange( + documentContent, + backlink.target_start_offset, + backlink.target_end_offset, + 'backlink-highlight', + { + 'data-backlink-id': backlink.id, + 'data-source-document': backlink.source_document_id, + 'data-source-title': backlink.source_document_title, + 'title': `백링크: ${backlink.source_document_title}에서 참조\n"${backlink.selected_text}"`, + 'style': 'background-color: rgba(168, 85, 247, 0.15) !important; border-left: 3px solid #A855F7; padding-left: 2px; cursor: pointer;' + } + ); + } + }); + + // 백링크 클릭 이벤트 추가 + const backlinkElements = documentContent.querySelectorAll('.backlink-highlight'); + backlinkElements.forEach(backlinkEl => { + backlinkEl.addEventListener('click', (e) => { + e.preventDefault(); + const sourceDocumentId = backlinkEl.getAttribute('data-source-document'); + const sourceTitle = backlinkEl.getAttribute('data-source-title'); + + if (confirm(`"${sourceTitle}" 문서로 이동하시겠습니까?`)) { + window.location.href = `/viewer.html?id=${sourceDocumentId}`; + } + }); + }); + + } catch (error) { + console.warn('백링크 하이라이트 렌더링 실패:', error); + } + }, + + // 링크된 문서로 이동 (특정 텍스트 위치 포함) + navigateToLinkedDocument(targetDocumentId, linkInfo) { + let targetUrl = `/viewer.html?id=${targetDocumentId}`; + + // 특정 텍스트 위치가 있는 경우 URL에 추가 + if (linkInfo && linkInfo.link_type === 'text_fragment' && linkInfo.target_text) { + const params = new URLSearchParams({ + highlight_text: linkInfo.target_text, + start_offset: linkInfo.target_start_offset, + end_offset: linkInfo.target_end_offset + }); + targetUrl += `&${params.toString()}`; + } + + console.log('🔗 링크된 문서로 이동:', targetUrl); + window.location.href = targetUrl; + }, + + // 기존 navigateToDocument 함수 (백워드 호환성) + navigateToDocument(documentId) { + window.location.href = `/viewer.html?id=${documentId}`; + }, + + // URL 파라미터에서 특정 텍스트 하이라이트 확인 + checkForTextHighlight() { + const urlParams = new URLSearchParams(window.location.search); + const highlightText = urlParams.get('highlight_text'); + const startOffset = parseInt(urlParams.get('start_offset')); + const endOffset = parseInt(urlParams.get('end_offset')); + + if (highlightText && !isNaN(startOffset) && !isNaN(endOffset)) { + console.log('🎯 링크된 텍스트로 이동:', { highlightText, startOffset, endOffset }); + + // 약간의 지연 후 하이라이트 및 스크롤 (DOM이 완전히 로드된 후) + setTimeout(() => { + this.highlightAndScrollToText(highlightText, startOffset, endOffset); + }, 500); + } + }, + + // 특정 텍스트를 하이라이트하고 스크롤 + highlightAndScrollToText(targetText, startOffset, endOffset) { + console.log('🎯 highlightAndScrollToText 호출됨:', { targetText, startOffset, endOffset }); + + const documentContent = document.getElementById('document-content'); + if (!documentContent) { + console.error('❌ document-content 요소를 찾을 수 없습니다'); + return; + } + + console.log('📄 문서 내용 길이:', documentContent.textContent.length); + + try { + // 임시 하이라이트 적용 + console.log('🎨 하이라이트 적용 시작...'); + const highlightElement = this.highlightTextRange( + documentContent, + startOffset, + endOffset, + 'linked-text-highlight', + { + 'style': 'background-color: #FEF3C7 !important; border: 2px solid #F59E0B; border-radius: 4px; padding: 2px;' + } + ); + + console.log('🔍 하이라이트 요소 결과:', highlightElement); + + if (highlightElement) { + console.log('📐 하이라이트 요소 위치:', highlightElement.getBoundingClientRect()); + + // 해당 요소로 스크롤 + highlightElement.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + + console.log('✅ 링크된 텍스트로 스크롤 완료'); + + // 5초 후 하이라이트 제거 + setTimeout(() => { + const tempHighlight = document.querySelector('.linked-text-highlight'); + if (tempHighlight) { + const parent = tempHighlight.parentNode; + parent.replaceChild(document.createTextNode(tempHighlight.textContent), tempHighlight); + parent.normalize(); + console.log('🗑️ 임시 하이라이트 제거됨'); + } + }, 5000); + } else { + console.warn('⚠️ 링크된 텍스트를 찾을 수 없습니다'); + console.log('🔍 전체 텍스트 미리보기:', documentContent.textContent.substring(startOffset - 50, endOffset + 50)); + } + + } catch (error) { + console.error('❌ 텍스트 하이라이트 실패:', error); + } + }, + + // 텍스트 오프셋 계산 (하이라이트와 동일한 로직) + getTextOffset(container, node, offset) { + let textOffset = 0; + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentNode; + while (currentNode = walker.nextNode()) { + if (currentNode === node) { + return textOffset + offset; + } + textOffset += currentNode.textContent.length; + } + return textOffset; + }, + + // 텍스트 범위 하이라이트 (하이라이트와 동일한 로직, 클래스명만 다름) + highlightTextRange(container, startOffset, endOffset, className, attributes = {}) { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + false + ); + + let currentOffset = 0; + let startNode = null; + let startNodeOffset = 0; + let endNode = null; + let endNodeOffset = 0; + + // 시작과 끝 노드 찾기 + let node; + while (node = walker.nextNode()) { + const nodeLength = node.textContent.length; + + if (!startNode && currentOffset + nodeLength > startOffset) { + startNode = node; + startNodeOffset = startOffset - currentOffset; + } + + if (currentOffset + nodeLength >= endOffset) { + endNode = node; + endNodeOffset = endOffset - currentOffset; + break; + } + + currentOffset += nodeLength; + } + + if (!startNode || !endNode) return; + + try { + const range = document.createRange(); + range.setStart(startNode, startNodeOffset); + range.setEnd(endNode, endNodeOffset); + + const span = document.createElement('span'); + span.className = className; + + // 속성 추가 + Object.entries(attributes).forEach(([key, value]) => { + span.setAttribute(key, value); + }); + + range.surroundContents(span); + return span; // 생성된 요소 반환 + } catch (error) { + console.warn('링크 렌더링 실패:', error); + return null; + } + }, + + // 백링크 관련 메서드들 + async loadBacklinks() { + if (!this.documentId) return; + + try { + console.log('🔗 백링크 로드 중...'); + this.backlinks = await window.api.getDocumentBacklinks(this.documentId); + console.log(`✅ 백링크 ${this.backlinks.length}개 로드됨`); + } catch (error) { + console.error('백링크 로드 실패:', error); + this.backlinks = []; + } + }, + + navigateToBacklink(backlink) { + // 백링크의 출발 문서로 이동 + const url = `/viewer.html?id=${backlink.source_document_id}&from=backlink&highlight=${backlink.start_offset}-${backlink.end_offset}`; + window.location.href = url; + }, + + // 고급 링크 기능 메서드들 + onTargetDocumentChange() { + // 대상 문서가 변경되면 target_text 초기화 + this.linkForm.target_text = ''; + this.linkForm.target_start_offset = 0; + this.linkForm.target_end_offset = 0; + }, + + openTargetDocumentSelector() { + console.log('🎯 openTargetDocumentSelector 함수 호출됨!'); + console.log('📋 현재 linkForm.target_document_id:', this.linkForm.target_document_id); + + if (!this.linkForm.target_document_id) { + alert('먼저 대상 문서를 선택해주세요.'); + return; + } + + // 새 창에서 대상 문서 열기 (텍스트 선택 모드 전용 페이지) + const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}`; + console.log('🚀 텍스트 선택 창 열기:', targetUrl); + const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes'); + + if (!popup) { + console.error('❌ 팝업 창이 차단되었습니다!'); + alert('팝업 창이 차단되었습니다. 브라우저 설정에서 팝업을 허용해주세요.'); + } else { + console.log('✅ 팝업 창이 성공적으로 열렸습니다'); + } + + // 팝업에서 텍스트 선택 완료 시 메시지 수신 + window.addEventListener('message', (event) => { + if (event.data.type === 'TEXT_SELECTED') { + this.linkForm.target_text = event.data.selectedText; + this.linkForm.target_start_offset = event.data.startOffset; + this.linkForm.target_end_offset = event.data.endOffset; + console.log('🎯 대상 텍스트 선택됨:', event.data); + popup.close(); + } + }, { once: true }); + }, + + // 텍스트 선택 모드 초기화 + async initTextSelectorMode() { + console.log('🎯 텍스트 선택 모드로 초기화 중...'); + + // Alpine.js 완전 차단 + window.Alpine = { + start: () => console.log('Alpine.js 초기화 차단됨'), + data: () => ({}), + directive: () => {}, + magic: () => {}, + store: () => ({}), + version: '3.0.0' + }; + + // 기존 Alpine 인스턴스 제거 + if (window.Alpine && window.Alpine.stop) { + window.Alpine.stop(); + } + + // 인증 확인 + if (!api.token) { + window.location.href = '/'; + return; + } + + try { + // 문서만 로드 (다른 데이터는 불필요) + await this.loadDocument(); + + // UI 설정 + console.log('🔧 텍스트 선택 모드 UI 설정 시작'); + this.setupTextSelectorUI(); + console.log('✅ 텍스트 선택 모드 UI 설정 완료'); + + } catch (error) { + console.error('텍스트 선택 모드 초기화 실패:', error); + this.error = '문서를 불러올 수 없습니다: ' + error.message; + } finally { + this.loading = false; + } + }, + + // 텍스트 선택 모드 UI 설정 + setupTextSelectorUI() { + console.log('🔧 setupTextSelectorUI 함수 실행됨'); + + // 이미 설정되었는지 확인 + if (this.textSelectorUISetup) { + console.log('⚠️ 텍스트 선택 모드 UI가 이미 설정됨 - 중복 실행 방지'); + return; + } + + // 헤더를 텍스트 선택 모드용으로 변경 + const header = document.querySelector('header'); + console.log('📋 헤더 요소 찾기:', header); + + if (header) { + console.log('🎨 헤더 HTML 교체 중...'); + + // 기존 Alpine 속성 제거 + header.removeAttribute('x-data'); + header.removeAttribute('x-init'); + + header.innerHTML = ` +
+
+
+ +
+

텍스트 선택 모드

+

연결하고 싶은 텍스트를 선택하세요

+
+
+
+ + +
+
+
+ `; + + // 헤더가 다시 변경되지 않도록 보호 + header.setAttribute('data-text-selector-mode', 'true'); + console.log('🔒 헤더 보호 설정 완료'); + + // 실제 헤더 내용 확인 + console.log('📄 헤더 HTML 확인:', header.innerHTML.substring(0, 200) + '...'); + + // 언어전환 버튼 확인 + const langBtn = header.querySelector('#language-toggle-selector'); + console.log('🌐 언어전환 버튼 찾기:', langBtn); + + // 취소 버튼 확인 + const closeBtn = header.querySelector('button[onclick*="window.close"]'); + console.log('❌ 취소 버튼 찾기:', closeBtn); + + // 헤더 변경 감지 + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList' || mutation.type === 'attributes') { + console.log('⚠️ 헤더가 변경되었습니다!', mutation); + console.log('🔍 변경 후 헤더 내용:', header.innerHTML.substring(0, 100) + '...'); + } + }); + }); + + observer.observe(header, { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: true + }); + } + + // 사이드 패널 숨기기 + const aside = document.querySelector('aside'); + if (aside) { + aside.style.display = 'none'; + } + + // 메인 컨텐츠 영역 조정 + const main = document.querySelector('main'); + if (main) { + main.style.marginRight = '0'; + main.classList.add('text-selector-mode'); + } + + // 문서 콘텐츠에 텍스트 선택 이벤트 추가 + const documentContent = document.getElementById('document-content'); + if (documentContent) { + documentContent.addEventListener('mouseup', this.handleTextSelectionForLinking.bind(this)); + + // 선택 가능한 영역임을 시각적으로 표시 + documentContent.style.cursor = 'crosshair'; + documentContent.style.userSelect = 'text'; + + // 안내 메시지 추가 + const guideDiv = document.createElement('div'); + guideDiv.className = 'bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6'; + guideDiv.innerHTML = ` +
+ +
+

텍스트 선택 방법

+

+ 마우스로 연결하고 싶은 텍스트를 드래그하여 선택하세요. + 선택이 완료되면 자동으로 부모 창으로 전달됩니다. +

+
+
+ `; + documentContent.parentNode.insertBefore(guideDiv, documentContent); + } + + // Alpine.js 컴포넌트 비활성화 (텍스트 선택 모드에서는 불필요) + const alpineElements = document.querySelectorAll('[x-data]'); + alpineElements.forEach(el => { + el.removeAttribute('x-data'); + }); + + // 설정 완료 플래그 + this.textSelectorUISetup = true; + console.log('✅ 텍스트 선택 모드 UI 설정 완료'); + }, + + // 텍스트 선택 모드에서의 텍스트 선택 처리 + handleTextSelectionForLinking() { + const selection = window.getSelection(); + if (!selection.rangeCount || selection.isCollapsed) return; + + const range = selection.getRangeAt(0); + const selectedText = selection.toString().trim(); + + if (selectedText.length < 3) { + alert('최소 3글자 이상 선택해주세요.'); + return; + } + + if (selectedText.length > 500) { + alert('선택된 텍스트가 너무 깁니다. 500자 이하로 선택해주세요.'); + return; + } + + // 텍스트 오프셋 계산 + const documentContent = document.getElementById('document-content'); + const { startOffset, endOffset } = this.getTextOffset(documentContent, range); + + console.log('🎯 텍스트 선택됨:', { + selectedText, + startOffset, + endOffset + }); + + // 선택 확인 UI 표시 + this.showTextSelectionConfirm(selectedText, startOffset, endOffset); + }, + + // 텍스트 선택 확인 UI + showTextSelectionConfirm(selectedText, startOffset, endOffset) { + // 기존 확인 UI 제거 + const existingConfirm = document.querySelector('.text-selection-confirm'); + if (existingConfirm) { + existingConfirm.remove(); + } + + // 확인 UI 생성 + const confirmDiv = document.createElement('div'); + confirmDiv.className = 'text-selection-confirm fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-white rounded-lg shadow-2xl border p-6 max-w-md z-50'; + + // 텍스트 미리보기 (안전하게 처리) + const previewText = selectedText.length > 100 ? selectedText.substring(0, 100) + '...' : selectedText; + + confirmDiv.innerHTML = ` +
+
+ +
+

텍스트가 선택되었습니다

+
+

+
+
+ + +
+
+ `; + + // 텍스트 안전하게 설정 + const previewElement = confirmDiv.querySelector('#selected-text-preview'); + previewElement.textContent = `"${previewText}"`; + + // 이벤트 리스너 추가 + const reselectBtn = confirmDiv.querySelector('#reselect-btn'); + const confirmBtn = confirmDiv.querySelector('#confirm-selection-btn'); + + reselectBtn.addEventListener('click', () => { + confirmDiv.remove(); + }); + + confirmBtn.addEventListener('click', () => { + this.confirmTextSelection(selectedText, startOffset, endOffset); + }); + + document.body.appendChild(confirmDiv); + + // 10초 후 자동 제거 (사용자가 선택하지 않은 경우) + setTimeout(() => { + if (document.contains(confirmDiv)) { + confirmDiv.remove(); + } + }, 10000); + }, + + // 텍스트 선택 확정 + confirmTextSelection(selectedText, startOffset, endOffset) { + // 부모 창에 선택된 텍스트 정보 전달 + if (window.opener) { + window.opener.postMessage({ + type: 'TEXT_SELECTED', + selectedText: selectedText, + startOffset: startOffset, + endOffset: endOffset + }, '*'); + + console.log('✅ 부모 창에 텍스트 선택 정보 전달됨'); + + // 성공 메시지 표시 후 창 닫기 + const confirmDiv = document.querySelector('.text-selection-confirm'); + if (confirmDiv) { + confirmDiv.innerHTML = ` +
+
+ +
+

선택 완료!

+

창이 자동으로 닫힙니다...

+
+ `; + + setTimeout(() => { + window.close(); + }, 1500); + } + } else { + alert('부모 창을 찾을 수 없습니다.'); + } + }, + + // 기능 메뉴 토글 + toggleFeatureMenu(feature) { + if (this.activeFeatureMenu === feature) { + this.activeFeatureMenu = null; + } else { + this.activeFeatureMenu = feature; + } + }, + + // 링크 모드 활성화 + activateLinkMode() { + console.log('🔗 링크 모드 활성화 - activateLinkMode 함수 실행됨'); + + // 이미 선택된 텍스트가 있는지 확인 + const selection = window.getSelection(); + console.log('📝 현재 선택 상태:', { + rangeCount: selection.rangeCount, + isCollapsed: selection.isCollapsed, + selectedText: selection.toString() + }); + + if (selection.rangeCount > 0 && !selection.isCollapsed) { + const selectedText = selection.toString().trim(); + if (selectedText.length > 0) { + console.log('✅ 선택된 텍스트 발견:', selectedText); + this.selectedText = selectedText; + this.selectedRange = selection.getRangeAt(0); + console.log('🔗 createDocumentLink 함수 호출 예정'); + this.createDocumentLink(); + return; + } + } + + // 선택된 텍스트가 없으면 선택 모드 활성화 + console.log('📝 텍스트 선택 모드 활성화'); + this.activeMode = 'link'; + this.showSelectionMessage('텍스트를 선택하세요.'); + + // 기존 리스너 제거 후 새로 추가 + this.removeTextSelectionListener(); + this.textSelectionHandler = this.handleTextSelection.bind(this); + document.addEventListener('mouseup', this.textSelectionHandler); + }, + + // 메모 모드 활성화 + activateNoteMode() { + console.log('📝 메모 모드 활성화'); + + // 이미 선택된 텍스트가 있는지 확인 + const selection = window.getSelection(); + if (selection.rangeCount > 0 && !selection.isCollapsed) { + const selectedText = selection.toString().trim(); + if (selectedText.length > 0) { + console.log('✅ 선택된 텍스트 발견:', selectedText); + this.selectedText = selectedText; + this.selectedRange = selection.getRangeAt(0); + this.createNoteFromSelection(); + return; + } + } + + // 선택된 텍스트가 없으면 선택 모드 활성화 + console.log('📝 텍스트 선택 모드 활성화'); + this.activeMode = 'memo'; + this.showSelectionMessage('텍스트를 선택하세요.'); + + // 기존 리스너 제거 후 새로 추가 + this.removeTextSelectionListener(); + this.textSelectionHandler = this.handleTextSelection.bind(this); + document.addEventListener('mouseup', this.textSelectionHandler); + }, + + // 책갈피 모드 활성화 + activateBookmarkMode() { + console.log('🔖 책갈피 모드 활성화'); + + // 이미 선택된 텍스트가 있는지 확인 + const selection = window.getSelection(); + if (selection.rangeCount > 0 && !selection.isCollapsed) { + const selectedText = selection.toString().trim(); + if (selectedText.length > 0) { + console.log('✅ 선택된 텍스트 발견:', selectedText); + this.selectedText = selectedText; + this.selectedRange = selection.getRangeAt(0); + this.createBookmarkFromSelection(); + return; + } + } + + // 선택된 텍스트가 없으면 선택 모드 활성화 + console.log('📝 텍스트 선택 모드 활성화'); + this.activeMode = 'bookmark'; + this.showSelectionMessage('텍스트를 선택하세요.'); + + // 기존 리스너 제거 후 새로 추가 + this.removeTextSelectionListener(); + this.textSelectionHandler = this.handleTextSelection.bind(this); + document.addEventListener('mouseup', this.textSelectionHandler); + }, + + // 텍스트 선택 리스너 제거 + removeTextSelectionListener() { + if (this.textSelectionHandler) { + document.removeEventListener('mouseup', this.textSelectionHandler); + this.textSelectionHandler = null; + } + }, + + // 텍스트 선택 처리 + handleTextSelection(event) { + console.log('🎯 텍스트 선택 이벤트 발생'); + + const selection = window.getSelection(); + if (selection.rangeCount > 0 && !selection.isCollapsed) { + const range = selection.getRangeAt(0); + const selectedText = selection.toString().trim(); + + console.log('📝 선택된 텍스트:', selectedText); + + if (selectedText.length > 0) { + this.selectedText = selectedText; + this.selectedRange = range; + + // 선택 모드에 따라 다른 동작 + console.log('🎯 현재 모드:', this.activeMode); + + if (this.activeMode === 'link') { + console.log('🔗 링크 생성 실행'); + this.createDocumentLink(); + } else if (this.activeMode === 'memo') { + console.log('📝 메모 생성 실행'); + this.createNoteFromSelection(); + } else if (this.activeMode === 'bookmark') { + console.log('🔖 책갈피 생성 실행'); + this.createBookmarkFromSelection(); + } + + // 모드 해제 + this.activeMode = null; + this.hideSelectionMessage(); + this.removeTextSelectionListener(); + } + } + }, + + // 선택 메시지 표시 + showSelectionMessage(message) { + // 기존 메시지 제거 + const existingMessage = document.querySelector('.selection-message'); + if (existingMessage) { + existingMessage.remove(); + } + + // 새 메시지 생성 + const messageDiv = document.createElement('div'); + messageDiv.className = 'selection-message fixed top-20 left-1/2 transform -translate-x-1/2 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; + messageDiv.textContent = message; + document.body.appendChild(messageDiv); + }, + + // 선택 메시지 숨기기 + hideSelectionMessage() { + const existingMessage = document.querySelector('.selection-message'); + if (existingMessage) { + existingMessage.remove(); + } + }, + + // 링크 생성 UI 표시 + showLinkCreationUI() { + this.createDocumentLink(); + }, + + // 선택된 텍스트로 메모 생성 + async createNoteFromSelection() { + if (!this.selectedText || !this.selectedRange) return; + + try { + // 하이라이트 생성 + const highlightData = await this.createHighlight(this.selectedText, this.selectedRange, '#FFFF00'); + + // 메모 내용 입력받기 + const content = prompt('메모 내용을 입력하세요:', ''); + if (content === null) { + // 취소한 경우 하이라이트 제거 + const highlightElement = document.querySelector(`[data-highlight-id="${highlightData.id}"]`); + if (highlightElement) { + const parent = highlightElement.parentNode; + parent.replaceChild(document.createTextNode(highlightElement.textContent), highlightElement); + parent.normalize(); + } + return; + } + + // 메모 생성 + const noteData = { + highlight_id: highlightData.id, + content: content + }; + + const note = await api.createNote(this.documentId, noteData); + + // 데이터 새로고침 + await this.loadNotes(); + + alert('메모가 생성되었습니다.'); + + } catch (error) { + console.error('메모 생성 실패:', error); + alert('메모 생성에 실패했습니다.'); + } + }, + + // 선택된 텍스트로 책갈피 생성 + async createBookmarkFromSelection() { + if (!this.selectedText || !this.selectedRange) return; + + try { + // 하이라이트 생성 (책갈피는 주황색) + const highlightData = await this.createHighlight(this.selectedText, this.selectedRange, '#FFA500'); + + // 책갈피 생성 + const bookmarkData = { + highlight_id: highlightData.id, + title: this.selectedText.substring(0, 50) + (this.selectedText.length > 50 ? '...' : '') + }; + + const bookmark = await api.createBookmark(this.documentId, bookmarkData); + + // 데이터 새로고침 + await this.loadBookmarks(); + + alert('책갈피가 생성되었습니다.'); + + } catch (error) { + console.error('책갈피 생성 실패:', error); + alert('책갈피 생성에 실패했습니다.'); + } + }, + + // 대상 선택 초기화 + resetTargetSelection() { + this.linkForm.target_book_id = ''; + this.linkForm.target_document_id = ''; + this.linkForm.target_text = ''; + this.linkForm.target_start_offset = null; + this.linkForm.target_end_offset = null; + this.filteredDocuments = []; + + // 같은 서적인 경우 현재 서적의 문서들 로드 + if (this.linkForm.book_scope === 'same') { + this.loadSameBookDocuments(); + } + }, + + // 같은 서적의 문서들 로드 + async loadSameBookDocuments() { + try { + if (this.navigation?.book_info?.id) { + // 현재 서적의 문서들만 가져오기 + const allDocuments = await api.getLinkableDocuments(this.documentId); + this.filteredDocuments = allDocuments.filter(doc => + doc.book_id === this.navigation.book_info.id && doc.id !== this.documentId + ); + console.log('📚 같은 서적 문서들:', this.filteredDocuments); + } else { + // 서적 정보가 없으면 모든 문서 + this.filteredDocuments = await api.getLinkableDocuments(this.documentId); + } + } catch (error) { + console.error('같은 서적 문서 로드 실패:', error); + this.filteredDocuments = []; + } + }, + + // 서적별 문서 로드 + async loadDocumentsFromBook() { + try { + if (this.linkForm.target_book_id) { + // 선택된 서적의 문서들만 가져오기 + const allDocuments = await api.getLinkableDocuments(this.documentId); + this.filteredDocuments = allDocuments.filter(doc => + doc.book_id === this.linkForm.target_book_id + ); + console.log('📚 선택된 서적 문서들:', this.filteredDocuments); + } else { + this.filteredDocuments = []; + } + + // 문서 선택 초기화 + this.linkForm.target_document_id = ''; + } catch (error) { + console.error('서적별 문서 로드 실패:', error); + this.filteredDocuments = []; + } + }, + + // 사용 가능한 서적 목록 로드 + async loadAvailableBooks() { + try { + console.log('📚 서적 목록 로딩 시작...'); + + // 문서 목록에서 서적 정보 추출 + const allDocuments = await api.getLinkableDocuments(this.documentId); + console.log('📄 모든 문서들:', allDocuments); + + // 서적별로 그룹화 + const bookMap = new Map(); + allDocuments.forEach(doc => { + if (doc.book_id && doc.book_title) { + bookMap.set(doc.book_id, { + id: doc.book_id, + title: doc.book_title + }); + } + }); + + // 현재 서적 제외 + const currentBookId = this.navigation?.book_info?.id; + if (currentBookId) { + bookMap.delete(currentBookId); + } + + this.availableBooks = Array.from(bookMap.values()); + console.log('📚 사용 가능한 서적들:', this.availableBooks); + console.log('🔍 현재 서적 ID:', currentBookId); + } catch (error) { + console.error('서적 목록 로드 실패:', error); + this.availableBooks = []; + } + }, + + // 선택된 서적 제목 가져오기 + getSelectedBookTitle() { + const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id); + return selectedBook ? selectedBook.title : ''; } }); diff --git a/frontend/text-selector.html b/frontend/text-selector.html new file mode 100644 index 0000000..614f44a --- /dev/null +++ b/frontend/text-selector.html @@ -0,0 +1,495 @@ + + + + + + 텍스트 선택 모드 + + + + + +
+
+
+ +
+

텍스트 선택 모드

+

연결하고 싶은 텍스트를 선택하세요

+
+
+
+ + +
+
+
+ + +
+
+
+ +
+

텍스트 선택 방법

+

+ 마우스로 연결하고 싶은 텍스트를 드래그하여 선택하세요. + 선택이 완료되면 자동으로 부모 창으로 전달됩니다. +

+
+
+
+
+ + +
+
+
+ +

문서를 불러오는 중...

+
+ +
+
+ + + + + + + + + diff --git a/frontend/viewer.html b/frontend/viewer.html index 8434618..0d6745c 100644 --- a/frontend/viewer.html +++ b/frontend/viewer.html @@ -14,107 +14,254 @@
- -
-
- -
- -
- -
-

+ + +
+ + +
- -
-

+ + +
+

+
+ + +
+
+ + +
- -
- + +
+ +
+ + + + + + + + +
+ + +
+ + + + + + + + + + + + +
+
+ + +
+ +
+ 하이라이트 + + + + +
+ +
- -
- 하이라이트 - - - - -
- - -
- - - - - -
-
- - -
+
- - + + +
+ + +
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+ + +
- -
- - + +
@@ -122,11 +269,10 @@
-
+
-
-
+
+
@@ -143,310 +289,461 @@
-
+
- - -
- -
-
-
-

-
- - -
-

선택된 텍스트:

-

+ + +
+ + + + +
- -
-
- - -
- -
- - -
- -
- - -
-
- -
-
-
-

-
- -
-
- - + + +
+ +
+

선택된 텍스트

+

+
+ + +
+ +
+ + +
-
- - + +
+ +
+ +
+ + +

+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+

선택된 대상 텍스트:

+

+
+
+
+ + +
+ + +
+ +
+ + +
- -
- +
+
+
+ + +
+ +
+
+

+ + 메모 +

+ +
+
+ + + + +
+ +
+
+
+
+ + +
+ +
+
+

+ + 책갈피 +

+ +
+
+ + + + +
+ +
+
+
+
+ + +
+ +
+
+

+ + 백링크 +

+ +
+
+ + + + +
+ +
+
- - + + diff --git a/uploads/pdfs/624c4f51-6f69-4987-bd31-3ef4a82c5a6a.pdf b/uploads/pdfs/624c4f51-6f69-4987-bd31-3ef4a82c5a6a.pdf new file mode 100644 index 0000000..a148df0 Binary files /dev/null and b/uploads/pdfs/624c4f51-6f69-4987-bd31-3ef4a82c5a6a.pdf differ