feat: 노트북-서적 간 양방향 링크/백링크 시스템 완성

 주요 기능
- 노트 ↔ 서적 문서 간 양방향 링크 생성 및 이동
- 링크 대상 타입 선택 UI (서적 문서/노트북 노트)
- 통합 백링크 시스템 (일반 문서에서 노트 백링크도 표시)
- 링크 목록 UI 개선 (상세 정보 표시, 타입 구분)

🔧 백엔드 개선
- NoteLink 모델 및 API 추가 (/note-documents/{id}/links, /note-documents/{id}/backlinks)
- 일반 문서 백링크 API에서 노트 링크도 함께 조회
- target_content_type, source_content_type 필드 추가
- 노트 문서 콘텐츠 API 추가 (/note-documents/{id}/content)

🎨 프론트엔드 개선
- text-selector.html에서 노트 문서 지원
- 링크 이동 시 contentType에 따른 올바른 URL 생성
- URL 파라미터 파싱 수정 (contentType 지원)
- 링크 타입 자동 추론 로직
- 링크 목록 UI 대폭 개선 (출발점/도착점 텍스트, 타입 배지 등)

🐛 버그 수정
- 서적 목록 로드 실패 문제 해결
- 노트에서 링크 생성 시 대상 문서 열기 문제 해결
- 더미 문서로 이동하는 문제 해결
- 캐시 관련 문제 해결
This commit is contained in:
Hyungi Ahn
2025-09-02 16:22:03 +09:00
parent f711998ce9
commit d01cdeb2f5
12 changed files with 1188 additions and 82 deletions

View File

@@ -617,6 +617,11 @@ class DocumentServerAPI {
return await this.get(`/note-documents/${noteId}`);
}
// 특정 노트북의 노트들 조회
async getNotesInNotebook(notebookId) {
return await this.get('/note-documents/', { notebook_id: notebookId });
}
// === 노트 문서 (Note Document) 관련 API ===
// 용어 정의: 독립적인 문서 작성 (HTML 기반)
async createNoteDocument(noteData) {

View File

@@ -22,14 +22,28 @@ class LinkManager {
}
/**
* 문서 링크 데이터 로드
* 문서/노트 링크 데이터 로드
*/
async loadDocumentLinks(documentId) {
async loadDocumentLinks(documentId, contentType = 'document') {
try {
console.log('📡 링크 API 호출:', `/documents/${documentId}/links`);
console.log('📡 사용 중인 documentId:', documentId);
console.log('🔍 loadDocumentLinks 호출됨 - documentId:', documentId, 'contentType:', contentType);
let apiEndpoint;
if (contentType === 'note') {
// 노트 문서의 경우 노트 전용 링크 API 사용
apiEndpoint = `/note-documents/${documentId}/links`;
console.log('✅ 노트 API 엔드포인트 선택:', apiEndpoint);
} else {
// 일반 문서의 경우 기존 API 사용
apiEndpoint = `/documents/${documentId}/links`;
console.log('✅ 문서 API 엔드포인트 선택:', apiEndpoint);
}
console.log('📡 링크 API 호출:', apiEndpoint);
console.log('📡 사용 중인 documentId:', documentId, 'contentType:', contentType);
console.log('📡 cachedApi 객체:', this.cachedApi);
const response = await this.cachedApi.get(`/documents/${documentId}/links`, {}, { category: 'links' });
const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' });
console.log('📡 원본 API 응답:', response);
console.log('📡 응답 타입:', typeof response);
console.log('📡 응답이 배열인가?', Array.isArray(response));
@@ -53,7 +67,21 @@ class LinkManager {
this.documentLinks = [];
}
console.log('📡 최종 링크 데이터:', this.documentLinks);
// target_content_type이 없는 링크들에 대해 추론 로직 적용
this.documentLinks = this.documentLinks.map(link => {
if (!link.target_content_type) {
if (link.target_note_id) {
link.target_content_type = 'note';
console.log('🔍 링크 타입 추론: note -', link.id);
} else if (link.target_document_id) {
link.target_content_type = 'document';
console.log('🔍 링크 타입 추론: document -', link.id);
}
}
return link;
});
console.log('📡 최종 링크 데이터 (타입 추론 완료):', this.documentLinks);
console.log('📡 최종 링크 개수:', this.documentLinks.length);
return this.documentLinks;
} catch (error) {
@@ -66,10 +94,25 @@ class LinkManager {
/**
* 백링크 데이터 로드
*/
async loadBacklinks(documentId) {
async loadBacklinks(documentId, contentType = 'document') {
try {
console.log('📡 백링크 API 호출:', `/documents/${documentId}/backlinks`);
const response = await this.cachedApi.get(`/documents/${documentId}/backlinks`, {}, { category: 'links' });
console.log('🔍 loadBacklinks 호출됨 - documentId:', documentId, 'contentType:', contentType);
let apiEndpoint;
if (contentType === 'note') {
// 노트 문서의 경우 노트 전용 백링크 API 사용
apiEndpoint = `/note-documents/${documentId}/backlinks`;
console.log('✅ 노트 백링크 API 엔드포인트 선택:', apiEndpoint);
} else {
// 일반 문서의 경우 기존 API 사용
apiEndpoint = `/documents/${documentId}/backlinks`;
console.log('✅ 문서 백링크 API 엔드포인트 선택:', apiEndpoint);
}
console.log('📡 백링크 API 호출:', apiEndpoint);
console.log('📡 사용 중인 documentId:', documentId, 'contentType:', contentType);
const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' });
console.log('📡 원본 백링크 응답:', response);
console.log('📡 백링크 응답 타입:', typeof response);
console.log('📡 백링크 응답이 배열인가?', Array.isArray(response));
@@ -753,15 +796,40 @@ class LinkManager {
* 링크된 문서로 이동
*/
navigateToLinkedDocument(targetDocumentId, linkInfo) {
let targetUrl = `/viewer.html?id=${targetDocumentId}`;
console.log('🔗 navigateToLinkedDocument 호출됨');
console.log('📋 전달받은 파라미터:', {
targetDocumentId: targetDocumentId,
linkInfo: linkInfo
});
if (!targetDocumentId) {
console.error('❌ targetDocumentId가 없습니다!');
alert('대상 문서 ID가 없습니다.');
return;
}
// contentType에 따라 적절한 URL 생성
let targetUrl;
if (linkInfo.target_content_type === 'note') {
// 노트 문서로 이동
targetUrl = `/viewer.html?id=${targetDocumentId}&contentType=note`;
console.log('📝 노트 문서로 이동:', targetDocumentId);
} else {
// 일반 문서로 이동
targetUrl = `/viewer.html?id=${targetDocumentId}`;
console.log('📄 일반 문서로 이동:', targetDocumentId);
}
// 특정 텍스트 위치가 있는 경우 URL에 추가
if (linkInfo.target_text && linkInfo.target_start_offset !== undefined) {
targetUrl += `&highlight_text=${encodeURIComponent(linkInfo.target_text)}`;
targetUrl += `&start_offset=${linkInfo.target_start_offset}`;
targetUrl += `&end_offset=${linkInfo.target_end_offset}`;
console.log('🎯 텍스트 하이라이트 추가:', linkInfo.target_text);
}
console.log('🚀 최종 이동할 URL:', targetUrl);
window.location.href = targetUrl;
}
@@ -769,15 +837,40 @@ class LinkManager {
* 원본 문서로 이동 (백링크)
*/
navigateToSourceDocument(sourceDocumentId, backlinkInfo) {
let targetUrl = `/viewer.html?id=${sourceDocumentId}`;
console.log('🔙 navigateToSourceDocument 호출됨');
console.log('📋 전달받은 파라미터:', {
sourceDocumentId: sourceDocumentId,
backlinkInfo: backlinkInfo
});
if (!sourceDocumentId) {
console.error('❌ sourceDocumentId가 없습니다!');
alert('소스 문서 ID가 없습니다.');
return;
}
// source_content_type에 따라 적절한 URL 생성
let targetUrl;
if (backlinkInfo.source_content_type === 'note') {
// 노트 문서로 이동
targetUrl = `/viewer.html?id=${sourceDocumentId}&contentType=note`;
console.log('📝 노트 문서로 이동 (백링크):', sourceDocumentId);
} else {
// 일반 문서로 이동
targetUrl = `/viewer.html?id=${sourceDocumentId}`;
console.log('📄 일반 문서로 이동 (백링크):', sourceDocumentId);
}
// 원본 텍스트 위치가 있는 경우 URL에 추가
if (backlinkInfo.selected_text && backlinkInfo.start_offset !== undefined) {
targetUrl += `&highlight_text=${encodeURIComponent(backlinkInfo.selected_text)}`;
targetUrl += `&start_offset=${backlinkInfo.start_offset}`;
targetUrl += `&end_offset=${backlinkInfo.end_offset}`;
console.log('🎯 텍스트 하이라이트 추가 (백링크):', backlinkInfo.selected_text);
}
console.log('🚀 최종 이동할 URL (백링크):', targetUrl);
window.location.href = targetUrl;
}
@@ -1050,7 +1143,34 @@ class LinkManager {
const link = this.documentLinks.find(l => l.id === elementId);
if (link) {
this.navigateToLinkedDocument(link.target_document_id, link);
console.log('🔗 메뉴에서 링크 클릭:', link);
// target_content_type이 없으면 ID로 추론
let targetContentType = link.target_content_type;
if (!targetContentType) {
if (link.target_note_id) {
targetContentType = 'note';
} else if (link.target_document_id) {
targetContentType = 'document';
}
console.log('🔍 메뉴에서 target_content_type 추론됨:', targetContentType);
}
const targetId = link.target_document_id || link.target_note_id;
if (!targetId) {
console.error('❌ 메뉴에서 대상 ID가 없습니다!', link);
alert('링크 대상을 찾을 수 없습니다.');
return;
}
// 링크 객체에 추론된 타입 추가
const linkWithType = {
...link,
target_content_type: targetContentType
};
console.log('🚀 메뉴에서 최종 링크 데이터:', linkWithType);
this.navigateToLinkedDocument(targetId, linkWithType);
} else {
console.warn('링크 데이터를 찾을 수 없음:', elementId);
}

View File

@@ -34,6 +34,7 @@ window.documentViewer = () => ({
description: ''
},
linkForm: {
target_type: 'document', // 'document' 또는 'note'
target_document_id: '',
selected_text: '',
start_offset: 0,
@@ -246,11 +247,13 @@ window.documentViewer = () => ({
parseUrlParameters() {
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
this.contentType = urlParams.get('type') || 'document';
// contentType 파라미터를 올바르게 가져오기 (type과 contentType 둘 다 지원)
this.contentType = urlParams.get('contentType') || urlParams.get('type') || 'document';
console.log('🔍 URL 파싱 결과:', {
documentId: this.documentId,
contentType: this.contentType
contentType: this.contentType,
fullURL: window.location.href
});
if (!this.documentId) {
@@ -304,8 +307,8 @@ window.documentViewer = () => ({
this.highlightManager.loadHighlights(this.documentId, this.contentType),
this.highlightManager.loadNotes(this.documentId, this.contentType),
this.bookmarkManager.loadBookmarks(this.documentId),
this.linkManager.loadDocumentLinks(this.documentId),
this.linkManager.loadBacklinks(this.documentId)
this.linkManager.loadDocumentLinks(this.documentId, this.contentType),
this.linkManager.loadBacklinks(this.documentId, this.contentType)
]);
// 데이터 저장 및 모듈 동기화
@@ -475,33 +478,72 @@ window.documentViewer = () => ({
async loadBacklinks() {
console.log('🔗 백링크 로드 시작');
if (this.linkManager) {
await this.linkManager.loadBacklinks(this.documentId);
await this.linkManager.loadBacklinks(this.documentId, this.contentType);
// UI 상태 동기화
this.backlinks = this.linkManager.backlinks || [];
}
},
async loadAvailableBooks() {
// 링크 대상 타입 변경 시 호출
async onTargetTypeChange() {
console.log('🔄 링크 대상 타입 변경:', this.linkForm.target_type);
// 기존 선택 초기화
this.linkForm.target_book_id = '';
this.linkForm.target_document_id = '';
this.availableBooks = [];
this.filteredDocuments = [];
// 선택된 타입에 따라 데이터 로드
if (this.linkForm.target_type === 'note') {
await this.loadNotebooks();
} else {
await this.loadBooks();
}
},
// 노트북 목록 로드
async loadNotebooks() {
try {
console.log('📚 노트북 목록 로딩 시작...');
const notebooks = await this.api.get('/notebooks/', { active_only: true });
this.availableBooks = notebooks.map(notebook => ({
id: notebook.id,
title: notebook.title
})) || [];
console.log('📚 로드된 노트북 목록:', this.availableBooks.length, '개');
} catch (error) {
console.error('노트북 목록 로드 실패:', error);
this.availableBooks = [];
}
},
// 서적 목록 로드
async loadBooks() {
try {
console.log('📚 서적 목록 로딩 시작...');
// 문서 목록에서 서적 정보 추출
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
console.log('📄 모든 문서들 (총 개수):', allDocuments.length);
let allDocuments;
// 소스 문서의 서적 정보 찾기
const sourceBookInfo = this.getSourceBookInfo(allDocuments);
console.log('📖 소스 문서 서적 정보:', sourceBookInfo);
// contentType에 따라 다른 API 사용
if (this.contentType === 'note') {
// 노트의 경우 전체 문서 목록에서 서적 정보 추출
console.log('📝 노트 모드: 전체 문서 목록에서 서적 정보 추출');
allDocuments = await this.api.getDocuments();
console.log('📄 전체 문서들 (총 개수):', allDocuments.length);
} else {
// 일반 문서의 경우 linkable-documents API 사용
console.log('📄 문서 모드: linkable-documents API 사용');
allDocuments = await this.api.getLinkableDocuments(this.documentId);
console.log('📄 링크 가능한 문서들 (총 개수):', allDocuments.length);
}
// 서적별로 그룹화
const bookMap = new Map();
allDocuments.forEach(doc => {
if (doc.book_id && doc.book_title) {
console.log('📖 문서 서적 정보:', {
docId: doc.id,
bookId: doc.book_id,
bookTitle: doc.book_title
});
bookMap.set(doc.book_id, {
id: doc.book_id,
title: doc.book_title
@@ -509,18 +551,28 @@ window.documentViewer = () => ({
}
});
console.log('📚 그룹화된 모든 서적들:', Array.from(bookMap.values()));
// 모든 서적 표시 (소스 서적 포함)
this.availableBooks = Array.from(bookMap.values());
console.log('📚 최종 사용 가능한 서적들 (모든 서적):', this.availableBooks);
console.log('📖 소스 서적 정보 (포함됨):', sourceBookInfo);
console.log('📚 로드된 서적 목록:', this.availableBooks.length, '개');
} catch (error) {
console.error('서적 목록 로드 실패:', error);
this.availableBooks = [];
}
},
async loadAvailableBooks() {
try {
// 기본값으로 문서 타입 설정 (기존 호환성)
if (this.linkForm.target_type === 'note') {
await this.loadNotebooks();
} else {
await this.loadBooks();
}
} catch (error) {
console.error('목록 로드 실패:', error);
this.availableBooks = [];
}
},
getSourceBookInfo(allDocuments = null) {
// 여러 소스에서 현재 문서의 서적 정보 찾기
let sourceBookId = this.navigation?.book_info?.id ||
@@ -548,6 +600,30 @@ window.documentViewer = () => ({
async loadSameBookDocuments() {
try {
if (this.contentType === 'note') {
console.log('📚 같은 노트북의 노트들 로드 시작...');
// 현재 노트의 노트북 정보 가져오기
const currentNote = this.document;
const notebookId = currentNote?.notebook_id;
if (notebookId) {
// 같은 노트북의 노트들 로드 (현재 노트 제외)
const notes = await this.api.getNotesInNotebook(notebookId);
this.filteredDocuments = notes.filter(note => note.id !== this.documentId);
console.log('📚 같은 노트북 노트들:', {
count: this.filteredDocuments.length,
notebookId: notebookId,
notes: this.filteredDocuments.map(note => ({ id: note.id, title: note.title }))
});
} else {
console.warn('⚠️ 현재 노트의 노트북 정보를 찾을 수 없습니다');
this.filteredDocuments = [];
}
return;
}
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
// 소스 문서의 서적 정보 가져오기
@@ -574,7 +650,7 @@ window.documentViewer = () => ({
this.filteredDocuments = [];
}
} catch (error) {
console.error('같은 서적 문서 로드 실패:', error);
console.error('같은 서적/노트북 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
@@ -620,12 +696,30 @@ window.documentViewer = () => ({
async loadDocumentsFromBook() {
try {
if (this.linkForm.target_book_id) {
// 선택된 서적의 문서들만 가져오기
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.linkForm.target_book_id
);
console.log('📚 선택된 서적 문서들:', this.filteredDocuments);
if (this.linkForm.target_type === 'note') {
// 노트북 선택: 선택된 노트북의 노트들 가져오기
const notes = await this.api.getNotesInNotebook(this.linkForm.target_book_id);
this.filteredDocuments = notes.filter(note => note.id !== this.documentId);
console.log('📚 선택된 노트북 노트들:', this.filteredDocuments);
} else {
// 서적 선택: 선택된 서적의 문서들만 가져오기
let allDocuments;
if (this.contentType === 'note') {
// 노트에서 서적 문서를 선택하는 경우: 전체 문서 목록에서 필터링
console.log('📝 노트에서 서적 문서 선택: 전체 문서 목록 사용');
allDocuments = await this.api.getDocuments();
} else {
// 일반 문서에서 서적 문서를 선택하는 경우: linkable-documents API 사용
console.log('📄 문서에서 서적 문서 선택: linkable-documents API 사용');
allDocuments = await this.api.getLinkableDocuments(this.documentId);
}
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.linkForm.target_book_id
);
console.log('📚 선택된 서적 문서들:', this.filteredDocuments);
}
} else {
this.filteredDocuments = [];
}
@@ -633,7 +727,7 @@ window.documentViewer = () => ({
// 문서 선택 초기화
this.linkForm.target_document_id = '';
} catch (error) {
console.error('서적별 문서 로드 실패:', error);
console.error('서적/노트북별 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
@@ -662,8 +756,9 @@ window.documentViewer = () => ({
}
// 새 창에서 대상 문서 열기 (텍스트 선택 모드 전용 페이지)
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl);
const targetContentType = this.linkForm.target_type === 'note' ? 'note' : 'document';
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}&contentType=${targetContentType}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl, 'contentType:', targetContentType);
const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes');
if (!popup) {
@@ -1087,6 +1182,62 @@ window.documentViewer = () => ({
return this.linkManager.navigateToBacklinkDocument(documentId, backlinkData);
},
// HTML에서 사용되는 링크 이동 함수들
navigateToLink(link) {
console.log('🔗 링크 클릭:', link);
console.log('📋 링크 상세 정보:', {
target_document_id: link.target_document_id,
target_note_id: link.target_note_id,
target_content_type: link.target_content_type,
target_document_title: link.target_document_title,
target_note_title: link.target_note_title
});
// target_content_type이 없으면 ID로 추론
let targetContentType = link.target_content_type;
if (!targetContentType) {
if (link.target_note_id) {
targetContentType = 'note';
} else if (link.target_document_id) {
targetContentType = 'document';
}
console.log('🔍 target_content_type 추론됨:', targetContentType);
}
const targetId = link.target_document_id || link.target_note_id;
if (!targetId) {
console.error('❌ 대상 문서/노트 ID가 없습니다!', link);
alert('링크 대상을 찾을 수 없습니다.');
return;
}
// 링크 객체에 추론된 타입 추가
const linkWithType = {
...link,
target_content_type: targetContentType
};
console.log('🚀 최종 링크 데이터:', linkWithType);
return this.linkManager.navigateToLinkedDocument(targetId, linkWithType);
},
navigateToBacklink(backlink) {
console.log('🔙 백링크 클릭:', backlink);
console.log('📋 백링크 상세 정보:', {
source_document_id: backlink.source_document_id,
source_content_type: backlink.source_content_type,
source_document_title: backlink.source_document_title
});
if (!backlink.source_document_id) {
console.error('❌ 소스 문서 ID가 없습니다!', backlink);
alert('백링크 소스를 찾을 수 없습니다.');
return;
}
return this.linkManager.navigateToSourceDocument(backlink.source_document_id, backlink);
},
// 북마크 관련
scrollToBookmark(bookmark) {
return this.bookmarkManager.scrollToBookmark(bookmark);
@@ -1144,8 +1295,29 @@ window.documentViewer = () => ({
console.log('✅ 모든 필수 필드 확인됨');
// API 호출
await this.api.createDocumentLink(this.documentId, linkData);
// API 호출 (출발지와 대상에 따라 다른 API 사용)
if (this.contentType === 'note') {
// 노트에서 출발하는 링크
if (this.linkForm.target_type === 'note') {
// 노트 → 노트: 노트 링크 API 사용
linkData.target_note_id = linkData.target_document_id;
delete linkData.target_document_id;
await this.api.post(`/note-documents/${this.documentId}/links`, linkData);
} else {
// 노트 → 문서: 노트 링크 API 사용 (target_document_id 유지)
await this.api.post(`/note-documents/${this.documentId}/links`, linkData);
}
} else {
// 문서에서 출발하는 링크
if (this.linkForm.target_type === 'note') {
// 문서 → 노트: 문서 링크 API에 노트 대상 지원 필요 (향후 구현)
// 현재는 기존 API 사용
await this.api.createDocumentLink(this.documentId, linkData);
} else {
// 문서 → 문서: 기존 문서 링크 API 사용
await this.api.createDocumentLink(this.documentId, linkData);
}
}
console.log('✅ 링크 생성됨');
// 성공 알림
@@ -1157,13 +1329,17 @@ window.documentViewer = () => ({
// 캐시 무효화 (새 링크가 반영되도록)
console.log('🗑️ 링크 캐시 무효화 시작...');
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']);
if (this.contentType === 'note') {
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/links`, ['links']);
} else {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']);
}
console.log('✅ 링크 캐시 무효화 완료');
}
// 링크 목록 새로고침
console.log('🔄 링크 목록 새로고침 시작...');
await this.linkManager.loadDocumentLinks(this.documentId);
await this.linkManager.loadDocumentLinks(this.documentId, this.contentType);
this.documentLinks = this.linkManager.documentLinks || [];
console.log('📊 로드된 링크 개수:', this.documentLinks.length);
console.log('📊 링크 데이터:', this.documentLinks);
@@ -1177,10 +1353,14 @@ window.documentViewer = () => ({
console.log('🔄 백링크 새로고침 시작...');
// 백링크 캐시도 무효화
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']);
if (this.contentType === 'note') {
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']);
} else {
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']);
}
console.log('✅ 백링크 캐시도 무효화 완료');
}
await this.linkManager.loadBacklinks(this.documentId);
await this.linkManager.loadBacklinks(this.documentId, this.contentType);
this.backlinks = this.linkManager.backlinks || [];
this.linkManager.renderBacklinks();
console.log('✅ 백링크 새로고침 완료');

View File

@@ -78,11 +78,14 @@
// URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
this.contentType = urlParams.get('contentType') || 'document'; // 기본값은 document
if (!this.documentId) {
this.showError('문서 ID가 없습니다');
return;
}
console.log('🔧 초기화:', { documentId: this.documentId, contentType: this.contentType });
// 인증 확인
if (!api.token) {
@@ -100,16 +103,28 @@
}
async loadDocument() {
console.log('📄 문서 로드 중:', this.documentId);
console.log('📄 문서 로드 중:', this.documentId, 'contentType:', this.contentType);
try {
// 문서 메타데이터 조회
const docResponse = await api.getDocument(this.documentId);
// contentType에 따라 적절한 API 호출
let docResponse, contentEndpoint;
if (this.contentType === 'note') {
// 노트 문서 메타데이터 조회
docResponse = await api.getNoteDocument(this.documentId);
contentEndpoint = `/note-documents/${this.documentId}/content`;
console.log('📝 노트 메타데이터:', docResponse);
} else {
// 일반 문서 메타데이터 조회
docResponse = await api.getDocument(this.documentId);
contentEndpoint = `/documents/${this.documentId}/content`;
console.log('📋 문서 메타데이터:', docResponse);
}
this.document = docResponse;
console.log('📋 문서 메타데이터:', docResponse);
// 문서 HTML 콘텐츠 조회
const contentResponse = await fetch(`${api.baseURL}/documents/${this.documentId}/content`, {
const contentResponse = await fetch(`${api.baseURL}${contentEndpoint}`, {
headers: {
'Authorization': `Bearer ${api.token}`,
'Content-Type': 'application/json'

View File

@@ -311,29 +311,45 @@
<template x-for="link in documentLinks" :key="link.id">
<div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors"
@click="navigateToLink(link)">
<div class="flex items-center justify-between">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="font-medium text-purple-700 mb-1" x-text="link.target_document_title"></div>
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
<div x-show="link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 bg-gray-100 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
<!-- 대상 문서 제목 -->
<div class="font-medium text-purple-700 mb-2 flex items-center">
<span x-text="link.target_document_title || link.target_note_title"></span>
<span x-show="link.target_content_type === 'note'" class="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">노트</span>
<span x-show="link.target_content_type === 'document'" class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-1 rounded">문서</span>
</div>
<div x-show="!link.selected_text" class="mb-2">
<div class="text-sm text-gray-600 italic">📄 문서 전체 링크</div>
<!-- 현재 문서에서 선택한 텍스트 (출발점) -->
<div x-show="link.selected_text" class="mb-3">
<div class="text-xs text-gray-500 mb-1">📍 현재 문서에서 선택한 텍스트:</div>
<div class="text-sm text-gray-700 bg-purple-50 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
</div>
<!-- 대상 문서의 텍스트 (도착점) -->
<div x-show="link.target_text" class="mb-3">
<div class="text-xs text-gray-500 mb-1">🎯 대상 문서의 텍스트:</div>
<div class="text-sm text-gray-700 bg-blue-50 px-3 py-2 rounded border-l-4 border-blue-500" x-text="link.target_text"></div>
</div>
<!-- 문서 전체 링크인 경우 -->
<div x-show="!link.selected_text && !link.target_text" class="mb-3">
<div class="text-sm text-gray-600 italic bg-gray-50 px-3 py-2 rounded">📄 문서 전체 링크</div>
</div>
<!-- 설명 -->
<div x-show="link.description" class="text-sm text-gray-600 mb-2" x-text="link.description"></div>
<div x-show="link.description" class="mb-3">
<div class="text-xs text-gray-500 mb-1">💬 설명:</div>
<div class="text-sm text-gray-600 bg-yellow-50 px-3 py-2 rounded" x-text="link.description"></div>
</div>
<!-- 링크 타입과 날짜 -->
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500"
x-text="link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'"></span>
<span class="text-xs text-gray-500" x-text="formatDate(link.created_at)"></span>
<div class="flex items-center justify-between text-xs text-gray-500">
<span x-text="link.link_type === 'text_fragment' ? '🔗 텍스트 조각 링크' : '📄 문서 링크'"></span>
<span x-text="formatDate(link.created_at)"></span>
</div>
</div>
<div class="ml-3">
<div class="ml-3 flex-shrink-0">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
@@ -376,15 +392,39 @@
<p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p>
</div>
<!-- 서적 선택 -->
<!-- 링크 대상 타입 선택 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-3">서적 선택</label>
<label class="block text-sm font-semibold text-gray-700 mb-3">링크 대상 타입</label>
<div class="flex space-x-4">
<label class="flex items-center">
<input type="radio"
x-model="linkForm.target_type"
value="document"
@change="onTargetTypeChange()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">📄 서적 문서</span>
</label>
<label class="flex items-center">
<input type="radio"
x-model="linkForm.target_type"
value="note"
@change="onTargetTypeChange()"
class="mr-2 text-blue-600">
<span class="text-sm text-gray-700">📝 노트북 노트</span>
</label>
</div>
</div>
<!-- 서적/노트북 선택 -->
<div class="mb-6" x-show="linkForm.target_type">
<label class="block text-sm font-semibold text-gray-700 mb-3"
x-text="linkForm.target_type === 'note' ? '노트북 선택' : '서적 선택'"></label>
<select
x-model="linkForm.target_book_id"
@change="loadDocumentsFromBook()"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">서적을 선택하세요</option>
<option value="" x-text="linkForm.target_type === 'note' ? '노트북을 선택하세요' : '서적을 선택하세요'"></option>
<template x-for="book in availableBooks" :key="book.id">
<option :value="book.id" x-text="book.title"></option>
</template>
@@ -404,8 +444,10 @@
:disabled="!linkForm.target_book_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
<option value="">
<span x-show="!linkForm.target_book_id">먼저 서적을 선택하세요</span>
<span x-show="linkForm.target_book_id">문서를 선택하세요</span>
<span x-show="!linkForm.target_book_id"
x-text="linkForm.target_type === 'note' ? '먼저 노트북을 선택하세요' : '먼저 서적을 선택하세요'"></span>
<span x-show="linkForm.target_book_id"
x-text="linkForm.target_type === 'note' ? '노트를 선택하세요' : '문서를 선택하세요'"></span>
</option>
<template x-for="doc in filteredDocuments" :key="doc.id">
<option :value="doc.id" x-text="doc.title"></option>
@@ -501,8 +543,8 @@
<div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors cursor-pointer"
@click="scrollToHighlight(note.highlight.id)">
<!-- 선택된 텍스트 -->
<div class="bg-blue-50 rounded-md p-2 mb-3">
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight.selected_text"></p>
<div class="bg-blue-50 rounded-md p-2 mb-3" x-show="note.highlight?.selected_text">
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight?.selected_text || ''"></p>
</div>
<!-- 메모 내용 -->