Fix: 노트 링크 관련 모든 기능 완전 수정

주요 수정사항:
- 노트 간 링크 네비게이션 수정 (target_note_id 우선 사용)
- 노트 백링크 네비게이션 수정 (source_note_id 우선 사용)
- 노트 링크 삭제 API 분기 처리 (/note-links vs /document-links)
- 하이라이트 삭제 시 메모 캐시 무효화 추가
- 하이라이트 메모 삭제 API 엔드포인트 추가 (DELETE /highlight-notes/{note_id})
- URL 파싱 개선 (null/undefined ID 감지 및 오류 처리)
- 노트 링크 생성 응답에 source_content_type, target_content_type 추가
- 통합 툴팁에서 노트 링크 제목 표시 수정 (target_note_title 사용)
- 링크 삭제 버튼에서 null 참조 오류 수정

수정된 파일:
- frontend: viewer-core.js, link-manager.js, highlight-manager.js, api.js, cached-api.js
- backend: note_links.py, notes.py
- 브라우저 캐시 무효화: 버전 v=2025012623
This commit is contained in:
Hyungi Ahn
2025-09-04 08:42:12 +09:00
parent 0786bdc86d
commit 3ba804276c
8 changed files with 143 additions and 26 deletions

View File

@@ -253,6 +253,20 @@ def create_note_link(
"updated_at": note_link.updated_at.isoformat() if note_link.updated_at else None,
}
# 소스 및 타겟 타입 설정
response_data["source_content_type"] = "note" # 노트에서 출발하는 링크
if note_link.target_note_id:
target_note = db.query(NoteDocument).filter(NoteDocument.id == note_link.target_note_id).first()
if target_note:
response_data["target_note_title"] = target_note.title
response_data["target_content_type"] = "note"
elif note_link.target_document_id:
target_doc = db.query(Document).filter(Document.id == note_link.target_document_id).first()
if target_doc:
response_data["target_document_title"] = target_doc.title
response_data["target_content_type"] = "document"
return NoteLinkResponse(**response_data)

View File

@@ -98,6 +98,29 @@ def update_note(
return note
@router.delete("/{note_id}")
def delete_highlight_note(
note_id: str,
db: Session = Depends(get_sync_db),
current_user: User = Depends(get_current_user)
):
"""하이라이트 메모 삭제"""
from ...models.note import Note
from ...models.highlight import Highlight
note = db.query(Note).join(Highlight).filter(
Note.id == note_id,
Highlight.user_id == current_user.id
).first()
if not note:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
db.delete(note)
db.commit()
return {"message": "메모가 삭제되었습니다"}
@router.get("/document/{document_id}")
async def get_document_notes(
document_id: str,

View File

@@ -277,6 +277,10 @@ class DocumentServerAPI {
return await this.put(`/highlight-notes/${noteId}`, updateData);
}
async deleteNote(noteId) {
return await this.delete(`/highlight-notes/${noteId}`);
}
// === 문서 메모 조회 ===
// 용어 정의: 특정 문서의 모든 하이라이트 메모 조회
async getDocumentNotes(documentId) {

View File

@@ -730,7 +730,21 @@ class HighlightManager {
async deleteHighlight(highlightId) {
try {
await this.api.delete(`/highlights/${highlightId}`);
// 하이라이트 배열에서 제거
this.highlights = this.highlights.filter(h => h.id !== highlightId);
// 메모 배열에서도 해당 하이라이트의 메모들 제거
this.notes = this.notes.filter(note => note.highlight_id !== highlightId);
// 캐시 무효화 (하이라이트와 메모 모두)
if (window.documentViewerInstance && window.documentViewerInstance.cacheManager) {
window.documentViewerInstance.cacheManager.invalidateCategory('highlights');
window.documentViewerInstance.cacheManager.invalidateCategory('notes');
console.log('🗑️ 하이라이트 삭제 후 캐시 무효화 완료');
}
// 화면 다시 렌더링
this.renderHighlights();
console.log('하이라이트 삭제 완료:', highlightId);
} catch (error) {
@@ -1071,7 +1085,7 @@ class HighlightManager {
</svg>
복사
</button>
<button onclick="window.documentViewerInstance.highlightManager.deleteHighlightWithConfirm('${clickedHighlight.id}')"
<button onclick="window.documentViewerInstance.deleteHighlightWithConfirm('${clickedHighlight.id}')"
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600 transition-colors flex items-center"
title="하이라이트 삭제">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -781,7 +781,7 @@ class LinkManager {
</div>
<div class="flex flex-col sm:flex-row gap-3 mb-4">
<button onclick="window.documentViewerInstance.linkManager.navigateToLinkedDocument('${link.target_document_id}', ${JSON.stringify(link).replace(/"/g, '&quot;')})"
<button onclick="window.documentViewerInstance.linkManager.navigateToLinkedDocument('${link.target_note_id || link.target_document_id}', ${JSON.stringify(link).replace(/"/g, '&quot;')})"
class="flex-1 bg-purple-500 text-white px-4 py-2 rounded-lg hover:bg-purple-600 transition-colors flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@@ -1033,10 +1033,22 @@ class LinkManager {
linkInfo: linkInfo
});
if (!targetDocumentId) {
console.error('❌ targetDocumentId가 없습니다!');
alert('대상 문서 ID가 없습니다.');
return;
// targetDocumentId가 null이거나 'null' 문자열인 경우 처리
if (!targetDocumentId || targetDocumentId === 'null' || targetDocumentId === null) {
console.error('❌ targetDocumentId가 유효하지 않습니다:', targetDocumentId);
console.log('🔍 linkInfo에서 대체 ID 찾기:', linkInfo);
// linkInfo에서 대체 ID 찾기 (노트 링크의 경우 target_note_id 우선)
if (linkInfo && linkInfo.target_note_id && linkInfo.target_note_id !== 'null') {
targetDocumentId = linkInfo.target_note_id;
console.log('✅ linkInfo에서 target_note_id 발견:', targetDocumentId);
} else if (linkInfo && linkInfo.target_document_id && linkInfo.target_document_id !== 'null') {
targetDocumentId = linkInfo.target_document_id;
console.log('✅ linkInfo에서 target_document_id 발견:', targetDocumentId);
} else {
alert('대상 문서 ID가 유효하지 않습니다.');
return;
}
}
// contentType에 따라 적절한 URL 생성
@@ -1083,7 +1095,18 @@ class LinkManager {
// source_content_type에 따라 적절한 URL 생성
let targetUrl;
if (backlinkInfo.source_content_type === 'note') {
// source_content_type이 없으면 ID로 추론
let sourceContentType = backlinkInfo.source_content_type;
if (!sourceContentType) {
if (backlinkInfo.source_note_id) {
sourceContentType = 'note';
} else if (backlinkInfo.source_document_id) {
sourceContentType = 'document';
}
console.log('🔍 백링크에서 source_content_type 추론됨:', sourceContentType);
}
if (sourceContentType === 'note') {
// 노트 문서로 이동
targetUrl = `/viewer.html?id=${sourceDocumentId}&contentType=note`;
console.log('📝 노트 문서로 이동 (백링크):', sourceDocumentId);
@@ -1114,7 +1137,19 @@ class LinkManager {
}
try {
await this.api.delete(`/document-links/${linkId}`);
// 링크 타입 확인 (노트 링크인지 문서 링크인지)
const link = this.documentLinks.find(l => l.id === linkId);
if (link && link.source_note_id) {
// 노트 링크 삭제
console.log('📝 노트 링크 삭제 API 호출');
await this.api.delete(`/note-links/${linkId}`);
} else {
// 문서 링크 삭제
console.log('📄 문서 링크 삭제 API 호출');
await this.api.delete(`/document-links/${linkId}`);
}
this.documentLinks = this.documentLinks.filter(l => l.id !== linkId);
this.hideTooltip();

View File

@@ -375,6 +375,15 @@ class CachedAPI {
});
}
/**
* 메모 삭제 (캐시 무효화)
*/
async deleteNote(noteId) {
return await this.delete(`/highlight-notes/${noteId}`, {
invalidateCategories: ['notes', 'highlights']
});
}
/**
* 북마크 생성 (캐시 무효화)
*/

View File

@@ -246,19 +246,24 @@ window.documentViewer = () => ({
// ==================== URL 파라미터 처리 ====================
parseUrlParameters() {
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
const rawId = urlParams.get('id');
// null 문자열이나 빈 값 처리
if (!rawId || rawId === 'null' || rawId === 'undefined' || rawId.trim() === '') {
console.error('❌ 유효하지 않은 문서 ID:', rawId);
throw new Error('유효한 문서 ID가 필요합니다. URL을 확인해주세요.');
}
this.documentId = rawId;
// contentType 파라미터를 올바르게 가져오기 (type과 contentType 둘 다 지원)
this.contentType = urlParams.get('contentType') || urlParams.get('type') || 'document';
console.log('🔍 URL 파싱 결과:', {
rawId: rawId,
documentId: this.documentId,
contentType: this.contentType,
fullURL: window.location.href
});
if (!this.documentId) {
throw new Error('문서 ID가 필요합니다.');
}
},
// ==================== 문서 로드 ====================
@@ -1162,7 +1167,8 @@ window.documentViewer = () => ({
const link = this.linkManager.documentLinks.find(l => l.id === linkId);
if (link && !overlappingLinks.find(l => l.id === link.id)) {
overlappingLinks.push(link);
console.log('✅ 겹치는 링크 발견:', link.target_document_title);
const linkTitle = link.target_note_title || link.target_document_title || 'Unknown';
console.log('✅ 겹치는 링크 발견:', linkTitle);
}
}
});
@@ -1362,7 +1368,7 @@ window.documentViewer = () => ({
'<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>'
}
</svg>
<span class="font-medium ${iconClass}">${link.target_document_title}</span>
<span class="font-medium ${iconClass}">${link.target_note_title || link.target_document_title}</span>
</div>
${link.target_text ? `
@@ -1390,7 +1396,7 @@ window.documentViewer = () => ({
</div>
<!-- 삭제 버튼 -->
<button onclick="event.stopPropagation(); window.documentViewerInstance.deleteLinkWithConfirm('${link.id}', '${link.target_document_title.replace(/'/g, "\\'")}');"
<button onclick="event.stopPropagation(); window.documentViewerInstance.deleteLinkWithConfirm('${link.id}', '${(link.target_note_title || link.target_document_title).replace(/'/g, "\\'")}');"
class="ml-3 p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors duration-200 opacity-0 group-hover:opacity-100"
title="링크 삭제">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1650,17 +1656,21 @@ window.documentViewer = () => ({
console.log('🔙 백링크 클릭:', backlink);
console.log('📋 백링크 상세 정보:', {
source_document_id: backlink.source_document_id,
source_note_id: backlink.source_note_id,
source_content_type: backlink.source_content_type,
source_document_title: backlink.source_document_title
});
if (!backlink.source_document_id) {
console.error('❌ 소스 문서 ID가 없습니다!', backlink);
// 소스 ID 찾기 (노트 백링크의 경우 source_note_id 우선)
const sourceId = backlink.source_note_id || backlink.source_document_id;
if (!sourceId) {
console.error('❌ 소스 문서/노트 ID가 없습니다!', backlink);
alert('백링크 소스를 찾을 수 없습니다.');
return;
}
return this.linkManager.navigateToSourceDocument(backlink.source_document_id, backlink);
console.log('✅ 백링크 소스 ID 발견:', sourceId);
return this.linkManager.navigateToSourceDocument(sourceId, backlink);
},
// 링크 삭제 (확인 후)
@@ -1676,8 +1686,16 @@ window.documentViewer = () => ({
try {
console.log('🗑️ 링크 삭제 시작:', linkId);
// API 호출
await this.api.deleteDocumentLink(linkId);
// 출발지 타입에 따라 다른 API 사용
if (this.contentType === 'note') {
// 노트에서 출발하는 링크: NoteLink API 사용
console.log('📝 노트 링크 삭제 API 호출');
await this.api.delete(`/note-links/${linkId}`);
} else {
// 문서에서 출발하는 링크: DocumentLink API 사용
console.log('📄 문서 링크 삭제 API 호출');
await this.api.deleteDocumentLink(linkId);
}
console.log('✅ 링크 삭제 성공');
// 툴팁 숨기기

View File

@@ -773,22 +773,22 @@
</div>
<!-- 스크립트 -->
<script src="/static/js/api.js?v=2025012615"></script>
<script src="/static/js/api.js?v=2025012618"></script>
<!-- 캐시 및 성능 최적화 시스템 -->
<script src="/static/js/viewer/utils/cache-manager.js?v=2025012607"></script>
<script src="/static/js/viewer/utils/cached-api.js?v=2025012615"></script>
<script src="/static/js/viewer/utils/cached-api.js?v=2025012618"></script>
<script src="/static/js/viewer/utils/module-loader.js?v=2025012607"></script>
<!-- 모든 모듈들 직접 로드 -->
<script src="/static/js/viewer/core/document-loader.js?v=2025012607"></script>
<script src="/static/js/viewer/features/ui-manager.js?v=2025012607"></script>
<script src="/static/js/viewer/features/highlight-manager.js?v=2025012617"></script>
<script src="/static/js/viewer/features/link-manager.js?v=2025012607"></script>
<script src="/static/js/viewer/features/highlight-manager.js?v=2025012619"></script>
<script src="/static/js/viewer/features/link-manager.js?v=2025012622"></script>
<script src="/static/js/viewer/features/bookmark-manager.js?v=2025012607"></script>
<!-- ViewerCore (Alpine.js 컴포넌트) -->
<script src="/static/js/viewer/viewer-core.js?v=2025012617"></script>
<script src="/static/js/viewer/viewer-core.js?v=2025012623"></script>
<!-- Alpine.js 프레임워크 -->
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>