From 04ae64fc4d149de0ae6d2b231d811f0c34a21104 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 26 Aug 2025 07:44:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20PDF/HTML=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업로드 시 HTML과 PDF를 별도 폴더에 저장 (/documents/, /pdfs/) - 프론트엔드 필터링을 폴더 경로 기준으로 단순화 - PDF 삭제 시 외래키 참조 해제 로직 추가 - book-documents.js, book-editor.js 필터링 통일 - HTML 문서 목록에서 PDF 완전 분리 --- backend/migrations/005_add_matched_pdf_id.sql | 12 + .../006_make_html_path_nullable.sql | 9 + backend/src/api/routes/documents.py | 260 ++++++++++++++++-- backend/src/models/document.py | 3 +- frontend/book-documents.html | 11 +- frontend/book-editor.html | 194 +++++++++++++ frontend/components/header.html | 3 + frontend/pdf-manager.html | 195 +++++++++++++ frontend/static/js/api.js | 6 +- frontend/static/js/book-documents.js | 53 +++- frontend/static/js/book-editor.js | 246 +++++++++++++++++ frontend/static/js/pdf-manager.js | 229 +++++++++++++++ frontend/static/js/upload.js | 111 +++++--- frontend/static/js/viewer.js | 63 ++++- frontend/upload.html | 2 +- frontend/viewer.html | 10 +- .../319c6faa-7881-4c16-a18b-ea46f521067e.pdf | Bin 0 -> 2421078 bytes .../6b4260b0-c920-4929-87ba-4505b05052d9.pdf | Bin 0 -> 8285419 bytes .../d7b34eab-071f-48ac-ba16-951798115726.pdf | Bin 0 -> 372049 bytes .../e7bf9dd9-0740-45ab-893b-0cf58b671384.pdf | Bin 0 -> 3768131 bytes 20 files changed, 1334 insertions(+), 73 deletions(-) create mode 100644 backend/migrations/005_add_matched_pdf_id.sql create mode 100644 backend/migrations/006_make_html_path_nullable.sql create mode 100644 frontend/book-editor.html create mode 100644 frontend/pdf-manager.html create mode 100644 frontend/static/js/book-editor.js create mode 100644 frontend/static/js/pdf-manager.js create mode 100644 uploads/pdfs/319c6faa-7881-4c16-a18b-ea46f521067e.pdf create mode 100644 uploads/pdfs/6b4260b0-c920-4929-87ba-4505b05052d9.pdf create mode 100644 uploads/pdfs/d7b34eab-071f-48ac-ba16-951798115726.pdf create mode 100644 uploads/pdfs/e7bf9dd9-0740-45ab-893b-0cf58b671384.pdf diff --git a/backend/migrations/005_add_matched_pdf_id.sql b/backend/migrations/005_add_matched_pdf_id.sql new file mode 100644 index 0000000..921977c --- /dev/null +++ b/backend/migrations/005_add_matched_pdf_id.sql @@ -0,0 +1,12 @@ +-- 문서에 PDF 매칭 필드 추가 +-- Migration: 005_add_matched_pdf_id.sql + +-- matched_pdf_id 컬럼 추가 +ALTER TABLE documents +ADD COLUMN matched_pdf_id UUID REFERENCES documents(id); + +-- 인덱스 추가 (성능 향상) +CREATE INDEX idx_documents_matched_pdf_id ON documents(matched_pdf_id); + +-- 코멘트 추가 +COMMENT ON COLUMN documents.matched_pdf_id IS '매칭된 PDF 문서 ID (HTML 문서에 연결된 원본 PDF)'; diff --git a/backend/migrations/006_make_html_path_nullable.sql b/backend/migrations/006_make_html_path_nullable.sql new file mode 100644 index 0000000..e54b66d --- /dev/null +++ b/backend/migrations/006_make_html_path_nullable.sql @@ -0,0 +1,9 @@ +-- HTML 경로를 nullable로 변경 (PDF만 업로드하는 경우 대응) +-- Migration: 006_make_html_path_nullable.sql + +-- html_path 컬럼을 nullable로 변경 +ALTER TABLE documents +ALTER COLUMN html_path DROP NOT NULL; + +-- 코멘트 업데이트 +COMMENT ON COLUMN documents.html_path IS 'HTML 파일 경로 (PDF만 업로드하는 경우 null 가능)'; diff --git a/backend/src/api/routes/documents.py b/backend/src/api/routes/documents.py index f193a5e..3831e14 100644 --- a/backend/src/api/routes/documents.py +++ b/backend/src/api/routes/documents.py @@ -3,7 +3,7 @@ """ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete, and_, or_ +from sqlalchemy import select, delete, and_, or_, update from sqlalchemy.orm import selectinload from typing import List, Optional import os @@ -27,7 +27,7 @@ class DocumentResponse(BaseModel): id: str title: str description: Optional[str] - html_path: str + html_path: Optional[str] # PDF만 업로드하는 경우 None 가능 pdf_path: Optional[str] thumbnail_path: Optional[str] file_size: Optional[int] @@ -50,6 +50,9 @@ class DocumentResponse(BaseModel): category_id: Optional[str] = None category_name: Optional[str] = None sort_order: int = 0 + + # PDF 매칭 정보 + matched_pdf_id: Optional[str] = None class Config: from_attributes = True @@ -128,7 +131,7 @@ async def list_documents( id=str(doc.id), title=doc.title, description=doc.description, - html_path=doc.html_path, + html_path=doc.html_path, # None 가능 (PDF만 업로드한 경우) pdf_path=doc.pdf_path, thumbnail_path=doc.thumbnail_path, file_size=doc.file_size, @@ -148,7 +151,9 @@ async def list_documents( # 소분류 정보 추가 category_id=str(doc.category.id) if doc.category else None, category_name=doc.category.name if doc.category else None, - sort_order=doc.sort_order + sort_order=doc.sort_order, + # PDF 매칭 정보 추가 + matched_pdf_id=str(doc.matched_pdf_id) if doc.matched_pdf_id else None ) response_data.append(doc_data) @@ -257,11 +262,15 @@ async def upload_document( db: AsyncSession = Depends(get_db) ): """문서 업로드""" - # 파일 확장자 확인 - if not html_file.filename.lower().endswith(('.html', '.htm')): + # 파일 확장자 확인 (HTML 또는 PDF 허용) + file_extension = html_file.filename.lower() + is_pdf_file = file_extension.endswith('.pdf') + is_html_file = file_extension.endswith(('.html', '.htm')) + + if not (is_html_file or is_pdf_file): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Only HTML files are allowed for the main document" + detail="Only HTML and PDF files are allowed" ) if pdf_file and not pdf_file.filename.lower().endswith('.pdf'): @@ -272,24 +281,45 @@ async def upload_document( # 고유 파일명 생성 doc_id = str(uuid.uuid4()) - html_filename = f"{doc_id}.html" - pdf_filename = f"{doc_id}.pdf" if pdf_file else None - # 파일 저장 경로 - html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename) - pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None + # 메인 파일 처리 (HTML 또는 PDF) - 폴더 분리 + if is_pdf_file: + main_filename = f"{doc_id}.pdf" + pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs") + os.makedirs(pdf_dir, exist_ok=True) # PDF 폴더 생성 + main_path = os.path.join(pdf_dir, main_filename) + html_path = None # PDF만 업로드하는 경우 html_path는 None + pdf_path = main_path # PDF 파일인 경우 pdf_path에 저장 + else: + main_filename = f"{doc_id}.html" + html_dir = os.path.join(settings.UPLOAD_DIR, "documents") + os.makedirs(html_dir, exist_ok=True) # HTML 폴더 생성 + main_path = os.path.join(html_dir, main_filename) + html_path = main_path + pdf_path = None + + # 추가 PDF 파일 처리 (HTML 파일과 함께 업로드된 경우) + additional_pdf_path = None + if pdf_file: + additional_pdf_filename = f"{doc_id}_additional.pdf" + pdf_dir = os.path.join(settings.UPLOAD_DIR, "pdfs") + os.makedirs(pdf_dir, exist_ok=True) # PDF 폴더 생성 + additional_pdf_path = os.path.join(pdf_dir, additional_pdf_filename) try: - # HTML 파일 저장 - async with aiofiles.open(html_path, 'wb') as f: + # 메인 파일 저장 (HTML 또는 PDF) + async with aiofiles.open(main_path, 'wb') as f: content = await html_file.read() await f.write(content) - # PDF 파일 저장 (있는 경우) - if pdf_file and pdf_path: - async with aiofiles.open(pdf_path, 'wb') as f: - content = await pdf_file.read() - await f.write(content) + # 추가 PDF 파일 저장 (HTML과 함께 업로드된 경우) + if pdf_file and additional_pdf_path: + async with aiofiles.open(additional_pdf_path, 'wb') as f: + additional_content = await pdf_file.read() + await f.write(additional_content) + # HTML 파일인 경우 추가 PDF를 pdf_path로 설정 + if is_html_file: + pdf_path = additional_pdf_path # 서적 ID 검증 (있는 경우) validated_book_id = None @@ -370,15 +400,16 @@ async def upload_document( updated_at=document_with_tags.updated_at, document_date=document_with_tags.document_date, uploader_name=current_user.full_name or current_user.email, - tags=[tag.name for tag in document_with_tags.tags] + tags=[tag.name for tag in document_with_tags.tags], + matched_pdf_id=str(document_with_tags.matched_pdf_id) if document_with_tags.matched_pdf_id else None ) except Exception as e: # 파일 정리 - if os.path.exists(html_path): - os.remove(html_path) - if pdf_path and os.path.exists(pdf_path): - os.remove(pdf_path) + if os.path.exists(main_path): + os.remove(main_path) + if additional_pdf_path and os.path.exists(additional_pdf_path): + os.remove(additional_pdf_path) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -429,7 +460,8 @@ async def get_document( updated_at=document.updated_at, document_date=document.document_date, uploader_name=document.uploader.full_name or document.uploader.email, - tags=[tag.name for tag in document.tags] + tags=[tag.name for tag in document.tags], + matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None ) @@ -475,6 +507,175 @@ async def get_document_content( raise HTTPException(status_code=500, detail=f"Error reading document: {str(e)}") +class UpdateDocumentRequest(BaseModel): + """문서 업데이트 요청""" + title: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + matched_pdf_id: Optional[str] = None + is_public: Optional[bool] = None + tags: Optional[List[str]] = None + + +@router.put("/{document_id}", response_model=DocumentResponse) +async def update_document( + document_id: str, + update_data: UpdateDocumentRequest, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 정보 업데이트""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # 문서 조회 + result = await db.execute( + select(Document) + .options(selectinload(Document.tags), selectinload(Document.uploader), selectinload(Document.book)) + .where(Document.id == doc_uuid) + ) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document not found" + ) + + # 권한 확인 (관리자이거나 문서 소유자) + if not current_user.is_admin and document.uploaded_by != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to update this document" + ) + + # 업데이트할 필드들 적용 + update_fields = update_data.model_dump(exclude_unset=True) + + for field, value in update_fields.items(): + if field == "matched_pdf_id": + # PDF 매칭 처리 + if value: + try: + pdf_uuid = UUID(value) + # PDF 문서가 실제로 존재하는지 확인 + pdf_result = await db.execute(select(Document).where(Document.id == pdf_uuid)) + pdf_doc = pdf_result.scalar_one_or_none() + if pdf_doc: + setattr(document, field, pdf_uuid) + except ValueError: + # 잘못된 UUID 형식이면 무시 + pass + else: + # None으로 설정하여 매칭 해제 + setattr(document, field, None) + elif field == "tags": + # 태그 처리 + if value is not None: + # 기존 태그 관계 제거 + document.tags.clear() + + # 새 태그 추가 + for tag_name in value: + tag_name = tag_name.strip() + if tag_name: + # 기존 태그 찾기 또는 생성 + tag_result = await db.execute(select(Tag).where(Tag.name == tag_name)) + tag = tag_result.scalar_one_or_none() + + if not tag: + tag = Tag( + name=tag_name, + created_by=current_user.id + ) + db.add(tag) + await db.flush() + + document.tags.append(tag) + else: + # 일반 필드 업데이트 + setattr(document, field, value) + + # 업데이트 시간 갱신 + document.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(document) + + # 응답 데이터 생성 + return DocumentResponse( + id=str(document.id), + title=document.title, + description=document.description, + html_path=document.html_path, + pdf_path=document.pdf_path, + thumbnail_path=document.thumbnail_path, + file_size=document.file_size, + page_count=document.page_count, + language=document.language, + is_public=document.is_public, + is_processed=document.is_processed, + created_at=document.created_at, + updated_at=document.updated_at, + document_date=document.document_date, + uploader_name=document.uploader.full_name or document.uploader.email, + tags=[tag.name for tag in document.tags], + book_id=str(document.book.id) if document.book else None, + book_title=document.book.title if document.book else None, + book_author=document.book.author if document.book else None, + sort_order=document.sort_order, + matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None + ) + + +@router.get("/{document_id}/download") +async def download_document( + document_id: str, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db) +): + """문서 파일 다운로드""" + try: + doc_uuid = UUID(document_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid document ID format") + + # 문서 조회 + query = select(Document).where(Document.id == doc_uuid) + result = await db.execute(query) + document = result.scalar_one_or_none() + + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + # 권한 확인 + if not current_user.is_admin and not document.is_public and document.uploaded_by != current_user.id: + raise HTTPException(status_code=403, detail="Access denied") + + # 다운로드할 파일 경로 결정 (PDF 우선, 없으면 HTML) + file_path = document.pdf_path if document.pdf_path else document.html_path + + if not file_path or not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Document file not found") + + # 파일 응답 + from fastapi.responses import FileResponse + + # 파일명 설정 + filename = document.original_filename + if not filename: + extension = '.pdf' if document.pdf_path else '.html' + filename = f"{document.title}{extension}" + + return FileResponse( + path=file_path, + filename=filename, + media_type='application/octet-stream' + ) + + @router.delete("/{document_id}") async def delete_document( document_id: str, @@ -514,6 +715,15 @@ async def delete_document( try: print(f"DEBUG: Starting deletion of document {document_id}") + # 0. PDF 참조 해제 (외래키 제약조건 해결) + # 이 문서를 matched_pdf_id로 참조하는 모든 문서의 참조를 NULL로 설정 + await db.execute( + update(Document) + .where(Document.matched_pdf_id == document_id) + .values(matched_pdf_id=None) + ) + print(f"DEBUG: Cleared matched_pdf_id references to document {document_id}") + # 1. 먼저 해당 문서의 모든 하이라이트 ID 조회 highlight_ids_result = await db.execute(select(Highlight.id).where(Highlight.document_id == document_id)) highlight_ids = [row[0] for row in highlight_ids_result.fetchall()] diff --git a/backend/src/models/document.py b/backend/src/models/document.py index 583edcb..5ba8f74 100644 --- a/backend/src/models/document.py +++ b/backend/src/models/document.py @@ -31,9 +31,10 @@ class Document(Base): description = Column(Text, nullable=True) # 파일 정보 - html_path = Column(String(1000), nullable=False) # HTML 파일 경로 + html_path = Column(String(1000), nullable=True) # HTML 파일 경로 (PDF만 업로드하는 경우 null 가능) pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택) thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로 + matched_pdf_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=True) # 매칭된 PDF 문서 ID # 메타데이터 file_size = Column(Integer, nullable=True) # 바이트 단위 diff --git a/frontend/book-documents.html b/frontend/book-documents.html index 8127b82..a2e10ce 100644 --- a/frontend/book-documents.html +++ b/frontend/book-documents.html @@ -31,11 +31,16 @@
-
+
+ +
@@ -125,9 +130,9 @@
- + - + diff --git a/frontend/book-editor.html b/frontend/book-editor.html new file mode 100644 index 0000000..499047b --- /dev/null +++ b/frontend/book-editor.html @@ -0,0 +1,194 @@ + + + + + + 서적 편집 - Document Server + + + + + + + + + +
+ + +
+ +
+
+ + + +
+ +
+
+
+ +
+
+

+

+

+ 개 문서 편집 +

+
+
+
+
+ + +
+ +

데이터를 불러오는 중...

+
+ + +
+ +
+
+

+ + 서적 정보 +

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

+ + 문서 순서 및 PDF 매칭 +

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

편집할 문서가 없습니다

+
+
+
+
+
+ + + + + + + + diff --git a/frontend/components/header.html b/frontend/components/header.html index ff3b7fc..8daa2fb 100644 --- a/frontend/components/header.html +++ b/frontend/components/header.html @@ -21,6 +21,9 @@ 문서 관리 + + PDF 관리 + diff --git a/frontend/pdf-manager.html b/frontend/pdf-manager.html new file mode 100644 index 0000000..de0d2bf --- /dev/null +++ b/frontend/pdf-manager.html @@ -0,0 +1,195 @@ + + + + + + PDF 파일 관리 - Document Server + + + + + + + + +
+ + +
+ +
+
+
+

PDF 파일 관리

+

업로드된 PDF 파일들을 관리하고 삭제할 수 있습니다

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

전체 PDF

+

+
+
+
+ +
+
+
+ +
+
+

연결된 PDF

+

+
+
+
+ +
+
+
+ +
+
+

독립 PDF

+

+
+
+
+
+ + +
+
+
+

PDF 파일 목록

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

PDF 파일을 불러오는 중...

+
+ + +
+ +
+ + +
+ +

PDF 파일이 없습니다

+

+ 업로드된 PDF 파일이 없습니다 + 연결된 PDF 파일이 없습니다 + 독립 PDF 파일이 없습니다 +

+
+
+
+ + + + + + + + diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index e62b24a..6e777db 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -8,7 +8,7 @@ class DocumentServerAPI { this.token = localStorage.getItem('access_token'); console.log('🐳 API Base URL (DOCKER BACKEND):', this.baseURL); - console.log('🔧 도커 환경 설정 완료 - 버전 2025012380'); + console.log('🔧 도커 환경 설정 완료 - 버전 2025012384'); } // 토큰 설정 @@ -383,6 +383,10 @@ class DocumentServerAPI { return await this.get(`/books/${bookId}`); } + async updateBook(bookId, bookData) { + return await this.put(`/books/${bookId}`, bookData); + } + async searchBooks(query, limit = 10) { const params = new URLSearchParams({ q: query, limit }); return await this.get(`/books/search/?${params}`); diff --git a/frontend/static/js/book-documents.js b/frontend/static/js/book-documents.js index c236cda..d23907b 100644 --- a/frontend/static/js/book-documents.js +++ b/frontend/static/js/book-documents.js @@ -2,6 +2,7 @@ window.bookDocumentsApp = () => ({ // 상태 관리 documents: [], + availablePDFs: [], bookInfo: {}, loading: false, error: '', @@ -73,15 +74,38 @@ window.bookDocumentsApp = () => ({ const allDocuments = await window.api.getDocuments(); if (this.bookId === 'none') { - // 서적 미분류 문서들 - this.documents = allDocuments.filter(doc => !doc.book_id); + // 서적 미분류 HTML 문서들만 (폴더로 구분) + this.documents = allDocuments.filter(doc => + !doc.book_id && + doc.html_path && + !doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 + ); + + // 서적 미분류 PDF 문서들 (매칭용) + this.availablePDFs = allDocuments.filter(doc => + !doc.book_id && + doc.pdf_path && + doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨 + ); + this.bookInfo = { title: '서적 미분류', description: '서적에 속하지 않은 문서들입니다.' }; } else { - // 특정 서적의 문서들 - this.documents = allDocuments.filter(doc => doc.book_id === this.bookId); + // 특정 서적의 HTML 문서들만 (폴더로 구분) + this.documents = allDocuments.filter(doc => + doc.book_id === this.bookId && + doc.html_path && + !doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 + ); + + // 특정 서적의 PDF 문서들 (매칭용) + this.availablePDFs = allDocuments.filter(doc => + doc.book_id === this.bookId && + doc.pdf_path && + doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨 + ); if (this.documents.length > 0) { // 첫 번째 문서에서 서적 정보 추출 @@ -107,6 +131,18 @@ window.bookDocumentsApp = () => ({ } console.log('📚 서적 문서 로드 완료:', this.documents.length, '개'); + console.log('📕 사용 가능한 PDF:', this.availablePDFs.length, '개'); + + // 디버깅: 문서들의 original_filename 확인 + console.log('🔍 문서들 확인:'); + this.documents.slice(0, 5).forEach(doc => { + console.log(`- ${doc.title}: ${doc.original_filename}`); + }); + + console.log('🔍 PDF들 확인:'); + this.availablePDFs.slice(0, 5).forEach(doc => { + console.log(`- ${doc.title}: ${doc.original_filename}`); + }); } catch (error) { console.error('서적 문서 로드 실패:', error); this.error = '문서를 불러오는데 실패했습니다: ' + error.message; @@ -125,6 +161,15 @@ window.bookDocumentsApp = () => ({ window.open(`/viewer.html?id=${documentId}&from=book`, '_blank'); }, + // 서적 편집 페이지 열기 + openBookEditor() { + if (this.bookId === 'none') { + alert('서적 미분류 문서들은 편집할 수 없습니다.'); + return; + } + window.location.href = `book-editor.html?bookId=${this.bookId}`; + }, + // 문서 수정 editDocument(doc) { // TODO: 문서 수정 모달 또는 페이지로 이동 diff --git a/frontend/static/js/book-editor.js b/frontend/static/js/book-editor.js new file mode 100644 index 0000000..b2d2094 --- /dev/null +++ b/frontend/static/js/book-editor.js @@ -0,0 +1,246 @@ +// 서적 편집 애플리케이션 컴포넌트 +window.bookEditorApp = () => ({ + // 상태 관리 + documents: [], + bookInfo: {}, + availablePDFs: [], + loading: false, + saving: false, + error: '', + + // 인증 상태 + isAuthenticated: false, + currentUser: null, + + // URL 파라미터 + bookId: null, + + // SortableJS 인스턴스 + sortableInstance: null, + + // 초기화 + async init() { + console.log('🚀 Book Editor App 초기화 시작'); + + // URL 파라미터 파싱 + this.parseUrlParams(); + + // 인증 상태 확인 + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadBookData(); + this.initSortable(); + } + + // 헤더 로드 + await this.loadHeader(); + }, + + // URL 파라미터 파싱 + parseUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + this.bookId = urlParams.get('bookId'); + console.log('📖 편집할 서적 ID:', this.bookId); + }, + + // 인증 상태 확인 + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('✅ 인증됨:', user.username); + } catch (error) { + console.log('❌ 인증되지 않음'); + this.isAuthenticated = false; + this.currentUser = null; + window.location.href = '/login.html'; + } + }, + + // 헤더 로드 + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('헤더 로드 실패:', error); + } + }, + + // 서적 데이터 로드 + async loadBookData() { + this.loading = true; + this.error = ''; + + try { + // 서적 정보 로드 + this.bookInfo = await window.api.getBook(this.bookId); + console.log('📚 서적 정보 로드:', this.bookInfo); + + // 모든 문서 가져와서 이 서적에 속한 HTML 문서들만 필터링 (폴더로 구분) + const allDocuments = await window.api.getDocuments(); + this.documents = allDocuments + .filter(doc => + doc.book_id === this.bookId && + doc.html_path && + !doc.pdf_path?.includes('/pdfs/') // pdfs 폴더에 있는 건 제외 + ) + .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); // 순서대로 정렬 + + console.log('📄 서적 문서들:', this.documents.length, '개'); + + // 사용 가능한 PDF 문서들 로드 (PDF 타입 문서들) + // PDF 문서들만 필터링 (폴더 경로 기준) + this.availablePDFs = allDocuments.filter(doc => + doc.pdf_path && + doc.pdf_path.includes('/pdfs/') // PDF는 pdfs 폴더에 저장됨 + ); + + console.log('📎 사용 가능한 PDF:', this.availablePDFs.length, '개'); + + } catch (error) { + console.error('서적 데이터 로드 실패:', error); + this.error = '데이터를 불러오는데 실패했습니다: ' + error.message; + } finally { + this.loading = false; + } + }, + + // SortableJS 초기화 + initSortable() { + this.$nextTick(() => { + const sortableList = document.getElementById('sortable-list'); + if (sortableList && !this.sortableInstance) { + this.sortableInstance = Sortable.create(sortableList, { + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + handle: '.fa-grip-vertical', + onEnd: (evt) => { + // 배열 순서 업데이트 + const item = this.documents.splice(evt.oldIndex, 1)[0]; + this.documents.splice(evt.newIndex, 0, item); + this.updateDisplayOrder(); + } + }); + console.log('✅ SortableJS 초기화 완료'); + } + }); + }, + + // 표시 순서 업데이트 + updateDisplayOrder() { + this.documents.forEach((doc, index) => { + doc.sort_order = index + 1; + }); + console.log('🔢 표시 순서 업데이트됨'); + }, + + // 위로 이동 + moveUp(index) { + if (index > 0) { + const item = this.documents.splice(index, 1)[0]; + this.documents.splice(index - 1, 0, item); + this.updateDisplayOrder(); + } + }, + + // 아래로 이동 + moveDown(index) { + if (index < this.documents.length - 1) { + const item = this.documents.splice(index, 1)[0]; + this.documents.splice(index + 1, 0, item); + this.updateDisplayOrder(); + } + }, + + // 이름순 정렬 + autoSortByName() { + this.documents.sort((a, b) => { + return a.title.localeCompare(b.title, 'ko', { numeric: true }); + }); + this.updateDisplayOrder(); + console.log('📝 이름순 정렬 완료'); + }, + + // 순서 뒤집기 + reverseOrder() { + this.documents.reverse(); + this.updateDisplayOrder(); + console.log('🔄 순서 뒤집기 완료'); + }, + + // 변경사항 저장 + async saveChanges() { + if (this.saving) return; + + this.saving = true; + + try { + // 서적 정보 업데이트 + await window.api.updateBook(this.bookId, { + title: this.bookInfo.title, + author: this.bookInfo.author, + description: this.bookInfo.description + }); + + // 각 문서의 순서와 PDF 매칭 정보 업데이트 + const updatePromises = this.documents.map(doc => { + return window.api.updateDocument(doc.id, { + sort_order: doc.sort_order, + matched_pdf_id: doc.matched_pdf_id || null + }); + }); + + await Promise.all(updatePromises); + + console.log('✅ 모든 변경사항 저장 완료'); + this.showNotification('변경사항이 저장되었습니다', 'success'); + + // 잠시 후 서적 페이지로 돌아가기 + setTimeout(() => { + this.goBack(); + }, 1500); + + } catch (error) { + console.error('저장 실패:', error); + this.showNotification('저장에 실패했습니다: ' + error.message, 'error'); + } finally { + this.saving = false; + } + }, + + // 뒤로가기 + goBack() { + window.location.href = `book-documents.html?bookId=${this.bookId}`; + }, + + // 알림 표시 + showNotification(message, type = 'info') { + console.log(`${type.toUpperCase()}: ${message}`); + + // 간단한 토스트 알림 생성 + const toast = document.createElement('div'); + toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${ + type === 'success' ? 'bg-green-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + }`; + toast.textContent = message; + + document.body.appendChild(toast); + + // 3초 후 제거 + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 3000); + } +}); + +// 페이지 로드 시 초기화 +document.addEventListener('DOMContentLoaded', () => { + console.log('📄 Book Editor 페이지 로드됨'); +}); diff --git a/frontend/static/js/pdf-manager.js b/frontend/static/js/pdf-manager.js new file mode 100644 index 0000000..d48bdbb --- /dev/null +++ b/frontend/static/js/pdf-manager.js @@ -0,0 +1,229 @@ +// PDF 관리 애플리케이션 컴포넌트 +window.pdfManagerApp = () => ({ + // 상태 관리 + pdfDocuments: [], + allDocuments: [], + loading: false, + error: '', + filterType: 'all', // 'all', 'linked', 'standalone' + + // 인증 상태 + isAuthenticated: false, + currentUser: null, + + // 초기화 + async init() { + console.log('🚀 PDF Manager App 초기화 시작'); + + // 인증 상태 확인 + await this.checkAuthStatus(); + + if (this.isAuthenticated) { + await this.loadPDFs(); + } + + // 헤더 로드 + await this.loadHeader(); + }, + + // 인증 상태 확인 + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.isAuthenticated = true; + this.currentUser = user; + console.log('✅ 인증됨:', user.username); + } catch (error) { + console.log('❌ 인증되지 않음'); + this.isAuthenticated = false; + this.currentUser = null; + window.location.href = '/login.html'; + } + }, + + // 헤더 로드 + async loadHeader() { + try { + await window.headerLoader.loadHeader(); + } catch (error) { + console.error('헤더 로드 실패:', error); + } + }, + + // PDF 파일들 로드 + async loadPDFs() { + this.loading = true; + this.error = ''; + + try { + // 모든 문서 가져오기 + this.allDocuments = await window.api.getDocuments(); + + // PDF 파일들만 필터링 + this.pdfDocuments = this.allDocuments.filter(doc => + (doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) || + (doc.pdf_path && doc.pdf_path !== '') || + (doc.html_path === null && doc.pdf_path) // PDF만 업로드된 경우 + ); + + // 연결 상태 확인 + this.pdfDocuments.forEach(pdf => { + // 이 PDF를 참조하는 다른 문서가 있는지 확인 + const linkedDocuments = this.allDocuments.filter(doc => + doc.matched_pdf_id === pdf.id + ); + pdf.isLinked = linkedDocuments.length > 0; + pdf.linkedDocuments = linkedDocuments; + }); + + console.log('📕 PDF 문서들:', this.pdfDocuments.length, '개'); + + } catch (error) { + console.error('PDF 로드 실패:', error); + this.error = 'PDF 파일을 불러오는데 실패했습니다: ' + error.message; + this.pdfDocuments = []; + } finally { + this.loading = false; + } + }, + + // 필터링된 PDF 목록 + get filteredPDFs() { + switch (this.filterType) { + case 'linked': + return this.pdfDocuments.filter(pdf => pdf.isLinked); + case 'standalone': + return this.pdfDocuments.filter(pdf => !pdf.isLinked); + default: + return this.pdfDocuments; + } + }, + + // 통계 계산 + get linkedPDFs() { + return this.pdfDocuments.filter(pdf => pdf.isLinked).length; + }, + + get standalonePDFs() { + return this.pdfDocuments.filter(pdf => !pdf.isLinked).length; + }, + + // PDF 새로고침 + async refreshPDFs() { + await this.loadPDFs(); + }, + + // PDF 다운로드 + async downloadPDF(pdf) { + try { + console.log('📕 PDF 다운로드 시작:', pdf.id); + + // PDF 파일 다운로드 URL 생성 + const downloadUrl = `/api/documents/${pdf.id}/download`; + + // 인증 헤더 추가를 위해 fetch 사용 + const response = await fetch(downloadUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!response.ok) { + throw new Error('PDF 다운로드에 실패했습니다'); + } + + // Blob으로 변환하여 다운로드 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // 다운로드 링크 생성 및 클릭 + const link = document.createElement('a'); + link.href = url; + link.download = pdf.original_filename || `${pdf.title}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // URL 정리 + window.URL.revokeObjectURL(url); + + console.log('✅ PDF 다운로드 완료'); + + } catch (error) { + console.error('❌ PDF 다운로드 실패:', error); + alert('PDF 다운로드에 실패했습니다: ' + error.message); + } + }, + + // PDF 삭제 + async deletePDF(pdf) { + // 연결된 문서가 있는지 확인 + if (pdf.isLinked && pdf.linkedDocuments.length > 0) { + const linkedTitles = pdf.linkedDocuments.map(doc => doc.title).join('\n- '); + const confirmMessage = `이 PDF는 다음 문서들과 연결되어 있습니다:\n\n- ${linkedTitles}\n\n정말 삭제하시겠습니까? 연결된 문서들의 PDF 링크가 해제됩니다.`; + + if (!confirm(confirmMessage)) { + return; + } + } else { + if (!confirm(`"${pdf.title}" PDF 파일을 삭제하시겠습니까?`)) { + return; + } + } + + try { + await window.api.deleteDocument(pdf.id); + + // 목록에서 제거 + this.pdfDocuments = this.pdfDocuments.filter(p => p.id !== pdf.id); + + this.showNotification('PDF 파일이 삭제되었습니다', 'success'); + + // 목록 새로고침 (연결 상태 업데이트를 위해) + await this.loadPDFs(); + + } catch (error) { + console.error('PDF 삭제 실패:', error); + this.showNotification('PDF 삭제에 실패했습니다: ' + error.message, 'error'); + } + }, + + // 날짜 포맷팅 + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + // 알림 표시 + showNotification(message, type = 'info') { + console.log(`${type.toUpperCase()}: ${message}`); + + // 간단한 토스트 알림 생성 + const toast = document.createElement('div'); + toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${ + type === 'success' ? 'bg-green-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + }`; + toast.textContent = message; + + document.body.appendChild(toast); + + // 3초 후 제거 + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 3000); + } +}); + +// 페이지 로드 시 초기화 +document.addEventListener('DOMContentLoaded', () => { + console.log('📄 PDF Manager 페이지 로드됨'); +}); diff --git a/frontend/static/js/upload.js b/frontend/static/js/upload.js index 99ff595..f7040d5 100644 --- a/frontend/static/js/upload.js +++ b/frontend/static/js/upload.js @@ -261,8 +261,11 @@ window.uploadApp = () => ({ console.log('📄 HTML 파일:', htmlFiles.length, '개'); console.log('📕 PDF 파일:', pdfFiles.length, '개'); - // HTML 파일 업로드 (백엔드 API에 맞게) - const uploadPromises = htmlFiles.map(async (file, index) => { + // 업로드할 파일들 처리 + const uploadPromises = []; + + // HTML 파일 업로드 (PDF 파일이 있으면 함께 업로드) + htmlFiles.forEach(async (file, index) => { const formData = new FormData(); formData.append('html_file', file); // 백엔드가 요구하는 필드명 formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거 @@ -270,52 +273,96 @@ window.uploadApp = () => ({ formData.append('language', 'ko'); formData.append('is_public', 'false'); + // 같은 이름의 PDF 파일이 있는지 확인 + const htmlBaseName = file.name.replace(/\.[^/.]+$/, ""); + const matchingPdf = pdfFiles.find(pdfFile => { + const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, ""); + return pdfBaseName === htmlBaseName; + }); + + if (matchingPdf) { + formData.append('pdf_file', matchingPdf); + console.log('📎 매칭된 PDF 파일 함께 업로드:', matchingPdf.name); + } + if (bookId) { formData.append('book_id', bookId); } - try { - const response = await window.api.uploadDocument(formData); - console.log('✅ HTML 파일 업로드 완료:', file.name); - return response; - } catch (error) { - console.error('❌ HTML 파일 업로드 실패:', file.name, error); - throw error; + const uploadPromise = (async () => { + try { + const response = await window.api.uploadDocument(formData); + console.log('✅ HTML 파일 업로드 완료:', file.name); + return response; + } catch (error) { + console.error('❌ HTML 파일 업로드 실패:', file.name, error); + throw error; + } + })(); + + uploadPromises.push(uploadPromise); + }); + + // HTML과 매칭되지 않은 PDF 파일들을 별도로 업로드 + const unmatchedPdfs = pdfFiles.filter(pdfFile => { + const pdfBaseName = pdfFile.name.replace(/\.[^/.]+$/, ""); + return !htmlFiles.some(htmlFile => { + const htmlBaseName = htmlFile.name.replace(/\.[^/.]+$/, ""); + return htmlBaseName === pdfBaseName; + }); + }); + + unmatchedPdfs.forEach(async (file, index) => { + const formData = new FormData(); + formData.append('html_file', file); // PDF도 html_file로 전송 (백엔드에서 처리) + formData.append('title', file.name.replace(/\.[^/.]+$/, "")); // 확장자 제거 + formData.append('description', `PDF 파일: ${file.name}`); + formData.append('language', 'ko'); + formData.append('is_public', 'false'); + + if (bookId) { + formData.append('book_id', bookId); } + + const uploadPromise = (async () => { + try { + const response = await window.api.uploadDocument(formData); + console.log('✅ PDF 파일 업로드 완료:', file.name); + return response; + } catch (error) { + console.error('❌ PDF 파일 업로드 실패:', file.name, error); + throw error; + } + })(); + + uploadPromises.push(uploadPromise); }); - // PDF 파일은 별도로 처리 (나중에 HTML과 매칭) - const pdfUploadPromises = pdfFiles.map(async (file, index) => { - // PDF 전용 업로드 로직 (임시로 HTML 파일로 처리하지 않음) - console.log('📕 PDF 파일 대기 중:', file.name); - return { - id: `pdf-${Date.now()}-${index}`, - title: file.name.replace(/\.[^/.]+$/, ""), - original_filename: file.name, - file_type: 'pdf', - file: file // 실제 파일 객체 보관 - }; - }); + // 모든 업로드 완료 대기 + const uploadedDocs = await Promise.all(uploadPromises); - // HTML 파일 업로드 완료 대기 - const uploadedHtmlDocs = await Promise.all(uploadPromises); + // HTML 문서와 PDF 문서 분리 + const htmlDocuments = uploadedDocs.filter(doc => + doc.html_path && doc.html_path !== null + ); - // PDF 파일 처리 (실제 업로드는 하지 않고 매칭용으로만 보관) - const pdfDocs = await Promise.all(pdfUploadPromises); + const pdfDocuments = uploadedDocs.filter(doc => + (doc.original_filename && doc.original_filename.toLowerCase().endsWith('.pdf')) || + (doc.pdf_path && !doc.html_path) + ); - // 업로드된 HTML 문서 정리 - this.uploadedDocuments = uploadedHtmlDocs.map((doc, index) => ({ + // 업로드된 HTML 문서들만 정리 (순서 조정용) + this.uploadedDocuments = htmlDocuments.map((doc, index) => ({ ...doc, display_order: index + 1, - matched_pdf_id: null, - file_type: 'html' + matched_pdf_id: null })); - // PDF 파일 목록 (매칭용) - this.pdfFiles = pdfDocs; + // PDF 파일들을 매칭용으로 저장 + this.pdfFiles = pdfDocuments; console.log('🎉 모든 파일 업로드 완료!'); - console.log('📄 HTML 문서:', this.uploadedDocuments.filter(doc => doc.file_type === 'html').length, '개'); + console.log('📄 HTML 문서:', this.uploadedDocuments.length, '개'); console.log('📕 PDF 문서:', this.pdfFiles.length, '개'); // 3단계로 이동 diff --git a/frontend/static/js/viewer.js b/frontend/static/js/viewer.js index dff9651..7e08c36 100644 --- a/frontend/static/js/viewer.js +++ b/frontend/static/js/viewer.js @@ -1095,7 +1095,7 @@ window.documentViewer = () => ({ const response = await fetch(downloadUrl, { method: 'GET', headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` } }); @@ -1124,5 +1124,66 @@ window.documentViewer = () => ({ console.error('❌ PDF 다운로드 실패:', error); alert('PDF 다운로드에 실패했습니다: ' + error.message); } + }, + + // 원본 파일 다운로드 (연결된 PDF 파일) + async downloadOriginalFile() { + if (!this.document || !this.document.id) { + console.warn('문서 정보가 없습니다'); + return; + } + + // 연결된 PDF가 있는지 확인 + if (!this.document.matched_pdf_id) { + alert('연결된 원본 PDF 파일이 없습니다.\n\n서적 편집 페이지에서 PDF 파일을 연결해주세요.'); + return; + } + + try { + console.log('📕 연결된 PDF 다운로드 시작:', this.document.matched_pdf_id); + + // 연결된 PDF 문서 정보 가져오기 + const pdfDocument = await window.api.getDocument(this.document.matched_pdf_id); + + if (!pdfDocument) { + throw new Error('연결된 PDF 문서를 찾을 수 없습니다'); + } + + // PDF 파일 다운로드 URL 생성 + const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`; + + // 인증 헤더 추가를 위해 fetch 사용 + const response = await fetch(downloadUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!response.ok) { + throw new Error('연결된 PDF 다운로드에 실패했습니다'); + } + + // Blob으로 변환하여 다운로드 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + // 다운로드 링크 생성 및 클릭 + const link = document.createElement('a'); + link.href = url; + link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // URL 정리 + window.URL.revokeObjectURL(url); + + console.log('✅ 연결된 PDF 다운로드 완료'); + + } catch (error) { + console.error('❌ 연결된 PDF 다운로드 실패:', error); + alert('연결된 PDF 다운로드에 실패했습니다: ' + error.message); + } } }); diff --git a/frontend/upload.html b/frontend/upload.html index cb90ff2..93c079a 100644 --- a/frontend/upload.html +++ b/frontend/upload.html @@ -335,6 +335,6 @@ - + diff --git a/frontend/viewer.html b/frontend/viewer.html index 698bdbd..8434618 100644 --- a/frontend/viewer.html +++ b/frontend/viewer.html @@ -102,12 +102,12 @@
-