diff --git a/README.md b/README.md index 768f31b..b337e77 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,35 @@ HTML 문서 관리 및 뷰어 시스템 PDF 문서를 OCR 처리하고 AI로 HTML로 변환한 후, 웹에서 효율적으로 관리하고 열람할 수 있는 시스템입니다. +## 📝 용어 정의 + +시스템에서 사용하는 주요 용어들을 명확히 구분합니다: + +### 핵심 용어 +- **메모 (Memo)**: 하이라이트 기반의 메모 기능 + - 하이라이트에 달리는 짧은 코멘트 + - 문서 뷰어에서 텍스트 선택 → 하이라이트 → 메모 작성 + - API: `/api/notes/` (하이라이트 메모 전용) + +- **노트 (Note)**: 독립적인 문서 작성 기능 + - HTML 기반의 완전한 문서 + - 기본 뷰어 페이지에서 확인 및 편집 + - 하이라이트, 메모, 링크 등 모든 기능 사용 가능 + - 노트북에 그룹화 가능 + - API: `/api/note-documents/` + +- **노트북 (Notebook)**: 노트 문서들을 그룹화하는 폴더 + - 노트들의 컨테이너 역할 + - 계층적 구조 지원 + - API: `/api/notebooks/` + +### 기능별 구분 +| 기능 | 용어 | 설명 | 주요 API | 뷰어 지원 | +|------|------|------|----------|----------| +| 하이라이트 메모 | 메모 (Memo) | 하이라이트에 달리는 짧은 코멘트 | `/api/notes/` | ✅ 문서 뷰어 | +| 독립 문서 작성 | 노트 (Note) | HTML 기반 완전한 문서 | `/api/note-documents/` | ✅ 동일 뷰어 (모든 기능) | +| 문서 그룹화 | 노트북 (Notebook) | 노트들을 담는 폴더 | `/api/notebooks/` | - | + ### 문서 처리 워크플로우 1. PDF 스캔 후 OCR 처리 2. AI를 통한 HTML 변환 (필요시 번역 포함) @@ -202,7 +231,13 @@ notes ( - [x] API 오류 처리 및 사용자 피드백 - [x] 실시간 문서 목록 새로고침 -### Phase 7: 향후 개선사항 (예정) +### Phase 7: 최우선 개선사항 (진행 중) 🔥 +- [ ] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합 +- [ ] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원 +- [ ] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리 +- [ ] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보 + +### Phase 8: 향후 개선사항 (예정) - [ ] 관리자 대시보드 UI - [ ] 문서 통계 및 분석 - [ ] 모바일 반응형 최적화 diff --git a/backend/migrations/009_create_notes_system.sql b/backend/migrations/009_create_notes_system.sql new file mode 100644 index 0000000..333324b --- /dev/null +++ b/backend/migrations/009_create_notes_system.sql @@ -0,0 +1,81 @@ +-- 노트 관리 시스템 생성 +-- 009_create_notes_system.sql + +-- 노트 문서 테이블 +CREATE TABLE IF NOT EXISTS notes_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + content TEXT, -- 마크다운 내용 + html_content TEXT, -- 변환된 HTML 내용 + note_type VARCHAR(50) DEFAULT 'note', -- note, research, summary, idea 등 + tags TEXT[] DEFAULT '{}', -- 태그 배열 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL, + is_published BOOLEAN DEFAULT false, -- 공개 여부 + parent_note_id UUID REFERENCES notes_documents(id) ON DELETE SET NULL, -- 계층 구조 + sort_order INTEGER DEFAULT 0, -- 정렬 순서 + word_count INTEGER DEFAULT 0, -- 단어 수 + reading_time INTEGER DEFAULT 0, -- 예상 읽기 시간 (분) + + -- 인덱스 + CONSTRAINT notes_documents_title_check CHECK (char_length(title) > 0) +); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_notes_documents_created_by ON notes_documents(created_by); +CREATE INDEX IF NOT EXISTS idx_notes_documents_created_at ON notes_documents(created_at); +CREATE INDEX IF NOT EXISTS idx_notes_documents_note_type ON notes_documents(note_type); +CREATE INDEX IF NOT EXISTS idx_notes_documents_parent_note_id ON notes_documents(parent_note_id); +CREATE INDEX IF NOT EXISTS idx_notes_documents_tags ON notes_documents USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_notes_documents_is_published ON notes_documents(is_published); + +-- 업데이트 시간 자동 갱신 트리거 +CREATE OR REPLACE FUNCTION update_notes_documents_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_notes_documents_updated_at + BEFORE UPDATE ON notes_documents + FOR EACH ROW + EXECUTE FUNCTION update_notes_documents_updated_at(); + +-- 기존 document_links 테이블에 노트 지원 추가 +-- (이미 존재하는 테이블이므로 ALTER 사용) +DO $$ +BEGIN + -- source_type, target_type 컬럼이 없다면 추가 + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'document_links' AND column_name = 'source_type' + ) THEN + ALTER TABLE document_links + ADD COLUMN source_type VARCHAR(20) DEFAULT 'document', + ADD COLUMN target_type VARCHAR(20) DEFAULT 'document'; + + -- 기존 데이터는 모두 'document' 타입으로 설정 + UPDATE document_links SET source_type = 'document', target_type = 'document'; + END IF; +END $$; + +-- 노트 관련 링크를 위한 인덱스 +CREATE INDEX IF NOT EXISTS idx_document_links_source_type ON document_links(source_type); +CREATE INDEX IF NOT EXISTS idx_document_links_target_type ON document_links(target_type); + +-- 샘플 노트 타입 데이터 +INSERT INTO notes_documents (title, content, html_content, note_type, tags, created_by, is_published) +VALUES + ('노트 시스템 사용법', + '# 노트 시스템 사용법\n\n## 기본 기능\n- 마크다운으로 노트 작성\n- HTML로 자동 변환\n- 태그 기반 분류\n\n## 고급 기능\n- 서적과 링크 연결\n- 계층 구조 지원\n- 내보내기 기능', + '

노트 시스템 사용법

기본 기능

고급 기능

', + 'guide', + ARRAY['가이드', '사용법', '시스템'], + 'Administrator', + true) +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/backend/migrations/010_create_notebooks.sql b/backend/migrations/010_create_notebooks.sql new file mode 100644 index 0000000..d4bdf18 --- /dev/null +++ b/backend/migrations/010_create_notebooks.sql @@ -0,0 +1,25 @@ +-- 노트북 시스템 생성 +CREATE TABLE notebooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(500) NOT NULL, + description TEXT, + color VARCHAR(7) DEFAULT '#3B82F6', -- 헥스 컬러 코드 + icon VARCHAR(50) DEFAULT 'book', -- FontAwesome 아이콘 이름 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT true, + sort_order INTEGER DEFAULT 0 +); + +-- 노트북-노트 관계 테이블 (기존 notes_documents의 parent_note_id 대신 사용) +ALTER TABLE notes_documents ADD COLUMN notebook_id UUID REFERENCES notebooks(id); + +-- 인덱스 생성 +CREATE INDEX idx_notebooks_created_by ON notebooks(created_by); +CREATE INDEX idx_notebooks_created_at ON notebooks(created_at); +CREATE INDEX idx_notes_notebook_id ON notes_documents(notebook_id); + +-- 기본 노트북 생성 (기존 노트들을 위한) +INSERT INTO notebooks (title, description, created_by, color, icon) +VALUES ('기본 노트북', '분류되지 않은 노트들', 'admin@test.com', '#6B7280', 'sticky-note'); diff --git a/backend/migrations/011_create_note_highlights_and_notes.sql b/backend/migrations/011_create_note_highlights_and_notes.sql new file mode 100644 index 0000000..9da8458 --- /dev/null +++ b/backend/migrations/011_create_note_highlights_and_notes.sql @@ -0,0 +1,48 @@ +-- 노트용 하이라이트 테이블 생성 +CREATE TABLE note_highlights ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + selected_text TEXT NOT NULL, + highlight_color VARCHAR(50) NOT NULL DEFAULT '#FFFF00', + highlight_type VARCHAR(50) NOT NULL DEFAULT 'highlight', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +-- 노트용 메모 테이블 생성 +CREATE TABLE note_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + note_id UUID NOT NULL REFERENCES notes_documents(id) ON DELETE CASCADE, + highlight_id UUID REFERENCES note_highlights(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +-- 인덱스 생성 +CREATE INDEX ix_note_highlights_note_id ON note_highlights (note_id); +CREATE INDEX ix_note_highlights_created_by ON note_highlights (created_by); +CREATE INDEX ix_note_notes_note_id ON note_notes (note_id); +CREATE INDEX ix_note_notes_highlight_id ON note_notes (highlight_id); +CREATE INDEX ix_note_notes_created_by ON note_notes (created_by); + +-- updated_at 자동 업데이트 트리거 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_note_highlights_updated_at + BEFORE UPDATE ON note_highlights + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_note_notes_updated_at + BEFORE UPDATE ON note_notes + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/backend/src/api/routes/document_links.py b/backend/src/api/routes/document_links.py index cf17276..8a44f4b 100644 --- a/backend/src/api/routes/document_links.py +++ b/backend/src/api/routes/document_links.py @@ -402,6 +402,8 @@ class BacklinkResponse(BaseModel): source_document_id: str source_document_title: str source_document_book_id: Optional[str] + target_document_id: str # 추가 + target_document_title: str # 추가 selected_text: str start_offset: int end_offset: int @@ -421,16 +423,21 @@ async def get_document_backlinks( db: AsyncSession = Depends(get_db) ): """문서의 백링크 조회 (이 문서를 참조하는 모든 링크)""" + print(f"🔍 백링크 API 호출됨 - 문서 ID: {document_id}, 사용자: {current_user.email}") + # 문서 존재 확인 result = await db.execute(select(Document).where(Document.id == document_id)) document = result.scalar_one_or_none() if not document: + print(f"❌ 문서를 찾을 수 없음: {document_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Document not found" ) + print(f"✅ 문서 찾음: {document.title}") + # 권한 확인 if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin: raise HTTPException( @@ -454,12 +461,20 @@ async def get_document_backlinks( result = await db.execute(query) backlinks = [] + print(f"🔍 백링크 쿼리 실행 완료") + for link, source_doc, book in result.fetchall(): + print(f"📋 백링크 발견: {source_doc.title} -> {document.title}") + print(f" - 선택된 텍스트: {link.selected_text}") + print(f" - 링크 타입: {link.link_type}") + 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, + target_document_id=str(link.target_document_id), # 추가 + target_document_title=document.title, # 추가 selected_text=link.selected_text, start_offset=link.start_offset, end_offset=link.end_offset, @@ -469,6 +484,7 @@ async def get_document_backlinks( created_at=link.created_at.isoformat() )) + print(f"✅ 총 {len(backlinks)}개의 백링크 반환") return backlinks diff --git a/backend/src/api/routes/highlights.py b/backend/src/api/routes/highlights.py index 322eecb..11db165 100644 --- a/backend/src/api/routes/highlights.py +++ b/backend/src/api/routes/highlights.py @@ -63,6 +63,10 @@ class HighlightResponse(BaseModel): router = APIRouter() + + + + @router.post("/", response_model=HighlightResponse) async def create_highlight( highlight_data: CreateHighlightRequest, diff --git a/backend/src/api/routes/note_highlights.py b/backend/src/api/routes/note_highlights.py new file mode 100644 index 0000000..db0d75e --- /dev/null +++ b/backend/src/api/routes/note_highlights.py @@ -0,0 +1,103 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from ...core.database import get_sync_db +from ..dependencies import get_current_user +from ...models.user import User +from ...models.note_highlight import NoteHighlight, NoteHighlightCreate, NoteHighlightUpdate, NoteHighlightResponse +from ...models.note_document import NoteDocument + +router = APIRouter() + +@router.get("/note/{note_id}/highlights", response_model=List[NoteHighlightResponse]) +def get_note_highlights( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """특정 노트의 하이라이트 목록 조회""" + # 노트 존재 확인 + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # 하이라이트 조회 + highlights = db.query(NoteHighlight).filter( + NoteHighlight.note_id == note_id + ).order_by(NoteHighlight.start_offset).all() + + return [NoteHighlightResponse.from_orm(highlight) for highlight in highlights] + +@router.post("/note-highlights/", response_model=NoteHighlightResponse) +def create_note_highlight( + highlight_data: NoteHighlightCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 하이라이트 생성""" + # 노트 존재 확인 + note = db.query(NoteDocument).filter(NoteDocument.id == highlight_data.note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # 하이라이트 생성 + highlight = NoteHighlight( + note_id=highlight_data.note_id, + start_offset=highlight_data.start_offset, + end_offset=highlight_data.end_offset, + selected_text=highlight_data.selected_text, + highlight_color=highlight_data.highlight_color, + highlight_type=highlight_data.highlight_type, + created_by=current_user.email + ) + + db.add(highlight) + db.commit() + db.refresh(highlight) + + return NoteHighlightResponse.from_orm(highlight) + +@router.put("/note-highlights/{highlight_id}", response_model=NoteHighlightResponse) +def update_note_highlight( + highlight_id: str, + highlight_data: NoteHighlightUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 하이라이트 수정""" + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # 권한 확인 + if highlight.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # 업데이트 + for field, value in highlight_data.dict(exclude_unset=True).items(): + setattr(highlight, field, value) + + db.commit() + db.refresh(highlight) + + return NoteHighlightResponse.from_orm(highlight) + +@router.delete("/note-highlights/{highlight_id}") +def delete_note_highlight( + highlight_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 하이라이트 삭제""" + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # 권한 확인 + if highlight.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + db.delete(highlight) + db.commit() + + return {"message": "Highlight deleted successfully"} diff --git a/backend/src/api/routes/note_notes.py b/backend/src/api/routes/note_notes.py new file mode 100644 index 0000000..89fc733 --- /dev/null +++ b/backend/src/api/routes/note_notes.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, selectinload +from typing import List +from ...core.database import get_sync_db +from ..dependencies import get_current_user +from ...models.user import User +from ...models.note_note import NoteNote, NoteNoteCreate, NoteNoteUpdate, NoteNoteResponse +from ...models.note_document import NoteDocument +from ...models.note_highlight import NoteHighlight + +router = APIRouter() + +@router.get("/note/{note_id}/notes", response_model=List[NoteNoteResponse]) +def get_note_notes( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """특정 노트의 메모 목록 조회""" + # 노트 존재 확인 + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # 메모 조회 + notes = db.query(NoteNote).filter( + NoteNote.note_id == note_id + ).options( + selectinload(NoteNote.highlight) + ).order_by(NoteNote.created_at.desc()).all() + + return [NoteNoteResponse.from_orm(note) for note in notes] + +@router.get("/note-highlights/{highlight_id}/notes", response_model=List[NoteNoteResponse]) +def get_highlight_notes( + highlight_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """특정 하이라이트의 메모 목록 조회""" + # 하이라이트 존재 확인 + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # 메모 조회 + notes = db.query(NoteNote).filter( + NoteNote.highlight_id == highlight_id + ).order_by(NoteNote.created_at.desc()).all() + + return [NoteNoteResponse.from_orm(note) for note in notes] + +@router.post("/note-notes/", response_model=NoteNoteResponse) +def create_note_note( + note_data: NoteNoteCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 메모 생성""" + # 노트 존재 확인 + note = db.query(NoteDocument).filter(NoteDocument.id == note_data.note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # 하이라이트 존재 확인 (선택사항) + if note_data.highlight_id: + highlight = db.query(NoteHighlight).filter(NoteHighlight.id == note_data.highlight_id).first() + if not highlight: + raise HTTPException(status_code=404, detail="Highlight not found") + + # 메모 생성 + note_note = NoteNote( + note_id=note_data.note_id, + highlight_id=note_data.highlight_id, + content=note_data.content, + created_by=current_user.email + ) + + db.add(note_note) + db.commit() + db.refresh(note_note) + + return NoteNoteResponse.from_orm(note_note) + +@router.put("/note-notes/{note_note_id}", response_model=NoteNoteResponse) +def update_note_note( + note_note_id: str, + note_data: NoteNoteUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 메모 수정""" + note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first() + if not note_note: + raise HTTPException(status_code=404, detail="Note not found") + + # 권한 확인 + if note_note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # 업데이트 + for field, value in note_data.dict(exclude_unset=True).items(): + setattr(note_note, field, value) + + db.commit() + db.refresh(note_note) + + return NoteNoteResponse.from_orm(note_note) + +@router.delete("/note-notes/{note_note_id}") +def delete_note_note( + note_note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 메모 삭제""" + note_note = db.query(NoteNote).filter(NoteNote.id == note_note_id).first() + if not note_note: + raise HTTPException(status_code=404, detail="Note not found") + + # 권한 확인 + if note_note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + db.delete(note_note) + db.commit() + + return {"message": "Note deleted successfully"} diff --git a/backend/src/api/routes/notebooks.py b/backend/src/api/routes/notebooks.py new file mode 100644 index 0000000..42bbf90 --- /dev/null +++ b/backend/src/api/routes/notebooks.py @@ -0,0 +1,270 @@ +""" +노트북 (Notebook) 관리 API + +용어 정의: +- 노트북 (Notebook): 노트 문서들을 그룹화하는 폴더 +- 노트 (Note Document): 독립적인 HTML 기반 문서 작성 +- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API) +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, desc, asc, select +from typing import List, Optional + +from ...core.database import get_sync_db +from ...models.notebook import ( + Notebook, + NotebookCreate, + NotebookUpdate, + NotebookResponse, + NotebookListItem, + NotebookStats +) +from ...models.note_document import NoteDocument +from ...models.user import User +from ..dependencies import get_current_user + +router = APIRouter() + +@router.get("/", response_model=List[NotebookListItem]) +def get_notebooks( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + search: Optional[str] = Query(None), + active_only: bool = Query(True), + sort_by: str = Query("updated_at", regex="^(title|created_at|updated_at|sort_order)$"), + order: str = Query("desc", regex="^(asc|desc)$"), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트북 목록 조회""" + query = db.query(Notebook) + + # 필터링 + if search: + search_term = f"%{search}%" + query = query.filter( + (Notebook.title.ilike(search_term)) | + (Notebook.description.ilike(search_term)) + ) + + if active_only: + query = query.filter(Notebook.is_active == True) + + # 정렬 + if sort_by == 'title': + query = query.order_by(asc(Notebook.title) if order == 'asc' else desc(Notebook.title)) + elif sort_by == 'created_at': + query = query.order_by(asc(Notebook.created_at) if order == 'asc' else desc(Notebook.created_at)) + elif sort_by == 'sort_order': + query = query.order_by(asc(Notebook.sort_order) if order == 'asc' else desc(Notebook.sort_order)) + else: + query = query.order_by(desc(Notebook.updated_at)) + + # 페이지네이션 + notebooks = query.offset(skip).limit(limit).all() + + # 노트 개수 계산 + result = [] + for notebook in notebooks: + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + notebook_item = NotebookListItem.from_orm(notebook, note_count) + result.append(notebook_item) + + return result + +@router.get("/stats", response_model=NotebookStats) +def get_notebook_stats( + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트북 통계 정보""" + total_notebooks = db.query(func.count(Notebook.id)).scalar() + active_notebooks = db.query(func.count(Notebook.id)).filter( + Notebook.is_active == True + ).scalar() + + total_notes = db.query(func.count(NoteDocument.id)).scalar() + notes_without_notebook = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id.is_(None) + ).scalar() + + return NotebookStats( + total_notebooks=total_notebooks, + active_notebooks=active_notebooks, + total_notes=total_notes, + notes_without_notebook=notes_without_notebook + ) + +@router.get("/{notebook_id}", response_model=NotebookResponse) +def get_notebook( + notebook_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """특정 노트북 조회""" + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # 노트 개수 계산 + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + return NotebookResponse.from_orm(notebook, note_count) + +@router.post("/", response_model=NotebookResponse) +def create_notebook( + notebook_data: NotebookCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """새 노트북 생성""" + notebook = Notebook( + title=notebook_data.title, + description=notebook_data.description, + color=notebook_data.color, + icon=notebook_data.icon, + is_active=notebook_data.is_active, + sort_order=notebook_data.sort_order, + created_by=current_user.email + ) + + db.add(notebook) + db.commit() + db.refresh(notebook) + + return NotebookResponse.from_orm(notebook, 0) + +@router.put("/{notebook_id}", response_model=NotebookResponse) +def update_notebook( + notebook_id: str, + notebook_data: NotebookUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트북 업데이트""" + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # 업데이트할 필드만 적용 + update_data = notebook_data.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(notebook, field, value) + + db.commit() + db.refresh(notebook) + + # 노트 개수 계산 + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + return NotebookResponse.from_orm(notebook, note_count) + +@router.delete("/{notebook_id}") +def delete_notebook( + notebook_id: str, + force: bool = Query(False, description="강제 삭제 (노트가 있어도 삭제)"), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트북 삭제""" + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # 노트북에 포함된 노트 확인 + note_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.notebook_id == notebook.id + ).scalar() + + if note_count > 0 and not force: + raise HTTPException( + status_code=400, + detail=f"Cannot delete notebook with {note_count} notes. Use force=true to delete anyway." + ) + + if force and note_count > 0: + # 노트들의 notebook_id를 NULL로 설정 (기본 노트북으로 이동) + db.query(NoteDocument).filter( + NoteDocument.notebook_id == notebook.id + ).update({NoteDocument.notebook_id: None}) + + db.delete(notebook) + db.commit() + + return {"message": "Notebook deleted successfully"} + +@router.get("/{notebook_id}/notes") +def get_notebook_notes( + notebook_id: str, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트북에 포함된 노트들 조회""" + # 노트북 존재 확인 + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + # 노트들 조회 + notes = db.query(NoteDocument).filter( + NoteDocument.notebook_id == notebook_id + ).order_by(desc(NoteDocument.updated_at)).offset(skip).limit(limit).all() + + return notes + +@router.post("/{notebook_id}/notes/{note_id}") +def add_note_to_notebook( + notebook_id: str, + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트를 노트북에 추가""" + # 노트북과 노트 존재 확인 + notebook = db.query(Notebook).filter(Notebook.id == notebook_id).first() + if not notebook: + raise HTTPException(status_code=404, detail="Notebook not found") + + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # 노트를 노트북에 할당 + note.notebook_id = notebook_id + db.commit() + + return {"message": "Note added to notebook successfully"} + +@router.delete("/{notebook_id}/notes/{note_id}") +def remove_note_from_notebook( + notebook_id: str, + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트를 노트북에서 제거""" + note = db.query(NoteDocument).filter( + NoteDocument.id == note_id, + NoteDocument.notebook_id == notebook_id + ).first() + + if not note: + raise HTTPException(status_code=404, detail="Note not found in this notebook") + + # 노트북에서 제거 (기본 노트북으로 이동) + note.notebook_id = None + db.commit() + + return {"message": "Note removed from notebook successfully"} diff --git a/backend/src/api/routes/notes.py b/backend/src/api/routes/notes.py index c40f1e8..f92243d 100644 --- a/backend/src/api/routes/notes.py +++ b/backend/src/api/routes/notes.py @@ -1,452 +1,452 @@ """ -메모 관리 API 라우터 +노트 문서 (Note Document) 관리 API + +용어 정의: +- 노트 (Note Document): 독립적인 HTML 기반 문서 작성 +- 노트북 (Notebook): 노트들을 그룹화하는 폴더 +- 메모 (Memo): 하이라이트에 달리는 짧은 코멘트 (별도 API - highlights.py) """ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete, and_, or_ -from sqlalchemy.orm import selectinload, joinedload + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, selectinload +from sqlalchemy import func, desc, asc, select from typing import List, Optional -from datetime import datetime +# import markdown # 임시로 비활성화 +import re +from datetime import datetime, timedelta -from ...core.database import get_db +from ...core.database import get_sync_db +from ...models.note_document import ( + NoteDocument, + NoteDocumentCreate, + NoteDocumentUpdate, + NoteDocumentResponse, + NoteDocumentListItem, + NoteStats +) from ...models.user import User -from ...models.highlight import Highlight -from ...models.note import Note -from ...models.document import Document -from ..dependencies import get_current_active_user -from pydantic import BaseModel - - -class CreateNoteRequest(BaseModel): - """메모 생성 요청""" - highlight_id: str - content: str - tags: Optional[List[str]] = None - - -class UpdateNoteRequest(BaseModel): - """메모 업데이트 요청""" - content: Optional[str] = None - tags: Optional[List[str]] = None - is_private: Optional[bool] = None - - -class NoteResponse(BaseModel): - """메모 응답""" - id: str - user_id: str - highlight_id: str - content: str - is_private: bool - tags: Optional[List[str]] - created_at: datetime - updated_at: Optional[datetime] - # 연결된 하이라이트 정보 - highlight: dict - # 문서 정보 - document: dict - - class Config: - from_attributes = True - +from ..dependencies import get_current_user router = APIRouter() +# === 하이라이트 메모 (Highlight Memo) API === +# 용어 정의: 하이라이트에 달리는 짧은 코멘트 -@router.post("/", response_model=NoteResponse) +@router.post("/") async def create_note( - note_data: CreateNoteRequest, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) + note_data: dict, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """메모 생성 (하이라이트에 연결)""" - # 하이라이트 존재 및 소유권 확인 - result = await db.execute( - select(Highlight) - .options(joinedload(Highlight.document)) - .where(Highlight.id == note_data.highlight_id) - ) - highlight = result.scalar_one_or_none() + """하이라이트 메모 생성""" + from ...models.note import Note + from ...models.highlight import Highlight + + # 하이라이트 소유권 확인 + highlight = db.query(Highlight).filter( + Highlight.id == note_data.get('highlight_id'), + Highlight.user_id == current_user.id + ).first() if not highlight: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Highlight not found" - ) - - # 하이라이트 소유자 확인 - if highlight.user_id != current_user.id and not current_user.is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not enough permissions" - ) - - # 중복 확인 제거 - 하나의 하이라이트에 여러 메모 허용 + raise HTTPException(status_code=404, detail="하이라이트를 찾을 수 없습니다") # 메모 생성 note = Note( - highlight_id=note_data.highlight_id, - content=note_data.content, - tags=note_data.tags or [] + highlight_id=note_data.get('highlight_id'), + content=note_data.get('content', ''), + is_private=note_data.get('is_private', False), + tags=note_data.get('tags', []) ) db.add(note) - await db.commit() - await db.refresh(note) + db.commit() + db.refresh(note) - # 응답 데이터 생성 - response_data = NoteResponse( - id=str(note.id), - user_id=str(note.highlight.user_id), - highlight_id=str(note.highlight_id), - content=note.content, - is_private=note.is_private, - tags=note.tags, - created_at=note.created_at, - updated_at=note.updated_at, - highlight={}, - document={} - ) - response_data.highlight = { - "id": str(highlight.id), - "selected_text": highlight.selected_text, - "highlight_color": highlight.highlight_color, - "start_offset": highlight.start_offset, - "end_offset": highlight.end_offset - } - response_data.document = { - "id": str(highlight.document.id), - "title": highlight.document.title - } - - return response_data + return note - -@router.get("/", response_model=List[NoteResponse]) -async def list_user_notes( - skip: int = 0, - limit: int = 50, - document_id: Optional[str] = None, - tag: Optional[str] = None, - search: Optional[str] = None, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) +@router.get("/document/{document_id}") +async def get_document_notes( + document_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """사용자의 모든 메모 조회 (검색 가능)""" - query = ( - select(Note) - .options( - joinedload(Note.highlight).joinedload(Highlight.document) - ) - .join(Highlight) - .where(Highlight.user_id == current_user.id) - ) + """특정 문서의 모든 하이라이트 메모 조회""" + from ...models.note import Note + from ...models.highlight import Highlight - # 문서 필터링 - if document_id: - query = query.where(Highlight.document_id == document_id) + notes = db.query(Note).join(Highlight).filter( + Highlight.document_id == document_id, + Highlight.user_id == current_user.id + ).options( + selectinload(Note.highlight) + ).all() - # 태그 필터링 - if tag: - query = query.where(Note.tags.contains([tag])) + return notes + +def clean_html_content(content: str) -> str: + """HTML 내용 정리 및 검증""" + if not content: + return "" + + # 기본적인 HTML 정리 (나중에 더 정교하게 할 수 있음) + return content.strip() + +def calculate_reading_time(content: str) -> int: + """읽기 시간 계산 (분 단위)""" + if not content: + return 0 + + # 단어 수 계산 (한글, 영문 모두 고려) + korean_chars = len(re.findall(r'[가-힣]', content)) + english_words = len(re.findall(r'\b[a-zA-Z]+\b', content)) + + # 한글: 분당 500자, 영문: 분당 200단어 기준 + korean_time = korean_chars / 500 + english_time = english_words / 200 + + total_minutes = max(1, int(korean_time + english_time)) + return total_minutes + +def calculate_word_count(content: str) -> int: + """단어/글자 수 계산""" + if not content: + return 0 + + korean_chars = len(re.findall(r'[가-힣]', content)) + english_words = len(re.findall(r'\b[a-zA-Z]+\b', content)) + + return korean_chars + english_words + +@router.get("/", response_model=List[NoteDocumentListItem]) +def get_notes( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + note_type: Optional[str] = Query(None), + tags: Optional[str] = Query(None), # 쉼표로 구분된 태그 + search: Optional[str] = Query(None), + published_only: bool = Query(False), + parent_id: Optional[str] = Query(None), + notebook_id: Optional[str] = Query(None), # 노트북 필터 + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 목록 조회""" + # 동기 SQLAlchemy 스타일 + query = db.query(NoteDocument) + + # 필터링 + if note_type: + query = query.filter(NoteDocument.note_type == note_type) + + if tags: + tag_list = [tag.strip() for tag in tags.split(',')] + query = query.filter(NoteDocument.tags.overlap(tag_list)) - # 검색 필터링 (메모 내용 + 하이라이트된 텍스트) if search: - query = query.where( - or_( - Note.content.ilike(f"%{search}%"), - Highlight.selected_text.ilike(f"%{search}%") - ) + search_term = f"%{search}%" + query = query.filter( + (NoteDocument.title.ilike(search_term)) | + (NoteDocument.content.ilike(search_term)) ) - query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit) + if published_only: + query = query.filter(NoteDocument.is_published == True) - result = await db.execute(query) - notes = result.scalars().all() + if notebook_id: + if notebook_id == 'null': + # 미분류 노트 (notebook_id가 None인 것들) + query = query.filter(NoteDocument.notebook_id.is_(None)) + else: + query = query.filter(NoteDocument.notebook_id == notebook_id) - # 응답 데이터 변환 - response_data = [] + if parent_id: + query = query.filter(NoteDocument.parent_note_id == parent_id) + else: + # 최상위 노트만 (parent_id가 None인 것들) + query = query.filter(NoteDocument.parent_note_id.is_(None)) + + # 정렬 및 페이징 + query = query.order_by(desc(NoteDocument.updated_at)) + notes = query.offset(skip).limit(limit).all() + + # 자식 노트 개수 계산 + result = [] for note in notes: - note_data = NoteResponse( - id=str(note.id), - user_id=str(note.highlight.user_id), - highlight_id=str(note.highlight_id), - content=note.content, - is_private=note.is_private, - tags=note.tags, - created_at=note.created_at, - updated_at=note.updated_at, - highlight={}, - document={} - ) - note_data.highlight = { - "id": str(note.highlight.id), - "selected_text": note.highlight.selected_text, - "highlight_color": note.highlight.highlight_color, - "start_offset": note.highlight.start_offset, - "end_offset": note.highlight.end_offset - } - note_data.document = { - "id": str(note.highlight.document.id), - "title": note.highlight.document.title - } - response_data.append(note_data) + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == note.id + ).scalar() + + note_item = NoteDocumentListItem.from_orm(note, child_count) + result.append(note_item) - return response_data + return result - -@router.get("/{note_id}", response_model=NoteResponse) -async def get_note( - note_id: str, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) +@router.get("/stats", response_model=NoteStats) +def get_note_stats( + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """메모 상세 조회""" - result = await db.execute( - select(Note) - .options( - joinedload(Note.highlight).joinedload(Highlight.document) - ) - .where(Note.id == note_id) + """노트 통계 정보""" + total_notes = db.query(func.count(NoteDocument.id)).scalar() + published_notes = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.is_published == True + ).scalar() + draft_notes = total_notes - published_notes + + # 노트 타입별 통계 + type_stats = db.query( + NoteDocument.note_type, + func.count(NoteDocument.id) + ).group_by(NoteDocument.note_type).all() + + note_types = {note_type: count for note_type, count in type_stats} + + # 총 단어 수와 읽기 시간 + totals = db.query( + func.sum(NoteDocument.word_count), + func.sum(NoteDocument.reading_time) + ).first() + + total_words = totals[0] or 0 + total_reading_time = totals[1] or 0 + + # 최근 노트 (5개) + recent_notes_query = db.query(NoteDocument).order_by( + desc(NoteDocument.updated_at) + ).limit(5) + + recent_notes = [] + for note in recent_notes_query.all(): + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == note.id + ).scalar() + + note_item = NoteDocumentListItem.from_orm(note, child_count) + recent_notes.append(note_item) + + return NoteStats( + total_notes=total_notes, + published_notes=published_notes, + draft_notes=draft_notes, + note_types=note_types, + total_words=total_words, + total_reading_time=total_reading_time, + recent_notes=recent_notes ) - note = result.scalar_one_or_none() - - if not note: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found" - ) - - # 소유자 확인 - if note.highlight.user_id != current_user.id and not current_user.is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not enough permissions" - ) - - response_data = NoteResponse( - id=str(note.id), - user_id=str(note.highlight.user_id), - highlight_id=str(note.highlight_id), - content=note.content, - is_private=note.is_private, - tags=note.tags, - created_at=note.created_at, - updated_at=note.updated_at, - highlight={}, - document={} - ) - response_data.highlight = { - "id": str(note.highlight.id), - "selected_text": note.highlight.selected_text, - "highlight_color": note.highlight.highlight_color, - "start_offset": note.highlight.start_offset, - "end_offset": note.highlight.end_offset - } - response_data.document = { - "id": str(note.highlight.document.id), - "title": note.highlight.document.title - } - - return response_data - -@router.put("/{note_id}", response_model=NoteResponse) -async def update_note( +@router.get("/{note_id}", response_model=NoteDocumentResponse) +def get_note( note_id: str, - note_data: UpdateNoteRequest, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """메모 업데이트""" - result = await db.execute( - select(Note) - .options( - joinedload(Note.highlight).joinedload(Highlight.document) - ) - .where(Note.id == note_id) - ) - note = result.scalar_one_or_none() - + """특정 노트 조회""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() if not note: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found" - ) + raise HTTPException(status_code=404, detail="Note not found") - # 소유자 확인 - if note.highlight.user_id != current_user.id and not current_user.is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not enough permissions" - ) - - # 업데이트 - if note_data.content is not None: - note.content = note_data.content - if note_data.tags is not None: - note.tags = note_data.tags - if note_data.is_private is not None: - note.is_private = note_data.is_private - - await db.commit() - await db.refresh(note) - - response_data = NoteResponse( - id=str(note.id), - user_id=str(note.highlight.user_id), - highlight_id=str(note.highlight_id), - content=note.content, - is_private=note.is_private, - tags=note.tags, - created_at=note.created_at, - updated_at=note.updated_at, - highlight={}, - document={} - ) - response_data.highlight = { - "id": str(note.highlight.id), - "selected_text": note.highlight.selected_text, - "highlight_color": note.highlight.highlight_color, - "start_offset": note.highlight.start_offset, - "end_offset": note.highlight.end_offset - } - response_data.document = { - "id": str(note.highlight.document.id), - "title": note.highlight.document.title - } - - return response_data + return NoteDocumentResponse.from_orm(note) +@router.post("/", response_model=NoteDocumentResponse) +def create_note( + note_data: NoteDocumentCreate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """새 노트 생성""" + # HTML 내용 정리 + cleaned_content = clean_html_content(note_data.content or "") + + # 통계 계산 + word_count = calculate_word_count(note_data.content or "") + reading_time = calculate_reading_time(note_data.content or "") + + note = NoteDocument( + title=note_data.title, + content=cleaned_content, + note_type=note_data.note_type, + tags=note_data.tags, + is_published=note_data.is_published, + parent_note_id=note_data.parent_note_id, + sort_order=note_data.sort_order, + word_count=word_count, + reading_time=reading_time, + created_by=current_user.email + ) + + db.add(note) + db.commit() + db.refresh(note) + + return NoteDocumentResponse.from_orm(note) + +@router.put("/{note_id}", response_model=NoteDocumentResponse) +def update_note( + note_id: str, + note_data: NoteDocumentUpdate, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트 수정""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # 수정 권한 확인 (작성자만 수정 가능) + if note.created_by != current_user.username and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") + + # 필드 업데이트 + update_data = note_data.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(note, field, value) + + # 내용이 변경된 경우 통계 재계산 + if 'content' in update_data: + note.content = clean_html_content(note.content or "") + note.word_count = calculate_word_count(note.content or "") + note.reading_time = calculate_reading_time(note.content or "") + + db.commit() + db.refresh(note) + + return NoteDocumentResponse.from_orm(note) @router.delete("/{note_id}") -async def delete_note( +def delete_note( note_id: str, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """메모 삭제 (하이라이트는 유지)""" - result = await db.execute( - select(Note) - .options(joinedload(Note.highlight)) - .where(Note.id == note_id) - ) - note = result.scalar_one_or_none() - + """노트 삭제""" + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() if not note: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found" - ) + raise HTTPException(status_code=404, detail="Note not found") - # 소유자 확인 - if note.highlight.user_id != current_user.id and not current_user.is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not enough permissions" - ) + # 삭제 권한 확인 + if note.created_by != current_user.email and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Permission denied") - # 메모만 삭제 (하이라이트는 유지) - await db.execute(delete(Note).where(Note.id == note_id)) - await db.commit() + # 자식 노트들의 parent_note_id를 NULL로 설정 + db.query(NoteDocument).filter( + NoteDocument.parent_note_id == note_id + ).update({"parent_note_id": None}) + + db.delete(note) + db.commit() return {"message": "Note deleted successfully"} - -@router.get("/document/{document_id}", response_model=List[NoteResponse]) -async def get_document_notes( - document_id: str, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) +@router.get("/{note_id}/children", response_model=List[NoteDocumentListItem]) +async def get_note_children( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """특정 문서의 모든 메모 조회""" - # 문서 존재 및 권한 확인 - result = await db.execute(select(Document).where(Document.id == document_id)) - document = result.scalar_one_or_none() + """노트의 자식 노트들 조회""" + children = db.query(NoteDocument).filter( + NoteDocument.parent_note_id == note_id + ).order_by(asc(NoteDocument.sort_order), desc(NoteDocument.updated_at)).all() - if not document: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Document not found" - ) + result = [] + for child in children: + child_count = db.query(func.count(NoteDocument.id)).filter( + NoteDocument.parent_note_id == child.id + ).scalar() + + child_item = NoteDocumentListItem.from_orm(child) + child_item.child_count = child_count + result.append(child_item) - # 문서 접근 권한 확인 - 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="Not enough permissions to access this document" - ) - - # 해당 문서의 사용자 메모 조회 - result = await db.execute( - select(Note) - .options( - joinedload(Note.highlight).joinedload(Highlight.document) - ) - .join(Highlight) - .where( - and_( - Highlight.document_id == document_id, - Highlight.user_id == current_user.id - ) - ) - .order_by(Highlight.start_offset) - ) - notes = result.scalars().all() - - # 응답 데이터 변환 - response_data = [] - for note in notes: - note_data = NoteResponse( - id=str(note.id), - user_id=str(note.highlight.user_id), - highlight_id=str(note.highlight_id), - content=note.content, - is_private=note.is_private, - tags=note.tags, - created_at=note.created_at, - updated_at=note.updated_at, - highlight={}, - document={} - ) - note_data.highlight = { - "id": str(note.highlight.id), - "selected_text": note.highlight.selected_text, - "highlight_color": note.highlight.highlight_color, - "start_offset": note.highlight.start_offset, - "end_offset": note.highlight.end_offset - } - note_data.document = { - "id": str(note.highlight.document.id), - "title": note.highlight.document.title - } - response_data.append(note_data) - - return response_data + return result - -@router.get("/tags/popular") -async def get_popular_note_tags( - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db) +@router.get("/{note_id}/export/html") +async def export_note_html( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) ): - """인기 메모 태그 조회""" - # 사용자의 메모에서 태그 빈도 계산 - result = await db.execute( - select(Note) - .join(Highlight) - .where(Highlight.user_id == current_user.id) + """노트를 HTML 파일로 내보내기""" + from fastapi.responses import Response + + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + + # HTML 템플릿 생성 + html_template = f""" + + + + + {note.title} + + + +
+ 제목: {note.title}
+ 타입: {note.note_type}
+ 작성자: {note.created_by}
+ 작성일: {note.created_at.strftime('%Y-%m-%d %H:%M')}
+ 태그: {', '.join(note.tags) if note.tags else '없음'} +
+
+ {note.content or ''} + +""" + + filename = f"{note.title.replace(' ', '_')}.html" + + return Response( + content=html_template, + media_type="text/html", + headers={"Content-Disposition": f"attachment; filename={filename}"} ) - notes = result.scalars().all() + +@router.get("/{note_id}/export/markdown") +async def export_note_markdown( + note_id: str, + db: Session = Depends(get_sync_db), + current_user: User = Depends(get_current_user) +): + """노트를 마크다운 파일로 내보내기""" + from fastapi.responses import Response - # 태그 빈도 계산 - tag_counts = {} - for note in notes: - if note.tags: - for tag in note.tags: - tag_counts[tag] = tag_counts.get(tag, 0) + 1 + note = db.query(NoteDocument).filter(NoteDocument.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") - # 빈도순 정렬 - popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10] + # 메타데이터 포함한 마크다운 + markdown_content = f"""--- +title: {note.title} +type: {note.note_type} +author: {note.created_by} +created: {note.created_at.strftime('%Y-%m-%d %H:%M')} +tags: [{', '.join(note.tags) if note.tags else ''}] +--- + +# {note.title} + +{note.content or ''} +""" - return [{"tag": tag, "count": count} for tag, count in popular_tags] + filename = f"{note.title.replace(' ', '_')}.md" + + return Response( + content=markdown_content, + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) \ No newline at end of file diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 7f19bdf..46eecbc 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -2,9 +2,9 @@ 데이터베이스 설정 및 연결 """ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy.orm import DeclarativeBase -from sqlalchemy import MetaData -from typing import AsyncGenerator +from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session +from sqlalchemy import MetaData, create_engine +from typing import AsyncGenerator, Generator from .config import settings @@ -35,6 +35,15 @@ engine = create_async_engine( pool_recycle=300, ) +# 동기 데이터베이스 엔진 생성 (노트 API용) +sync_database_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") +sync_engine = create_engine( + sync_database_url, + echo=settings.DEBUG, + pool_pre_ping=True, + pool_recycle=300, +) + # 비동기 세션 팩토리 AsyncSessionLocal = async_sessionmaker( engine, @@ -42,9 +51,16 @@ AsyncSessionLocal = async_sessionmaker( expire_on_commit=False, ) +# 동기 세션 팩토리 +SyncSessionLocal = sessionmaker( + sync_engine, + class_=Session, + expire_on_commit=False, +) + async def get_db() -> AsyncGenerator[AsyncSession, None]: - """데이터베이스 세션 의존성""" + """비동기 데이터베이스 세션 의존성""" async with AsyncSessionLocal() as session: try: yield session @@ -55,6 +71,18 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: await session.close() +def get_sync_db() -> Generator[Session, None, None]: + """동기 데이터베이스 세션 의존성 (노트 API용)""" + session = SyncSessionLocal() + try: + yield session + except Exception: + session.rollback() + raise + finally: + session.close() + + async def init_db() -> None: """데이터베이스 초기화""" from ..models import user, document, highlight, note, bookmark diff --git a/backend/src/main.py b/backend/src/main.py index 35cf9f6..2195666 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -9,7 +9,8 @@ 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, document_links +from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes +from .api.routes.notes import router as note_documents_router @asynccontextmanager @@ -53,6 +54,10 @@ 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.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"]) +app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"]) +app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"]) +app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"]) @app.get("/") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index e194446..21e0049 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -8,6 +8,10 @@ from .highlight import Highlight from .note import Note from .bookmark import Bookmark from .document_link import DocumentLink +from .note_document import NoteDocument +from .notebook import Notebook +from .note_highlight import NoteHighlight +from .note_note import NoteNote __all__ = [ "User", @@ -18,4 +22,8 @@ __all__ = [ "Note", "Bookmark", "DocumentLink", + "NoteDocument", + "Notebook", + "NoteHighlight", + "NoteNote" ] diff --git a/backend/src/models/note_document.py b/backend/src/models/note_document.py new file mode 100644 index 0000000..bdb9227 --- /dev/null +++ b/backend/src/models/note_document.py @@ -0,0 +1,148 @@ +from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +import uuid + +from ..core.database import Base + +class NoteDocument(Base): + """노트 문서 모델""" + __tablename__ = "notes_documents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False) + content = Column(Text) # HTML 내용 (기본) + markdown_content = Column(Text) # 마크다운 내용 (선택사항) + note_type = Column(String(50), default='note') # note, research, summary, idea 등 + tags = Column(ARRAY(String), default=[]) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + is_published = Column(Boolean, default=False) + parent_note_id = Column(UUID(as_uuid=True), ForeignKey('notes_documents.id'), nullable=True) + notebook_id = Column(UUID(as_uuid=True), ForeignKey('notebooks.id'), nullable=True) + sort_order = Column(Integer, default=0) + + # 관계 설정 + notebook = relationship("Notebook", back_populates="notes") + highlights = relationship("NoteHighlight", back_populates="note", cascade="all, delete-orphan") + notes = relationship("NoteNote", back_populates="note", cascade="all, delete-orphan") + word_count = Column(Integer, default=0) + reading_time = Column(Integer, default=0) # 예상 읽기 시간 (분) + + # 관계 + parent_note = relationship("NoteDocument", remote_side=[id], back_populates="child_notes") + child_notes = relationship("NoteDocument", back_populates="parent_note") + +# Pydantic 모델들 +class NoteDocumentBase(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + content: Optional[str] = None + note_type: str = Field(default='note', pattern='^(note|research|summary|idea|guide|reference)$') + tags: List[str] = Field(default=[]) + is_published: bool = Field(default=False) + parent_note_id: Optional[str] = None + sort_order: int = Field(default=0) + +class NoteDocumentCreate(NoteDocumentBase): + pass + +class NoteDocumentUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=500) + content: Optional[str] = None + note_type: Optional[str] = Field(None, pattern='^(note|research|summary|idea|guide|reference)$') + tags: Optional[List[str]] = None + is_published: Optional[bool] = None + parent_note_id: Optional[str] = None + sort_order: Optional[int] = None + +class NoteDocumentResponse(NoteDocumentBase): + id: str + markdown_content: Optional[str] = None + created_at: datetime + updated_at: datetime + created_by: str + word_count: int + reading_time: int + + # 계층 구조 정보 + parent_note: Optional['NoteDocumentResponse'] = None + child_notes: List['NoteDocumentResponse'] = [] + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + """ORM 객체에서 Pydantic 모델로 변환""" + data = { + 'id': str(obj.id), # UUID를 문자열로 변환 + 'title': obj.title, + 'content': obj.content, + 'note_type': obj.note_type, + 'tags': obj.tags or [], + 'is_published': obj.is_published, + 'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None, + 'sort_order': obj.sort_order, + 'markdown_content': obj.markdown_content, + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'word_count': obj.word_count or 0, + 'reading_time': obj.reading_time or 0, + } + return cls(**data) + +# 자기 참조 관계를 위한 모델 업데이트 +NoteDocumentResponse.model_rebuild() + +class NoteDocumentListItem(BaseModel): + """노트 목록용 간소화된 모델""" + id: str + title: str + note_type: str + tags: List[str] + created_at: datetime + updated_at: datetime + created_by: str + is_published: bool + word_count: int + reading_time: int + parent_note_id: Optional[str] = None + child_count: int = 0 # 자식 노트 개수 + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj, child_count=0): + """ORM 객체에서 Pydantic 모델로 변환""" + data = { + 'id': str(obj.id), # UUID를 문자열로 변환 + 'title': obj.title, + 'note_type': obj.note_type, + 'tags': obj.tags or [], + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'is_published': obj.is_published, + 'word_count': obj.word_count or 0, + 'reading_time': obj.reading_time or 0, + 'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None, + 'child_count': child_count, + } + return cls(**data) + +class NoteStats(BaseModel): + """노트 통계 정보""" + total_notes: int + published_notes: int + draft_notes: int + note_types: dict # {type: count} + total_words: int + total_reading_time: int + recent_notes: List[NoteDocumentListItem] diff --git a/backend/src/models/note_highlight.py b/backend/src/models/note_highlight.py new file mode 100644 index 0000000..9aacc97 --- /dev/null +++ b/backend/src/models/note_highlight.py @@ -0,0 +1,69 @@ +from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +import uuid + +from ..core.database import Base + +class NoteHighlight(Base): + """노트 하이라이트 모델""" + __tablename__ = "note_highlights" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False) + start_offset = Column(Integer, nullable=False) + end_offset = Column(Integer, nullable=False) + selected_text = Column(Text, nullable=False) + highlight_color = Column(String(50), nullable=False, default='#FFFF00') + highlight_type = Column(String(50), nullable=False, default='highlight') + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + + # 관계 + note = relationship("NoteDocument", back_populates="highlights") + notes = relationship("NoteNote", back_populates="highlight", cascade="all, delete-orphan") + +# Pydantic 모델들 +class NoteHighlightBase(BaseModel): + note_id: str + start_offset: int + end_offset: int + selected_text: str + highlight_color: str = '#FFFF00' + highlight_type: str = 'highlight' + +class NoteHighlightCreate(NoteHighlightBase): + pass + +class NoteHighlightUpdate(BaseModel): + highlight_color: Optional[str] = None + highlight_type: Optional[str] = None + +class NoteHighlightResponse(NoteHighlightBase): + id: str + created_at: datetime + updated_at: datetime + created_by: str + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + return cls( + id=str(obj.id), + note_id=str(obj.note_id), + start_offset=obj.start_offset, + end_offset=obj.end_offset, + selected_text=obj.selected_text, + highlight_color=obj.highlight_color, + highlight_type=obj.highlight_type, + created_at=obj.created_at, + updated_at=obj.updated_at, + created_by=obj.created_by + ) diff --git a/backend/src/models/note_note.py b/backend/src/models/note_note.py new file mode 100644 index 0000000..5861dfd --- /dev/null +++ b/backend/src/models/note_note.py @@ -0,0 +1,59 @@ +from sqlalchemy import Column, String, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +import uuid + +from ..core.database import Base + +class NoteNote(Base): + """노트의 메모 모델 (노트 안의 하이라이트에 대한 메모)""" + __tablename__ = "note_notes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + note_id = Column(UUID(as_uuid=True), ForeignKey("notes_documents.id", ondelete="CASCADE"), nullable=False) + highlight_id = Column(UUID(as_uuid=True), ForeignKey("note_highlights.id", ondelete="CASCADE"), nullable=True) + content = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + + # 관계 + note = relationship("NoteDocument", back_populates="notes") + highlight = relationship("NoteHighlight", back_populates="notes") + +# Pydantic 모델들 +class NoteNoteBase(BaseModel): + note_id: str + highlight_id: Optional[str] = None + content: str + +class NoteNoteCreate(NoteNoteBase): + pass + +class NoteNoteUpdate(BaseModel): + content: Optional[str] = None + +class NoteNoteResponse(NoteNoteBase): + id: str + created_at: datetime + updated_at: datetime + created_by: str + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj): + return cls( + id=str(obj.id), + note_id=str(obj.note_id), + highlight_id=str(obj.highlight_id) if obj.highlight_id else None, + content=obj.content, + created_at=obj.created_at, + updated_at=obj.updated_at, + created_by=obj.created_by + ) diff --git a/backend/src/models/notebook.py b/backend/src/models/notebook.py new file mode 100644 index 0000000..2494caa --- /dev/null +++ b/backend/src/models/notebook.py @@ -0,0 +1,126 @@ +""" +노트북 모델 +""" +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +import uuid + +from ..core.database import Base + + +class Notebook(Base): + """노트북 테이블""" + __tablename__ = "notebooks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(500), nullable=False) + description = Column(Text, nullable=True) + color = Column(String(7), default='#3B82F6') # 헥스 컬러 코드 + icon = Column(String(50), default='book') # FontAwesome 아이콘 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String(100), nullable=False) + is_active = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + + # 관계 설정 (노트들) + notes = relationship("NoteDocument", back_populates="notebook") + + +# Pydantic 모델들 +class NotebookBase(BaseModel): + title: str = Field(..., min_length=1, max_length=500) + description: Optional[str] = None + color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$') + icon: str = Field(default='book', min_length=1, max_length=50) + is_active: bool = True + sort_order: int = 0 + + +class NotebookCreate(NotebookBase): + pass + + +class NotebookUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=500) + description: Optional[str] = None + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + icon: Optional[str] = Field(None, min_length=1, max_length=50) + is_active: Optional[bool] = None + sort_order: Optional[int] = None + + +class NotebookResponse(NotebookBase): + id: str + created_at: datetime + updated_at: datetime + created_by: str + note_count: int = 0 # 포함된 노트 개수 + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj, note_count=0): + """ORM 객체에서 Pydantic 모델로 변환""" + data = { + 'id': str(obj.id), + 'title': obj.title, + 'description': obj.description, + 'color': obj.color, + 'icon': obj.icon, + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'is_active': obj.is_active, + 'sort_order': obj.sort_order, + 'note_count': note_count, + } + return cls(**data) + + +class NotebookListItem(BaseModel): + """노트북 목록용 간소화된 모델""" + id: str + title: str + description: Optional[str] + color: str + icon: str + created_at: datetime + updated_at: datetime + created_by: str + is_active: bool + note_count: int = 0 + + class Config: + from_attributes = True + + @classmethod + def from_orm(cls, obj, note_count=0): + """ORM 객체에서 Pydantic 모델로 변환""" + data = { + 'id': str(obj.id), + 'title': obj.title, + 'description': obj.description, + 'color': obj.color, + 'icon': obj.icon, + 'created_at': obj.created_at, + 'updated_at': obj.updated_at, + 'created_by': obj.created_by, + 'is_active': obj.is_active, + 'note_count': note_count, + } + return cls(**data) + + +class NotebookStats(BaseModel): + """노트북 통계 정보""" + total_notebooks: int + active_notebooks: int + total_notes: int + notes_without_notebook: int diff --git a/frontend/components/header.html b/frontend/components/header.html index 8daa2fb..cd02b78 100644 --- a/frontend/components/header.html +++ b/frontend/components/header.html @@ -8,8 +8,8 @@

Document Server

- -