🐛 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

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