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- 내보내기 기능',
+ '
노트 시스템 사용법
기본 기능
마크다운으로 노트 작성
HTML로 자동 변환
태그 기반 분류
고급 기능
서적과 링크 연결
계층 구조 지원
내보내기 기능
',
+ '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}
+
+
+
+