🐛 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:
Hyungi Ahn
2025-08-26 23:50:48 +09:00
parent 8d7f4c04bb
commit 3e0a03f149
31 changed files with 5176 additions and 567 deletions

View File

@@ -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

View File

@@ -63,6 +63,10 @@ class HighlightResponse(BaseModel):
router = APIRouter()
@router.post("/", response_model=HighlightResponse)
async def create_highlight(
highlight_data: CreateHighlightRequest,

View 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"}

View 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"}

View 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"}

View File

@@ -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}"}
)

View File

@@ -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

View File

@@ -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("/")

View File

@@ -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"
]

View 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]

View 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
)

View 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
)

View 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