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:
34
backend/migrations/007_add_document_links.sql
Normal file
34
backend/migrations/007_add_document_links.sql
Normal 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();
|
||||
24
backend/migrations/008_enhance_document_links.sql
Normal file
24
backend/migrations/008_enhance_document_links.sql
Normal 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(특정 텍스트 부분)';
|
||||
529
backend/src/api/routes/document_links.py
Normal file
529
backend/src/api/routes/document_links.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
53
backend/src/models/document_link.py
Normal file
53
backend/src/models/document_link.py
Normal 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]}...')>"
|
||||
@@ -189,6 +189,6 @@
|
||||
<script src="/static/js/api.js?v=2025012384"></script>
|
||||
<script src="/static/js/auth.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>
|
||||
</html>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</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="flex items-center">
|
||||
<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 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="flex items-center">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +84,7 @@
|
||||
<i class="fas fa-unlink text-yellow-600 text-xl"></i>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,21 +98,26 @@
|
||||
<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'"
|
||||
: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">
|
||||
전체
|
||||
</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'"
|
||||
: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">
|
||||
연결됨
|
||||
HTML 연결
|
||||
</button>
|
||||
<button @click="filterType = 'standalone'"
|
||||
: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">
|
||||
독립
|
||||
독립 파일
|
||||
</button>
|
||||
</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-600 line-clamp-2" x-text="pdf.description || '설명이 없습니다'"></p>
|
||||
|
||||
<!-- 연결 상태 -->
|
||||
<div class="mt-3 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>
|
||||
|
||||
<div x-show="pdf.isLinked">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
<!-- 서적 정보 및 연결 상태 -->
|
||||
<div class="mt-3 space-y-2">
|
||||
<!-- 서적 정보 -->
|
||||
<div x-show="pdf.book_title" class="flex items-center space-x-2">
|
||||
<span class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full">
|
||||
<i class="fas fa-book mr-1"></i>
|
||||
<span x-text="pdf.book_title"></span>
|
||||
</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>
|
||||
</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>
|
||||
독립 파일
|
||||
</span>
|
||||
</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>
|
||||
@@ -179,7 +217,8 @@
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">PDF 파일이 없습니다</h3>
|
||||
<p class="text-gray-500">
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
@@ -190,6 +229,6 @@
|
||||
<script src="/static/js/api.js?v=2025012384"></script>
|
||||
<script src="/static/js/auth.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>
|
||||
</html>
|
||||
|
||||
@@ -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 인스턴스
|
||||
|
||||
@@ -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('🔍 문서들 확인:');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 새로고침
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
495
frontend/text-selector.html
Normal file
495
frontend/text-selector.html
Normal 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>
|
||||
1003
frontend/viewer.html
1003
frontend/viewer.html
File diff suppressed because it is too large
Load Diff
BIN
uploads/pdfs/624c4f51-6f69-4987-bd31-3ef4a82c5a6a.pdf
Normal file
BIN
uploads/pdfs/624c4f51-6f69-4987-bd31-3ef4a82c5a6a.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user