🐛 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

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