링크/백링크 기능 수정 및 안정화

- 링크 생성 기능 완전 복구
  - createDocumentLink 함수를 Alpine.js 데이터 객체 내로 이동
  - API 필드명 불일치 수정 (source_text → selected_text 등)
  - 필수 필드 검증 및 상세 에러 로깅 추가

- API 엔드포인트 수정
  - 백엔드와 일치하도록 /documents/{id}/links, /documents/{id}/backlinks 사용
  - 올바른 매개변수 전달 방식 적용

- 링크/백링크 렌더링 안정화
  - forEach 오류 방지를 위한 배열 타입 검증
  - 데이터 없을 시 기존 링크 유지 로직
  - 안전한 초기화 및 에러 처리

- UI 단순화
  - 같은 서적/다른 서적 구분 제거
  - 서적 선택 → 문서 선택 단순한 2단계 프로세스
  - 백링크는 자동 생성되도록 수정

- 디버깅 로그 대폭 강화
  - API 호출, 응답, 렌더링 각 단계별 상세 로깅
  - 데이터 타입 및 개수 추적
This commit is contained in:
Hyungi Ahn
2025-08-28 08:48:52 +09:00
parent 5d4465b15c
commit 844587c86f
5 changed files with 561 additions and 131 deletions

View File

@@ -10,6 +10,9 @@ class LinkManager {
this.cachedApi = window.cachedApi || api;
this.documentLinks = [];
this.backlinks = [];
// 안전한 초기화 확인
console.log('🔧 LinkManager 초기화 - backlinks 타입:', typeof this.backlinks, Array.isArray(this.backlinks));
this.selectedText = '';
this.selectedRange = null;
this.availableBooks = [];
@@ -23,10 +26,15 @@ class LinkManager {
*/
async loadDocumentLinks(documentId) {
try {
this.documentLinks = await this.cachedApi.get('/document-links', { document_id: documentId }, { category: 'links' }).catch(() => []);
return this.documentLinks || [];
console.log('📡 링크 API 호출:', `/documents/${documentId}/links`);
const response = await this.cachedApi.get(`/documents/${documentId}/links`, {}, { category: 'links' }).catch(() => []);
this.documentLinks = Array.isArray(response) ? response : [];
console.log('📡 API 응답 링크 개수:', this.documentLinks.length);
console.log('📡 API 응답 타입:', typeof response, response);
return this.documentLinks;
} catch (error) {
console.error('문서 링크 로드 실패:', error);
this.documentLinks = [];
return [];
}
}
@@ -36,10 +44,15 @@ class LinkManager {
*/
async loadBacklinks(documentId) {
try {
this.backlinks = await this.cachedApi.get('/document-links/backlinks', { target_document_id: documentId }, { category: 'links' }).catch(() => []);
return this.backlinks || [];
console.log('📡 백링크 API 호출:', `/documents/${documentId}/backlinks`);
const response = await this.cachedApi.get(`/documents/${documentId}/backlinks`, {}, { category: 'links' }).catch(() => []);
this.backlinks = Array.isArray(response) ? response : [];
console.log('📡 API 응답 백링크 개수:', this.backlinks.length);
console.log('📡 API 응답 타입:', typeof response, response);
return this.backlinks;
} catch (error) {
console.error('백링크 로드 실패:', error);
this.backlinks = [];
return [];
}
}
@@ -51,7 +64,20 @@ class LinkManager {
const documentContent = document.getElementById('document-content');
if (!documentContent) return;
// 안전한 링크 초기화
if (!Array.isArray(this.documentLinks)) {
console.warn('⚠️ this.documentLinks가 배열이 아닙니다. 빈 배열로 초기화합니다.');
console.log('🔍 기존 this.documentLinks:', typeof this.documentLinks, this.documentLinks);
this.documentLinks = [];
}
console.log('🔗 링크 렌더링 시작 - 총', this.documentLinks.length, '개');
// 링크 데이터가 없으면 렌더링하지 않음 (기존 링크 유지)
if (this.documentLinks.length === 0) {
console.log('📝 링크 데이터가 없어서 기존 링크를 유지합니다.');
return;
}
// 기존 링크 제거
const existingLinks = documentContent.querySelectorAll('.document-link');
@@ -62,9 +88,13 @@ class LinkManager {
});
// 각 링크 렌더링
this.documentLinks.forEach(link => {
this.renderSingleLink(link);
});
if (Array.isArray(this.documentLinks)) {
this.documentLinks.forEach(link => {
this.renderSingleLink(link);
});
} else {
console.warn('⚠️ this.documentLinks가 배열이 아닙니다:', typeof this.documentLinks, this.documentLinks);
}
console.log('✅ 링크 렌더링 완료');
}
@@ -166,26 +196,43 @@ class LinkManager {
const documentContent = document.getElementById('document-content');
if (!documentContent) return;
// 안전한 백링크 초기화
if (!Array.isArray(this.backlinks)) {
console.warn('⚠️ this.backlinks가 배열이 아닙니다. 빈 배열로 초기화합니다.');
console.log('🔍 기존 this.backlinks:', typeof this.backlinks, this.backlinks);
this.backlinks = [];
}
console.log('🔗 백링크 렌더링 시작 - 총', this.backlinks.length, '개');
// 백링크 데이터가 없으면 렌더링하지 않음 (기존 백링크 유지)
if (this.backlinks.length === 0) {
console.log('📝 백링크 데이터가 없어서 기존 백링크를 유지합니다.');
return;
}
// 기존 백링크는 제거하지 않고 중복 체크만 함
const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight');
console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견 (유지)`);
// 각 백링크 렌더링 (중복되지 않는 것만)
this.backlinks.forEach(backlink => {
// 이미 렌더링된 백링크인지 확인
const existingBacklink = Array.from(existingBacklinks).find(el =>
el.dataset.backlinkId === backlink.id.toString()
);
if (!existingBacklink) {
console.log(`🆕 새로운 백링크 렌더링: ${backlink.id}`);
this.renderSingleBacklink(backlink);
} else {
console.log(`✅ 백링크 이미 존재: ${backlink.id}`);
}
});
if (Array.isArray(this.backlinks)) {
this.backlinks.forEach(backlink => {
// 이미 렌더링된 백링크인지 확인
const existingBacklink = Array.from(existingBacklinks).find(el =>
el.dataset.backlinkId === backlink.id.toString()
);
if (!existingBacklink) {
console.log(`🆕 새로운 백링크 렌더링: ${backlink.id}`);
this.renderSingleBacklink(backlink);
} else {
console.log(`✅ 백링크 이미 존재: ${backlink.id}`);
}
});
} else {
console.warn('⚠️ this.backlinks가 배열이 아닙니다:', typeof this.backlinks, this.backlinks);
}
console.log('✅ 백링크 렌더링 완료');
}
@@ -534,8 +581,23 @@ class LinkManager {
/**
* 선택된 텍스트로 링크 생성
*/
async createLinkFromSelection(documentId, selectedText, selectedRange) {
if (!selectedText || !selectedRange) return;
async createLinkFromSelection(documentId = null, selectedText = null, selectedRange = null) {
// 매개변수가 없으면 현재 선택된 텍스트 사용
if (!selectedText || !selectedRange) {
selectedText = window.getSelection().toString().trim();
const selection = window.getSelection();
if (!selectedText || selection.rangeCount === 0) {
alert('텍스트를 먼저 선택해주세요.');
return;
}
selectedRange = selection.getRangeAt(0);
}
if (!documentId && window.documentViewerInstance) {
documentId = window.documentViewerInstance.documentId;
}
try {
console.log('🔗 링크 생성 시작:', selectedText);
@@ -547,6 +609,12 @@ class LinkManager {
window.documentViewerInstance.showLinkModal = true;
window.documentViewerInstance.linkForm.selected_text = selectedText;
// 서적 목록 로드
await window.documentViewerInstance.loadAvailableBooks();
// 기본적으로 같은 서적 문서들 로드
await window.documentViewerInstance.loadSameBookDocuments();
// 텍스트 오프셋 계산
const documentContent = document.getElementById('document-content');
const fullText = documentContent.textContent;
@@ -563,6 +631,8 @@ class LinkManager {
}
}
/**
* 텍스트 오프셋 계산
*/

View File

@@ -44,7 +44,7 @@ window.documentViewer = () => ({
target_text: '',
target_start_offset: 0,
target_end_offset: 0,
book_scope: 'same', // 'same' 또는 'other'
target_book_id: ''
},
@@ -422,15 +422,254 @@ window.documentViewer = () => ({
activateLinkMode() {
console.log('🔗 링크 모드 활성화');
this.activeMode = 'link';
this.linkManager.createLinkFromSelection();
// 선택된 텍스트 확인
const selectedText = window.getSelection().toString().trim();
const selection = window.getSelection();
if (!selectedText || selection.rangeCount === 0) {
alert('텍스트를 먼저 선택해주세요.');
return;
}
const selectedRange = selection.getRangeAt(0);
this.linkManager.createLinkFromSelection(this.documentId, selectedText, selectedRange);
},
activateNoteMode() {
console.log('📝 메모 모드 활성화');
this.activeMode = 'memo';
this.highlightManager.activateNoteMode();
},
async loadBacklinks() {
console.log('🔗 백링크 로드 시작');
if (this.linkManager) {
await this.linkManager.loadBacklinks(this.documentId);
// UI 상태 동기화
this.backlinks = this.linkManager.backlinks || [];
}
},
async loadAvailableBooks() {
try {
console.log('📚 서적 목록 로딩 시작...');
// 문서 목록에서 서적 정보 추출
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
console.log('📄 모든 문서들 (총 개수):', allDocuments.length);
// 소스 문서의 서적 정보 찾기
const sourceBookInfo = this.getSourceBookInfo(allDocuments);
console.log('📖 소스 문서 서적 정보:', sourceBookInfo);
// 서적별로 그룹화
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
});
}
});
console.log('📚 그룹화된 모든 서적들:', Array.from(bookMap.values()));
// 소스 문서 기준으로 서적 분류
if (sourceBookInfo.id) {
// 소스 서적 제외 (다른 서적만 남김)
const wasDeleted = bookMap.delete(sourceBookInfo.id);
console.log('✅ 소스 서적 제외 결과:', {
sourceBookId: sourceBookInfo.id,
sourceBookTitle: sourceBookInfo.title,
wasDeleted: wasDeleted,
remainingBooks: bookMap.size
});
} else {
console.warn('⚠️ 소스 문서의 서적 정보를 찾을 수 없습니다!');
}
this.availableBooks = Array.from(bookMap.values());
console.log('📚 최종 사용 가능한 서적들 (다른 서적):', this.availableBooks);
console.log('🔍 제외된 소스 서적 ID:', sourceBookInfo.id);
} catch (error) {
console.error('서적 목록 로드 실패:', error);
this.availableBooks = [];
}
},
getSourceBookInfo(allDocuments = null) {
// 여러 소스에서 현재 문서의 서적 정보 찾기
let sourceBookId = this.navigation?.book_info?.id ||
this.document?.book_id ||
this.document?.book_info?.id;
let sourceBookTitle = this.navigation?.book_info?.title ||
this.document?.book_title ||
this.document?.book_info?.title;
// allDocuments에서도 확인 (가장 확실한 방법)
if (allDocuments) {
const currentDoc = allDocuments.find(doc => doc.id === this.documentId);
if (currentDoc) {
sourceBookId = currentDoc.book_id;
sourceBookTitle = currentDoc.book_title;
}
}
return {
id: sourceBookId,
title: sourceBookTitle
};
},
async loadSameBookDocuments() {
try {
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
// 소스 문서의 서적 정보 가져오기
const sourceBookInfo = this.getSourceBookInfo(allDocuments);
console.log('📚 같은 서적 문서 로드 시작:', {
sourceBookId: sourceBookInfo.id,
sourceBookTitle: sourceBookInfo.title,
totalDocs: allDocuments.length
});
if (sourceBookInfo.id) {
// 소스 문서와 같은 서적의 문서들만 필터링 (현재 문서 제외)
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === sourceBookInfo.id && doc.id !== this.documentId
);
console.log('📚 같은 서적 문서들:', {
count: this.filteredDocuments.length,
bookTitle: sourceBookInfo.title,
documents: this.filteredDocuments.map(doc => ({ id: doc.id, title: doc.title }))
});
} else {
console.warn('⚠️ 소스 문서의 서적 정보를 찾을 수 없습니다!');
this.filteredDocuments = [];
}
} catch (error) {
console.error('같은 서적 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
async loadSameBookDocumentsForSelected() {
try {
console.log('📚 선택한 문서 기준으로 같은 서적 문서 로드 시작');
const allDocuments = await this.api.getLinkableDocuments(this.documentId);
// 선택한 대상 문서 찾기
const selectedDoc = allDocuments.find(doc => doc.id === this.linkForm.target_document_id);
if (!selectedDoc) {
console.error('❌ 선택한 문서를 찾을 수 없습니다:', this.linkForm.target_document_id);
return;
}
console.log('🎯 선택한 문서 정보:', {
id: selectedDoc.id,
title: selectedDoc.title,
bookId: selectedDoc.book_id,
bookTitle: selectedDoc.book_title
});
// 선택한 문서와 같은 서적의 모든 문서들 (소스 문서 제외)
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === selectedDoc.book_id && doc.id !== this.documentId
);
console.log('📚 선택한 문서와 같은 서적 문서들:', {
selectedBookTitle: selectedDoc.book_title,
count: this.filteredDocuments.length,
documents: this.filteredDocuments.map(doc => ({ id: doc.id, title: doc.title }))
});
} catch (error) {
console.error('선택한 문서 기준 같은 서적 로드 실패:', error);
this.filteredDocuments = [];
}
},
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);
} else {
this.filteredDocuments = [];
}
// 문서 선택 초기화
this.linkForm.target_document_id = '';
} catch (error) {
console.error('서적별 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
resetTargetSelection() {
console.log('🔄 대상 선택 초기화');
this.linkForm.target_book_id = '';
this.linkForm.target_document_id = '';
this.filteredDocuments = [];
// 초기화 후 아무것도 하지 않음 (서적 선택 후 문서 로드)
},
async onTargetDocumentChange() {
console.log('📄 대상 문서 변경:', this.linkForm.target_document_id);
// 대상 문서 변경 시 특별한 처리 없음
},
selectTextFromDocument() {
console.log('🎯 대상 문서에서 텍스트 선택 시작');
if (!this.linkForm.target_document_id) {
alert('대상 문서를 먼저 선택해주세요.');
return;
}
// 새 창에서 대상 문서 열기 (텍스트 선택 모드 전용 페이지)
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl);
const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes');
if (!popup) {
console.error('❌ 팝업 창이 차단되었습니다!');
alert('팝업 창이 차단되었습니다. 브라우저 설정에서 팝업을 허용해주세요.');
} else {
console.log('✅ 팝업 창이 성공적으로 열렸습니다');
}
// 팝업에서 텍스트 선택 완료 시 메시지 수신
window.addEventListener('message', (event) => {
if (event.data.type === 'TEXT_SELECTED') {
this.linkForm.target_text = event.data.selectedText;
this.linkForm.target_start_offset = event.data.startOffset;
this.linkForm.target_end_offset = event.data.endOffset;
console.log('🎯 대상 텍스트 선택됨:', event.data);
popup.close();
}
}, { once: true });
},
activateBookmarkMode() {
console.log('🔖 북마크 모드 활성화');
this.activeMode = 'bookmark';
@@ -550,6 +789,66 @@ window.documentViewer = () => ({
// 언어 전환 로직 구현 필요
},
async downloadOriginalFile() {
if (!this.document || !this.document.id) {
console.warn('문서 정보가 없습니다');
return;
}
// 연결된 PDF가 있는지 확인
if (!this.document.matched_pdf_id) {
alert('연결된 원본 PDF 파일이 없습니다.\n\n서적 편집 페이지에서 PDF 파일을 연결해주세요.');
return;
}
try {
console.log('📕 연결된 PDF 다운로드 시작:', this.document.matched_pdf_id);
// 연결된 PDF 문서 정보 가져오기
const pdfDocument = await this.api.getDocument(this.document.matched_pdf_id);
if (!pdfDocument) {
throw new Error('연결된 PDF 문서를 찾을 수 없습니다');
}
// PDF 파일 다운로드 URL 생성
const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`;
// 인증 헤더 추가를 위해 fetch 사용
const response = await fetch(downloadUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('연결된 PDF 다운로드에 실패했습니다');
}
// Blob으로 변환하여 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 다운로드 링크 생성 및 클릭
const link = document.createElement('a');
link.href = url;
link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL 정리
window.URL.revokeObjectURL(url);
console.log('📕 PDF 다운로드 완료:', pdfDocument.original_filename);
} catch (error) {
console.error('PDF 다운로드 오류:', error);
alert('PDF 다운로드 중 오류가 발생했습니다: ' + error.message);
}
},
// ==================== 유틸리티 메서드 ====================
formatDate(dateString) {
return new Date(dateString).toLocaleString('ko-KR');
@@ -574,12 +873,8 @@ window.documentViewer = () => ({
},
getSelectedBookTitle() {
if (this.linkForm.book_scope === 'same') {
return this.document?.book_title || '현재 서적';
} else {
const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
return selectedBook ? selectedBook.title : '서적을 선택하세요';
}
const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
return selectedBook ? selectedBook.title : '서적을 선택하세요';
},
// ==================== 모듈 메서드 위임 ====================
@@ -629,6 +924,94 @@ window.documentViewer = () => ({
deleteBookmark(bookmarkId) {
return this.bookmarkManager.deleteBookmark(bookmarkId);
},
// ==================== 링크 생성 ====================
async createDocumentLink() {
console.log('🔗 createDocumentLink 함수 실행');
console.log('📋 현재 linkForm 상태:', JSON.stringify(this.linkForm, null, 2));
try {
// 링크 데이터 검증
if (!this.linkForm.target_document_id) {
alert('대상 문서를 선택해주세요.');
return;
}
if (this.linkForm.link_type === 'text' && !this.linkForm.target_text) {
alert('대상 문서에서 텍스트를 선택해주세요. "대상 문서에서 텍스트 선택" 버튼을 클릭하여 연결할 텍스트를 드래그해주세요.');
return;
}
// API 호출용 데이터 준비 (백엔드 필드명에 맞춤)
const linkData = {
target_document_id: this.linkForm.target_document_id,
selected_text: this.linkForm.selected_text, // 백엔드: selected_text
start_offset: this.linkForm.start_offset, // 백엔드: start_offset
end_offset: this.linkForm.end_offset, // 백엔드: end_offset
link_text: this.linkForm.link_text || this.linkForm.selected_text,
description: this.linkForm.description,
link_type: this.linkForm.link_type,
target_text: this.linkForm.target_text || null,
target_start_offset: this.linkForm.target_start_offset || null,
target_end_offset: this.linkForm.target_end_offset || null
};
console.log('📤 링크 생성 데이터:', linkData);
console.log('📤 링크 생성 데이터 (JSON):', JSON.stringify(linkData, null, 2));
// 필수 필드 검증
const requiredFields = ['target_document_id', 'selected_text', 'start_offset', 'end_offset'];
const missingFields = requiredFields.filter(field =>
linkData[field] === undefined || linkData[field] === null || linkData[field] === ''
);
if (missingFields.length > 0) {
console.error('❌ 필수 필드 누락:', missingFields);
alert('필수 필드가 누락되었습니다: ' + missingFields.join(', '));
return;
}
console.log('✅ 모든 필수 필드 확인됨');
// API 호출
await this.api.createDocumentLink(this.documentId, linkData);
console.log('✅ 링크 생성됨');
// 성공 알림
alert('링크가 성공적으로 생성되었습니다!');
// 모달 닫기
this.showLinkModal = false;
// 링크 목록 새로고침
console.log('🔄 링크 목록 새로고침 시작...');
await this.linkManager.loadDocumentLinks(this.documentId);
this.documentLinks = this.linkManager.documentLinks || [];
console.log('📊 로드된 링크 개수:', this.documentLinks.length);
console.log('📊 링크 데이터:', this.documentLinks);
// 링크 렌더링
console.log('🎨 링크 렌더링 시작...');
await this.linkManager.renderDocumentLinks();
console.log('✅ 링크 렌더링 완료');
} catch (error) {
console.error('링크 생성 실패:', error);
console.error('에러 상세:', {
message: error.message,
stack: error.stack,
response: error.response
});
// 422 에러인 경우 상세 정보 표시
if (error.response && error.response.status === 422) {
console.error('422 Validation Error Details:', error.response.data);
alert('데이터 검증 실패: ' + JSON.stringify(error.response.data, null, 2));
} else {
alert('링크 생성에 실패했습니다: ' + error.message);
}
}
}
});
@@ -649,3 +1032,44 @@ document.addEventListener('alpine:init', () => {
}
};
});
// Alpine.js Store 등록
document.addEventListener('alpine:init', () => {
Alpine.store('documentViewer', {
instance: null,
init() {
// DocumentViewer 인스턴스가 생성되면 저장
setTimeout(() => {
this.instance = window.documentViewerInstance;
}, 500);
},
downloadOriginalFile() {
console.log('🏪 Store downloadOriginalFile 호출');
if (this.instance) {
return this.instance.downloadOriginalFile();
} else {
console.warn('DocumentViewer 인스턴스가 없습니다');
}
},
toggleLanguage() {
console.log('🏪 Store toggleLanguage 호출');
if (this.instance) {
return this.instance.toggleLanguage();
} else {
console.warn('DocumentViewer 인스턴스가 없습니다');
}
},
loadBacklinks() {
console.log('🏪 Store loadBacklinks 호출');
if (this.instance) {
return this.instance.loadBacklinks();
} else {
console.warn('DocumentViewer 인스턴스가 없습니다');
}
}
});
});