🐛 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:
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"}
|
||||
Reference in New Issue
Block a user