🐛 Fix Alpine.js SyntaxError and backlink visibility issues
- Fix SyntaxError in viewer.js line 2868 (.bind(this) issue in setTimeout) - Resolve Alpine.js 'Can't find variable' errors (documentViewer, goBack, etc.) - Fix backlink rendering and persistence during temporary highlights - Add backlink protection and restoration mechanism in highlightAndScrollToText - Implement Note Management System with hierarchical notebooks - Add note highlights and memos functionality - Update cache version to force browser refresh (v=2025012641) - Add comprehensive logging for debugging backlink issues
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ class HighlightResponse(BaseModel):
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/", response_model=HighlightResponse)
|
||||
async def create_highlight(
|
||||
highlight_data: CreateHighlightRequest,
|
||||
|
||||
103
backend/src/api/routes/note_highlights.py
Normal file
103
backend/src/api/routes/note_highlights.py
Normal file
@@ -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"}
|
||||
128
backend/src/api/routes/note_notes.py
Normal file
128
backend/src/api/routes/note_notes.py
Normal file
@@ -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"}
|
||||
270
backend/src/api/routes/notebooks.py
Normal file
270
backend/src/api/routes/notebooks.py
Normal file
@@ -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"}
|
||||
@@ -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"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{note.title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }}
|
||||
h1, h2, h3 {{ color: #333; }}
|
||||
code {{ background: #f4f4f4; padding: 2px 4px; border-radius: 3px; }}
|
||||
pre {{ background: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }}
|
||||
blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 20px; color: #666; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
||||
th {{ background-color: #f2f2f2; }}
|
||||
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="meta">
|
||||
<strong>제목:</strong> {note.title}<br>
|
||||
<strong>타입:</strong> {note.note_type}<br>
|
||||
<strong>작성자:</strong> {note.created_by}<br>
|
||||
<strong>작성일:</strong> {note.created_at.strftime('%Y-%m-%d %H:%M')}<br>
|
||||
<strong>태그:</strong> {', '.join(note.tags) if note.tags else '없음'}
|
||||
</div>
|
||||
<hr>
|
||||
{note.content or ''}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
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}"}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
148
backend/src/models/note_document.py
Normal file
148
backend/src/models/note_document.py
Normal file
@@ -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]
|
||||
69
backend/src/models/note_highlight.py
Normal file
69
backend/src/models/note_highlight.py
Normal file
@@ -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
|
||||
)
|
||||
59
backend/src/models/note_note.py
Normal file
59
backend/src/models/note_note.py
Normal file
@@ -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
|
||||
)
|
||||
126
backend/src/models/notebook.py
Normal file
126
backend/src/models/notebook.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user