feat: PDF 매칭 필터링 및 서적 정보 UI 개선

- 서적 편집 페이지에서 PDF 매칭 드롭다운이 현재 서적의 PDF만 표시하도록 수정
- PDF 관리 페이지에 서적 정보 표시 UI 추가
- 타입 안전한 비교로 book_id 필터링 개선
- PDF 통계 카드에 서적별 분류 추가
- 필터 기능에 '서적 포함' 옵션 추가
- 디버깅 로그 추가로 문제 추적 개선

주요 변경사항:
- book-editor.js: String() 타입 변환으로 안전한 book_id 비교
- pdf-manager.html/js: 서적 정보 배지 및 통계 카드 추가
- book-documents.js: HTML 문서 필터링 로직 개선
This commit is contained in:
Hyungi Ahn
2025-08-26 15:32:46 +09:00
parent 04ae64fc4d
commit 8d7f4c04bb
17 changed files with 3398 additions and 400 deletions

View File

@@ -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();

View File

@@ -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(특정 텍스트 부분)';

View File

@@ -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

View File

@@ -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}") @router.delete("/{document_id}")
async def delete_document( async def delete_document(
document_id: str, document_id: str,

View File

@@ -9,7 +9,7 @@ import uvicorn
from .core.config import settings from .core.config import settings
from .core.database import init_db 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 @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(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"]) app.include_router(search.router, prefix="/api/search", tags=["검색"])
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"]) app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
@app.get("/") @app.get("/")

View File

@@ -3,15 +3,19 @@
""" """
from .user import User from .user import User
from .document import Document, Tag from .document import Document, Tag
from .book import Book
from .highlight import Highlight from .highlight import Highlight
from .note import Note from .note import Note
from .bookmark import Bookmark from .bookmark import Bookmark
from .document_link import DocumentLink
__all__ = [ __all__ = [
"User", "User",
"Document", "Document",
"Tag", "Tag",
"Book",
"Highlight", "Highlight",
"Note", "Note",
"Bookmark", "Bookmark",
"DocumentLink",
] ]

View File

@@ -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"<DocumentLink(id='{self.id}', text='{self.selected_text[:50]}...')>"

View File

@@ -189,6 +189,6 @@
<script src="/static/js/api.js?v=2025012384"></script> <script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script> <script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script> <script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/book-editor.js?v=2025012401"></script> <script src="/static/js/book-editor.js?v=2025012461"></script>
</body> </body>
</html> </html>

View File

@@ -41,7 +41,7 @@
</div> </div>
<!-- 통계 카드 --> <!-- 통계 카드 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border p-6"> <div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-4"> <div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mr-4">
@@ -54,13 +54,25 @@
</div> </div>
</div> </div>
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-book text-blue-600 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">서적 포함</h3>
<p class="text-2xl font-bold text-blue-600" x-text="bookPDFs"></p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border p-6"> <div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4"> <div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-link text-green-600 text-xl"></i> <i class="fas fa-link text-green-600 text-xl"></i>
</div> </div>
<div> <div>
<h3 class="text-lg font-semibold text-gray-900">연결된 PDF</h3> <h3 class="text-lg font-semibold text-gray-900">HTML 연결</h3>
<p class="text-2xl font-bold text-green-600" x-text="linkedPDFs"></p> <p class="text-2xl font-bold text-green-600" x-text="linkedPDFs"></p>
</div> </div>
</div> </div>
@@ -72,7 +84,7 @@
<i class="fas fa-unlink text-yellow-600 text-xl"></i> <i class="fas fa-unlink text-yellow-600 text-xl"></i>
</div> </div>
<div> <div>
<h3 class="text-lg font-semibold text-gray-900">독립 PDF</h3> <h3 class="text-lg font-semibold text-gray-900">독립 파일</h3>
<p class="text-2xl font-bold text-yellow-600" x-text="standalonePDFs"></p> <p class="text-2xl font-bold text-yellow-600" x-text="standalonePDFs"></p>
</div> </div>
</div> </div>
@@ -86,21 +98,26 @@
<h2 class="text-lg font-semibold text-gray-900">PDF 파일 목록</h2> <h2 class="text-lg font-semibold text-gray-900">PDF 파일 목록</h2>
<!-- 필터 버튼 --> <!-- 필터 버튼 -->
<div class="flex space-x-2"> <div class="flex flex-wrap gap-2">
<button @click="filterType = 'all'" <button @click="filterType = 'all'"
:class="filterType === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'" :class="filterType === 'all' ? 'bg-gray-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors"> class="px-3 py-1.5 rounded-lg text-sm transition-colors">
전체 전체
</button> </button>
<button @click="filterType = 'book'"
:class="filterType === 'book' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors">
서적 포함
</button>
<button @click="filterType = 'linked'" <button @click="filterType = 'linked'"
:class="filterType === 'linked' ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'" :class="filterType === 'linked' ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors"> class="px-3 py-1.5 rounded-lg text-sm transition-colors">
연결 HTML 연결
</button> </button>
<button @click="filterType = 'standalone'" <button @click="filterType = 'standalone'"
:class="filterType === 'standalone' ? 'bg-yellow-600 text-white' : 'bg-gray-200 text-gray-700'" :class="filterType === 'standalone' ? 'bg-yellow-600 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1.5 rounded-lg text-sm transition-colors"> class="px-3 py-1.5 rounded-lg text-sm transition-colors">
독립 독립 파일
</button> </button>
</div> </div>
</div> </div>
@@ -129,26 +146,47 @@
<p class="text-sm text-gray-500 mb-2" x-text="pdf.original_filename"></p> <p class="text-sm text-gray-500 mb-2" x-text="pdf.original_filename"></p>
<p class="text-sm text-gray-600 line-clamp-2" x-text="pdf.description || '설명이 없습니다'"></p> <p class="text-sm text-gray-600 line-clamp-2" x-text="pdf.description || '설명이 없습니다'"></p>
<!-- 연결 상태 --> <!-- 서적 정보 및 연결 상태 -->
<div class="mt-3 flex items-center space-x-4"> <div class="mt-3 space-y-2">
<span class="text-sm text-gray-500"> <!-- 서적 정보 -->
<i class="fas fa-calendar mr-1"></i> <div x-show="pdf.book_title" class="flex items-center space-x-2">
<span x-text="formatDate(pdf.created_at)"></span> <span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
</span> <i class="fas fa-book mr-1"></i>
<span x-text="pdf.book_title"></span>
<div x-show="pdf.isLinked"> </span>
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full"> <span x-show="pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
<i class="fas fa-link mr-1"></i> <i class="fas fa-link mr-1"></i>
연결됨 HTML 연결됨
</span> </span>
</div> </div>
<div x-show="!pdf.isLinked"> <!-- 서적 없는 경우 -->
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full"> <div x-show="!pdf.book_title" class="flex items-center space-x-2">
<span class="inline-flex items-center px-3 py-1 bg-gray-100 text-gray-600 text-sm rounded-full">
<i class="fas fa-file mr-1"></i>
서적 미분류
</span>
<span x-show="pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
<i class="fas fa-link mr-1"></i>
HTML 연결됨
</span>
<span x-show="!pdf.isLinked" class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full">
<i class="fas fa-unlink mr-1"></i> <i class="fas fa-unlink mr-1"></i>
독립 파일 독립 파일
</span> </span>
</div> </div>
<!-- 업로드 날짜 -->
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">
<i class="fas fa-calendar mr-1"></i>
<span x-text="formatDate(pdf.created_at)"></span>
</span>
<span x-show="pdf.uploaded_by" class="text-sm text-gray-500">
<i class="fas fa-user mr-1"></i>
<span x-text="pdf.uploaded_by"></span>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -179,7 +217,8 @@
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3> <h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
<p class="text-gray-500"> <p class="text-gray-500">
<span x-show="filterType === 'all'">업로드된 PDF 파일이 없습니다</span> <span x-show="filterType === 'all'">업로드된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'linked'">연결된 PDF 파일이 없습니다</span> <span x-show="filterType === 'book'">서적에 포함된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'linked'">HTML과 연결된 PDF 파일이 없습니다</span>
<span x-show="filterType === 'standalone'">독립 PDF 파일이 없습니다</span> <span x-show="filterType === 'standalone'">독립 PDF 파일이 없습니다</span>
</p> </p>
</div> </div>
@@ -190,6 +229,6 @@
<script src="/static/js/api.js?v=2025012384"></script> <script src="/static/js/api.js?v=2025012384"></script>
<script src="/static/js/auth.js?v=2025012351"></script> <script src="/static/js/auth.js?v=2025012351"></script>
<script src="/static/js/header-loader.js?v=2025012351"></script> <script src="/static/js/header-loader.js?v=2025012351"></script>
<script src="/static/js/pdf-manager.js?v=2025012388"></script> <script src="/static/js/pdf-manager.js?v=2025012459"></script>
</body> </body>
</html> </html>

View File

@@ -8,7 +8,7 @@ class DocumentServerAPI {
this.token = localStorage.getItem('access_token'); this.token = localStorage.getItem('access_token');
console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL); 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); return await this.put(`/books/${bookId}`, bookData);
} }
// 문서 네비게이션 정보 조회
async getDocumentNavigation(documentId) {
return await this.get(`/documents/${documentId}/navigation`);
}
async searchBooks(query, limit = 10) { async searchBooks(query, limit = 10) {
const params = new URLSearchParams({ q: query, limit }); const params = new URLSearchParams({ q: query, limit });
return await this.get(`/books/search/?${params}`); return await this.get(`/books/search/?${params}`);
@@ -518,6 +523,36 @@ class DocumentServerAPI {
async exportMemoTree(exportData) { async exportMemoTree(exportData) {
return await this.post('/memo-trees/export', 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 인스턴스 // 전역 API 인스턴스

View File

@@ -78,7 +78,7 @@ window.bookDocumentsApp = () => ({
this.documents = allDocuments.filter(doc => this.documents = allDocuments.filter(doc =>
!doc.book_id && !doc.book_id &&
doc.html_path && doc.html_path &&
!doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
); );
// 서적 미분류 PDF 문서들 (매칭용) // 서적 미분류 PDF 문서들 (매칭용)
@@ -97,7 +97,7 @@ window.bookDocumentsApp = () => ({
this.documents = allDocuments.filter(doc => this.documents = allDocuments.filter(doc =>
doc.book_id === this.bookId && doc.book_id === this.bookId &&
doc.html_path && doc.html_path &&
!doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 doc.html_path.includes('/documents/') // HTML은 documents 폴더에 저장됨
); );
// 특정 서적의 PDF 문서들 (매칭용) // 특정 서적의 PDF 문서들 (매칭용)
@@ -132,6 +132,8 @@ window.bookDocumentsApp = () => ({
console.log('📚 서적 문서 로드 완료:', this.documents.length, '개'); console.log('📚 서적 문서 로드 완료:', this.documents.length, '개');
console.log('📕 사용 가능한 PDF:', this.availablePDFs.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 확인 // 디버깅: 문서들의 original_filename 확인
console.log('🔍 문서들 확인:'); console.log('🔍 문서들 확인:');

View File

@@ -84,20 +84,49 @@ window.bookEditorApp = () => ({
.filter(doc => .filter(doc =>
doc.book_id === this.bookId && doc.book_id === this.bookId &&
doc.html_path && 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)); // 순서대로 정렬 .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // 순서대로 정렬
console.log('📄 서적 문서들:', this.documents.length, '개'); console.log('📄 서적 문서들:', this.documents.length, '개');
// 사용 가능한 PDF 문서들 로드 (PDF 타입 문서들) // 사용 가능한 PDF 문서들 로드 (현재 서적의 PDF만)
// PDF 문서들만 필터링 (폴더 경로 기준) console.log('🔍 현재 서적 ID:', this.bookId);
this.availablePDFs = allDocuments.filter(doc => console.log('🔍 전체 문서 수:', allDocuments.length);
// PDF 문서들 먼저 필터링
const allPDFs = allDocuments.filter(doc =>
doc.pdf_path && doc.pdf_path &&
doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨 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) { } catch (error) {
console.error('서적 데이터 로드 실패:', error); console.error('서적 데이터 로드 실패:', error);

View File

@@ -5,7 +5,7 @@ window.pdfManagerApp = () => ({
allDocuments: [], allDocuments: [],
loading: false, loading: false,
error: '', error: '',
filterType: 'all', // 'all', 'linked', 'standalone' filterType: 'all', // 'all', 'book', 'linked', 'standalone'
// 인증 상태 // 인증 상태
isAuthenticated: false, isAuthenticated: false,
@@ -66,7 +66,7 @@ window.pdfManagerApp = () => ({
(doc.html_path === null && doc.pdf_path) // PDF만 업로드된 경우 (doc.html_path === null && doc.pdf_path) // PDF만 업로드된 경우
); );
// 연결 상태 확인 // 연결 상태 및 서적 정보 확인
this.pdfDocuments.forEach(pdf => { this.pdfDocuments.forEach(pdf => {
// 이 PDF를 참조하는 다른 문서가 있는지 확인 // 이 PDF를 참조하는 다른 문서가 있는지 확인
const linkedDocuments = this.allDocuments.filter(doc => const linkedDocuments = this.allDocuments.filter(doc =>
@@ -74,9 +74,27 @@ window.pdfManagerApp = () => ({
); );
pdf.isLinked = linkedDocuments.length > 0; pdf.isLinked = linkedDocuments.length > 0;
pdf.linkedDocuments = linkedDocuments; 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.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) { } catch (error) {
console.error('PDF 로드 실패:', error); console.error('PDF 로드 실패:', error);
@@ -90,22 +108,28 @@ window.pdfManagerApp = () => ({
// 필터링된 PDF 목록 // 필터링된 PDF 목록
get filteredPDFs() { get filteredPDFs() {
switch (this.filterType) { switch (this.filterType) {
case 'book':
return this.pdfDocuments.filter(pdf => pdf.book_title);
case 'linked': case 'linked':
return this.pdfDocuments.filter(pdf => pdf.isLinked); return this.pdfDocuments.filter(pdf => pdf.isLinked);
case 'standalone': case 'standalone':
return this.pdfDocuments.filter(pdf => !pdf.isLinked); return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title);
default: default:
return this.pdfDocuments; return this.pdfDocuments;
} }
}, },
// 통계 계산 // 통계 계산
get bookPDFs() {
return this.pdfDocuments.filter(pdf => pdf.book_title).length;
},
get linkedPDFs() { get linkedPDFs() {
return this.pdfDocuments.filter(pdf => pdf.isLinked).length; return this.pdfDocuments.filter(pdf => pdf.isLinked).length;
}, },
get standalonePDFs() { get standalonePDFs() {
return this.pdfDocuments.filter(pdf => !pdf.isLinked).length; return this.pdfDocuments.filter(pdf => !pdf.isLinked && !pdf.book_title).length;
}, },
// PDF 새로고침 // PDF 새로고침

File diff suppressed because it is too large Load Diff

495
frontend/text-selector.html Normal file
View File

@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>텍스트 선택 모드</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen">
<!-- 헤더 -->
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-4 sticky top-0 z-50">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-crosshairs text-2xl"></i>
<div>
<h1 class="text-lg font-bold">텍스트 선택 모드</h1>
<p class="text-sm opacity-90">연결하고 싶은 텍스트를 선택하세요</p>
</div>
</div>
<div class="flex items-center space-x-3">
<button id="language-toggle-btn"
class="bg-white bg-opacity-20 hover:bg-opacity-30 px-3 py-2 rounded-lg transition-colors">
<i class="fas fa-language mr-1"></i>언어전환
</button>
<button onclick="window.close()"
class="bg-white bg-opacity-20 hover:bg-opacity-30 px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-times mr-2"></i>취소
</button>
</div>
</div>
</header>
<!-- 안내 메시지 -->
<div class="max-w-4xl mx-auto p-6">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start space-x-3">
<i class="fas fa-info-circle text-blue-500 mt-0.5"></i>
<div>
<h3 class="font-semibold text-blue-800 mb-1">텍스트 선택 방법</h3>
<p class="text-sm text-blue-700">
마우스로 연결하고 싶은 텍스트를 드래그하여 선택하세요.
선택이 완료되면 자동으로 부모 창으로 전달됩니다.
</p>
</div>
</div>
</div>
</div>
<!-- 메인 콘텐츠 -->
<main class="max-w-4xl mx-auto p-6">
<div id="document-content" class="bg-white rounded-lg shadow-lg p-8 border-2 border-dashed border-blue-300 hover:border-blue-500 transition-all duration-300 cursor-crosshair select-text">
<div id="loading" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-2xl text-blue-500 mb-2"></i>
<p class="text-gray-600">문서를 불러오는 중...</p>
</div>
<div id="error" class="hidden text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-500 mb-2"></i>
<p class="text-red-600">문서를 불러올 수 없습니다.</p>
</div>
</div>
</main>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=2025012415"></script>
<script>
// 텍스트 선택 모드 전용 스크립트
class TextSelectorApp {
constructor() {
this.documentId = null;
this.document = null;
this.isKorean = true;
this.init();
}
async init() {
// URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
if (!this.documentId) {
this.showError('문서 ID가 없습니다');
return;
}
// 인증 확인
if (!api.token) {
window.location.href = '/';
return;
}
try {
await this.loadDocument();
this.setupEventListeners();
} catch (error) {
console.error('문서 로드 실패:', error);
this.showError('문서를 불러올 수 없습니다: ' + error.message);
}
}
async loadDocument() {
console.log('📄 문서 로드 중:', this.documentId);
try {
// 문서 메타데이터 조회
const docResponse = await api.getDocument(this.documentId);
this.document = docResponse;
console.log('📋 문서 메타데이터:', docResponse);
// 문서 HTML 콘텐츠 조회
const contentResponse = await fetch(`${api.baseURL}/documents/${this.documentId}/content`, {
headers: {
'Authorization': `Bearer ${api.token}`,
'Content-Type': 'application/json'
}
});
if (!contentResponse.ok) {
throw new Error(`HTTP ${contentResponse.status}: ${contentResponse.statusText}`);
}
const htmlContent = await contentResponse.text();
console.log('📝 HTML 콘텐츠 로드됨:', htmlContent.substring(0, 100) + '...');
const contentDiv = document.getElementById('document-content');
const loadingDiv = document.getElementById('loading');
loadingDiv.style.display = 'none';
contentDiv.innerHTML = htmlContent;
console.log('✅ 문서 로드 완료');
} catch (error) {
console.error('문서 로드 실패:', error);
this.showError('문서를 불러올 수 없습니다: ' + error.message);
}
}
setupEventListeners() {
const contentDiv = document.getElementById('document-content');
const langBtn = document.getElementById('language-toggle-btn');
// 텍스트 선택 이벤트
contentDiv.addEventListener('mouseup', (e) => {
this.handleTextSelection();
});
// 언어 전환 버튼
langBtn.addEventListener('click', (e) => {
e.preventDefault();
console.log('🌐 언어전환 버튼 클릭됨');
this.toggleLanguage();
});
console.log('✅ 이벤트 리스너 설정 완료');
}
handleTextSelection() {
console.log('🖱️ handleTextSelection 함수 호출됨');
const selection = window.getSelection();
console.log('📋 Selection 객체:', selection);
console.log('📊 Selection 정보:', {
rangeCount: selection.rangeCount,
isCollapsed: selection.isCollapsed,
toString: selection.toString()
});
if (!selection.rangeCount || selection.isCollapsed) {
console.log('⚠️ 선택된 텍스트가 없습니다');
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
console.log('✂️ 선택된 텍스트:', `"${selectedText}"`);
console.log('📏 텍스트 길이:', selectedText.length);
if (selectedText.length < 3) {
console.log('❌ 텍스트가 너무 짧음');
alert('최소 3글자 이상 선택해주세요.');
return;
}
if (selectedText.length > 500) {
console.log('❌ 텍스트가 너무 김');
alert('선택된 텍스트가 너무 깁니다. 500자 이하로 선택해주세요.');
return;
}
// 텍스트 오프셋 계산
const documentContent = document.getElementById('document-content');
console.log('📄 Document content:', documentContent);
try {
const { startOffset, endOffset } = this.getTextOffset(documentContent, range);
console.log('🎯 텍스트 선택 완료:', {
selectedText,
startOffset,
endOffset
});
// 선택 확인 UI 표시
console.log('🎨 확인 UI 표시 시작');
this.showTextSelectionConfirm(selectedText, startOffset, endOffset);
} catch (error) {
console.error('❌ 오프셋 계산 실패:', error);
alert('텍스트 선택 처리 중 오류가 발생했습니다.');
}
}
getTextOffset(container, range) {
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let startOffset = 0;
let endOffset = 0;
let currentOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (node === range.startContainer) {
startOffset = currentOffset + range.startOffset;
}
if (node === range.endContainer) {
endOffset = currentOffset + range.endOffset;
break;
}
currentOffset += nodeLength;
}
return { startOffset, endOffset };
}
showTextSelectionConfirm(selectedText, startOffset, endOffset) {
console.log('🎨 showTextSelectionConfirm 함수 호출됨');
console.log('📝 전달받은 데이터:', { selectedText, startOffset, endOffset });
// 기존 확인 UI 제거
const existingConfirm = document.querySelector('.text-selection-confirm');
if (existingConfirm) {
console.log('🗑️ 기존 확인 UI 제거');
existingConfirm.remove();
}
// 확인 UI 생성
console.log('🏗️ 새로운 확인 UI 생성 중');
const confirmDiv = document.createElement('div');
confirmDiv.className = 'text-selection-confirm';
// 강력한 인라인 스타일 적용
confirmDiv.style.cssText = `
position: fixed !important;
bottom: 20px !important;
left: 50% !important;
transform: translateX(-50%) !important;
background: white !important;
border-radius: 12px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
border: 1px solid #e5e7eb !important;
padding: 24px !important;
max-width: 400px !important;
width: 90vw !important;
z-index: 9999 !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
`;
const previewText = selectedText.length > 100 ? selectedText.substring(0, 100) + '...' : selectedText;
console.log('📄 미리보기 텍스트:', previewText);
confirmDiv.innerHTML = `
<div class="text-center">
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check text-green-600 text-xl"></i>
</div>
<h3 class="font-semibold text-gray-900 mb-2">텍스트가 선택되었습니다</h3>
<div class="bg-gray-50 rounded-md p-3 mb-4">
<p class="text-sm text-gray-700 italic">"${previewText}"</p>
</div>
<div class="flex space-x-3">
<button class="reselect-btn flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300 transition-colors">
다시 선택
</button>
<button class="confirm-btn flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors">
이 텍스트 사용
</button>
</div>
</div>
`;
// 이벤트 리스너 추가
const reselectBtn = confirmDiv.querySelector('.reselect-btn');
const confirmBtn = confirmDiv.querySelector('.confirm-btn');
reselectBtn.addEventListener('click', () => {
confirmDiv.remove();
});
confirmBtn.addEventListener('click', () => {
this.confirmTextSelection(selectedText, startOffset, endOffset);
});
console.log('📍 DOM에 확인 UI 추가 중');
document.body.appendChild(confirmDiv);
console.log('✅ 확인 UI가 DOM에 추가됨');
// 즉시 스타일 확인
console.log('🎨 추가 직후 스타일:', {
display: confirmDiv.style.display,
visibility: confirmDiv.style.visibility,
opacity: confirmDiv.style.opacity,
zIndex: confirmDiv.style.zIndex,
position: confirmDiv.style.position
});
// UI가 실제로 화면에 표시되는지 확인
setTimeout(() => {
const addedElement = document.querySelector('.text-selection-confirm');
console.log('🔍 추가된 UI 확인:', addedElement);
if (addedElement) {
console.log('✅ UI가 화면에 표시됨');
const rect = addedElement.getBoundingClientRect();
console.log('📐 UI 위치 정보:', rect);
console.log('🎨 계산된 스타일:', {
display: getComputedStyle(addedElement).display,
visibility: getComputedStyle(addedElement).visibility,
opacity: getComputedStyle(addedElement).opacity,
zIndex: getComputedStyle(addedElement).zIndex
});
// 화면에 실제로 보이는지 확인
if (rect.width > 0 && rect.height > 0) {
console.log('✅ UI가 실제로 화면에 렌더링됨');
} else {
console.error('❌ UI가 DOM에는 있지만 화면에 렌더링되지 않음');
}
} else {
console.error('❌ UI가 화면에 표시되지 않음');
}
}, 100);
// 10초 후 자동 제거
setTimeout(() => {
if (document.contains(confirmDiv)) {
console.log('⏰ 자동 제거 타이머 실행');
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 = `
<div class="text-center">
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check text-green-600 text-xl"></i>
</div>
<h3 class="font-semibold text-green-800 mb-2">선택 완료!</h3>
<p class="text-sm text-gray-600">창이 자동으로 닫힙니다...</p>
</div>
`;
setTimeout(() => {
window.close();
}, 1500);
}
} else {
alert('부모 창을 찾을 수 없습니다.');
}
}
toggleLanguage() {
console.log('🎯 toggleLanguage 함수 호출됨');
// 기존 viewer.js와 동일한 로직 사용
const koreanContent = document.getElementById('korean-content');
const englishContent = document.getElementById('english-content');
if (koreanContent && englishContent) {
// ID 기반 토글 (압력용기 매뉴얼 등)
console.log('📋 ID 기반 언어 전환 (korean-content, english-content)');
if (koreanContent.style.display === 'none') {
koreanContent.style.display = 'block';
englishContent.style.display = 'none';
console.log('🇰🇷 한국어로 전환됨');
} else {
koreanContent.style.display = 'none';
englishContent.style.display = 'block';
console.log('🇺🇸 영어로 전환됨');
}
} else {
// 클래스 기반 토글 (다른 문서들)
console.log('📋 클래스 기반 언어 전환');
const koreanElements = document.querySelectorAll('.korean, .ko, [lang="ko"]');
const englishElements = document.querySelectorAll('.english, .en, [lang="en"]');
console.log(`📊 언어별 요소: 한국어 ${koreanElements.length}개, 영어 ${englishElements.length}`);
if (koreanElements.length === 0 && englishElements.length === 0) {
console.log('⚠️ 언어 전환 요소가 없습니다');
alert('이 문서는 언어 전환이 불가능합니다.');
return;
}
koreanElements.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
englishElements.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
console.log('✅ 클래스 기반 언어 전환 완료');
}
}
showError(message) {
const loadingDiv = document.getElementById('loading');
const errorDiv = document.getElementById('error');
loadingDiv.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.querySelector('p').textContent = message;
}
}
// 앱 초기화
document.addEventListener('DOMContentLoaded', () => {
new TextSelectorApp();
});
</script>
<style>
/* 기존 언어 전환 버튼 숨기기 (확인 UI는 제외) */
.language-toggle:not(.text-selection-confirm),
button[onclick*="toggleLanguage"]:not(.text-selection-confirm *),
*[class*="language"]:not(.text-selection-confirm):not(.text-selection-confirm *),
*[class*="translate"]:not(.text-selection-confirm):not(.text-selection-confirm *) {
display: none !important;
}
/* 텍스트 선택 확인 UI 강제 표시 */
.text-selection-confirm {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
z-index: 9999 !important;
}
/* 텍스트 선택 확인 UI 애니메이션 */
.text-selection-confirm {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translate(-50%, 100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
</style>
</body>
</html>

File diff suppressed because it is too large Load Diff

Binary file not shown.