/** * LinkManager 모듈 * 문서 링크 및 백링크 통합 관리 */ class LinkManager { constructor(api) { console.log('🔗 LinkManager 초기화 시작'); this.api = api; // 캐싱된 API 사용 (사용 가능한 경우) 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 = []; this.filteredDocuments = []; console.log('✅ LinkManager 초기화 완료'); } /** * 문서/노트 링크 데이터 로드 */ async loadDocumentLinks(documentId, contentType = 'document') { try { 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(apiEndpoint, {}, { category: 'links' }); console.log('📡 원본 API 응답:', response); console.log('📡 응답 타입:', typeof response); console.log('📡 응답이 배열인가?', Array.isArray(response)); console.log('📡 응답 JSON 문자열:', JSON.stringify(response, null, 2)); console.log('📡 응답 키들:', Object.keys(response || {})); // API 응답이 객체일 경우 처리 if (Array.isArray(response)) { this.documentLinks = response; } else if (response && typeof response === 'object') { // 객체에서 배열 추출 시도 if (response.data && Array.isArray(response.data)) { this.documentLinks = response.data; } else if (response.links && Array.isArray(response.links)) { this.documentLinks = response.links; } else { console.warn('⚠️ 예상치 못한 API 응답 구조:', response); this.documentLinks = []; } } else { 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) { console.error('❌ 문서 링크 로드 실패:', error); this.documentLinks = []; return []; } } /** * 백링크 데이터 로드 */ async loadBacklinks(documentId, contentType = 'document') { try { 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)); console.log('📡 백링크 응답 JSON 문자열:', JSON.stringify(response, null, 2)); console.log('📡 백링크 응답 키들:', Object.keys(response || {})); // API 응답이 객체일 경우 처리 if (Array.isArray(response)) { this.backlinks = response; } else if (response && typeof response === 'object') { // 객체에서 배열 추출 시도 if (response.data && Array.isArray(response.data)) { this.backlinks = response.data; } else if (response.backlinks && Array.isArray(response.backlinks)) { this.backlinks = response.backlinks; } else { console.warn('⚠️ 예상치 못한 백링크 API 응답 구조:', response); this.backlinks = []; } } else { this.backlinks = []; } console.log('📡 최종 백링크 데이터:', this.backlinks); console.log('📡 최종 백링크 개수:', this.backlinks.length); return this.backlinks; } catch (error) { console.error('❌ 백링크 로드 실패:', error); this.backlinks = []; return []; } } /** * 문서 링크 렌더링 */ renderDocumentLinks() { const documentContent = document.getElementById('document-content'); if (!documentContent) { console.error('❌ document-content 요소를 찾을 수 없습니다'); 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, '개'); console.log('🔗 링크 데이터 상세:', this.documentLinks); // 기존 링크 제거 (항상 실행) const existingLinks = documentContent.querySelectorAll('.document-link'); console.log('🔍 기존 링크 제거:', existingLinks.length, '개'); existingLinks.forEach(el => { const parent = el.parentNode; parent.replaceChild(document.createTextNode(el.textContent), el); parent.normalize(); }); // 링크 데이터가 없으면 여기서 종료 if (this.documentLinks.length === 0) { console.log('📝 링크 데이터가 없습니다.'); return; } // 각 링크 렌더링 if (Array.isArray(this.documentLinks)) { this.documentLinks.forEach(link => { this.renderSingleLink(link); }); } else { console.warn('⚠️ this.documentLinks가 배열이 아닙니다:', typeof this.documentLinks, this.documentLinks); } console.log('✅ 링크 렌더링 완료'); } /** * 개별 링크 렌더링 */ renderSingleLink(link) { console.log('🔗 renderSingleLink 시작:', link.id, link.selected_text); const content = document.getElementById('document-content'); const textContent = content.textContent; console.log('📝 문서 텍스트 길이:', textContent.length); console.log('📍 링크 위치:', link.start_offset, '-', link.end_offset); console.log('📝 예상 텍스트:', textContent.substring(link.start_offset, link.end_offset)); if (link.start_offset >= textContent.length || link.end_offset > textContent.length) { console.warn('❌ 링크 위치가 텍스트 범위를 벗어남:', link); console.log('📝 텍스트 미리보기:', textContent.substring(0, 200)); console.log('📝 링크 주변 텍스트:', textContent.substring(Math.max(0, link.start_offset - 50), link.start_offset + 50)); return; } // 실제 텍스트와 선택된 텍스트가 일치하는지 확인 const actualText = textContent.substring(link.start_offset, link.end_offset); if (actualText !== link.selected_text) { console.warn('⚠️ 오프셋 위치의 텍스트가 선택된 텍스트와 다름'); console.log('📍 예상:', link.selected_text); console.log('📍 실제:', actualText); // 텍스트 검색으로 대체 시도 const searchIndex = textContent.indexOf(link.selected_text); if (searchIndex !== -1) { console.log('✅ 텍스트 검색으로 위치 찾음:', searchIndex); link.start_offset = searchIndex; link.end_offset = searchIndex + link.selected_text.length; } else { console.error('❌ 링크 텍스트를 찾을 수 없음:', link.selected_text); return; } } const walker = document.createTreeWalker( content, NodeFilter.SHOW_TEXT, null, false ); let currentOffset = 0; let node; while (node = walker.nextNode()) { const nodeLength = node.textContent.length; const nodeStart = currentOffset; const nodeEnd = currentOffset + nodeLength; // 링크 범위와 겹치는지 확인 if (nodeEnd > link.start_offset && nodeStart < link.end_offset) { const linkStart = Math.max(0, link.start_offset - nodeStart); const linkEnd = Math.min(nodeLength, link.end_offset - nodeStart); if (linkStart < linkEnd) { console.log('✅ 링크 적용:', linkStart, '-', linkEnd, '텍스트:', node.textContent.substring(linkStart, linkEnd)); this.applyLinkToNode(node, linkStart, linkEnd, link); break; } } currentOffset = nodeEnd; } } /** * 텍스트 노드에 링크 적용 */ applyLinkToNode(textNode, start, end, link) { console.log('🎨 applyLinkToNode 시작:', start, end, link.id); const text = textNode.textContent; const beforeText = text.substring(0, start); const linkText = text.substring(start, end); const afterText = text.substring(end); console.log('📝 링크 텍스트:', linkText); // 링크 스팬 생성 const span = document.createElement('span'); span.className = 'document-link'; span.textContent = linkText; span.dataset.linkId = link.id; // 링크 스타일 (보라색) - 레이아웃 안전 span.style.cssText = ` color: #7C3AED !important; text-decoration: underline !important; cursor: pointer !important; background-color: rgba(124, 58, 237, 0.1) !important; border-radius: 2px !important; padding: 0 1px !important; display: inline !important; line-height: inherit !important; vertical-align: baseline !important; margin: 0 !important; box-sizing: border-box !important; `; // 클릭 이벤트 추가 (겹치는 요소 감지) span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // 겹치는 하이라이트나 백링크가 있는지 확인 const overlappingElements = this.findOverlappingElements(span); if (overlappingElements.length > 0) { this.showOverlapMenu(e, span, overlappingElements, 'link'); } else { this.showLinkTooltip(link, span); } }); // DOM 교체 const parent = textNode.parentNode; const fragment = document.createDocumentFragment(); if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); fragment.appendChild(span); if (afterText) fragment.appendChild(document.createTextNode(afterText)); parent.replaceChild(fragment, textNode); console.log('✅ 링크 DOM 교체 완료:', linkText); } /** * 백링크 렌더링 (링크와 동일한 방식) */ renderBacklinks() { const documentContent = document.getElementById('document-content'); if (!documentContent) { console.error('❌ document-content 요소를 찾을 수 없습니다'); 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, '개'); console.log('🔗 백링크 데이터 상세:', this.backlinks); // 기존 백링크 확인 (제거하지 않고 중복 체크만) const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견`); // 백링크 데이터가 없으면 여기서 종료 if (this.backlinks.length === 0) { console.log('📝 백링크 데이터가 없습니다.'); return; } // 각 백링크 렌더링 (중복되지 않는 것만) 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('✅ 백링크 렌더링 완료'); } /** * 개별 백링크 렌더링 */ renderSingleBacklink(backlink) { console.log('🔗 renderSingleBacklink 시작:', backlink.id, backlink.target_text); const content = document.getElementById('document-content'); if (!content) { console.error('❌ document-content 요소를 찾을 수 없습니다'); return; } // 원본 문서 내용 사용 (오프셋 정확성을 위해) const textContent = content.textContent || content.innerText || ''; console.log('📝 문서 텍스트 길이:', textContent.length); // target_start_offset과 target_end_offset이 있으면 직접 사용 let textIndex, searchText, searchLength; if (backlink.target_start_offset !== undefined && backlink.target_end_offset !== undefined) { // 오프셋 정보가 있으면 직접 사용 (더 정확함) textIndex = backlink.target_start_offset; searchLength = backlink.target_end_offset - backlink.target_start_offset; searchText = textContent.substring(textIndex, textIndex + searchLength); console.log('✅ 오프셋으로 백링크 텍스트 찾음:', searchText, '위치:', textIndex); } else { // 오프셋 정보가 없으면 텍스트 검색 사용 searchText = backlink.target_text || backlink.selected_text; if (!searchText) { console.warn('❌ 백링크 텍스트 정보가 없음:', backlink); return; } // 텍스트 검색 (대소문자 무시, 공백 정규화) const normalizedContent = textContent.replace(/\s+/g, ' ').trim(); const normalizedSearchText = searchText.replace(/\s+/g, ' ').trim(); textIndex = normalizedContent.indexOf(normalizedSearchText); if (textIndex === -1) { // 부분 검색 시도 const words = normalizedSearchText.split(' '); if (words.length > 1) { const firstWord = words[0]; const lastWord = words[words.length - 1]; const partialPattern = firstWord + '.*' + lastWord; const regex = new RegExp(partialPattern, 'i'); const match = normalizedContent.match(regex); if (match) { textIndex = match.index; console.log('✅ 부분 매칭으로 백링크 텍스트 찾음:', searchText); } } } if (textIndex === -1) { console.warn('❌ 백링크 텍스트를 찾을 수 없음:', searchText); console.log('📝 검색 대상 텍스트 미리보기:', normalizedContent.substring(0, 200)); console.log('📝 전체 텍스트 길이:', normalizedContent.length); return; } searchLength = searchText.length; console.log('✅ 텍스트 검색으로 백링크 텍스트 찾음:', searchText, '위치:', textIndex); } const walker = document.createTreeWalker( content, NodeFilter.SHOW_TEXT, null, false ); let currentOffset = 0; let node; while (node = walker.nextNode()) { const nodeLength = node.textContent.length; const nodeStart = currentOffset; const nodeEnd = currentOffset + nodeLength; // 백링크 범위와 겹치는지 확인 if (nodeEnd > textIndex && nodeStart < textIndex + searchLength) { const backlinkStart = Math.max(0, textIndex - nodeStart); const backlinkEnd = Math.min(nodeLength, textIndex + searchLength - nodeStart); if (backlinkStart < backlinkEnd) { console.log('✅ 백링크 적용:', backlinkStart, '-', backlinkEnd, '텍스트:', node.textContent.substring(backlinkStart, backlinkEnd)); this.applyBacklinkToNode(node, backlinkStart, backlinkEnd, backlink); break; } } currentOffset = nodeEnd; } } /** * 텍스트 노드에 백링크 적용 */ applyBacklinkToNode(textNode, start, end, backlink) { console.log('🎨 applyBacklinkToNode 시작:', start, end, backlink.id); const text = textNode.textContent; const beforeText = text.substring(0, start); const backlinkText = text.substring(start, end); const afterText = text.substring(end); console.log('📝 백링크 텍스트:', backlinkText); // 백링크 스팬 생성 const span = document.createElement('span'); span.className = 'backlink-highlight'; span.textContent = backlinkText; span.dataset.backlinkId = backlink.id; // 백링크 스타일 (주황색) - 레이아웃 안전 span.style.cssText = ` color: #EA580C !important; text-decoration: underline !important; cursor: pointer !important; background-color: rgba(234, 88, 12, 0.2) !important; border: 1px solid #EA580C !important; border-radius: 3px !important; padding: 0 2px !important; font-weight: bold !important; display: inline !important; line-height: inherit !important; vertical-align: baseline !important; margin: 0 !important; box-sizing: border-box !important; `; // 클릭 이벤트 추가 (겹치는 요소 감지) span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // 겹치는 하이라이트나 링크가 있는지 확인 const overlappingElements = this.findOverlappingElements(span); if (overlappingElements.length > 0) { this.showOverlapMenu(e, span, overlappingElements, 'backlink'); } else { this.showBacklinkTooltip(backlink, span); } }); // DOM 교체 const parent = textNode.parentNode; const fragment = document.createDocumentFragment(); if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); fragment.appendChild(span); if (afterText) fragment.appendChild(document.createTextNode(afterText)); parent.replaceChild(fragment, textNode); console.log('✅ 백링크 DOM 교체 완료:', backlinkText); } /** * 링크 툴팁 표시 */ showLinkTooltip(link, element) { this.hideTooltip(); const tooltip = document.createElement('div'); tooltip.id = 'link-tooltip'; tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-5 z-50 max-w-2xl'; tooltip.style.minWidth = '450px'; tooltip.style.maxHeight = '80vh'; tooltip.style.overflowY = 'auto'; // 생성 날짜 포맷팅 const createdDate = link.created_at ? this.formatDate(link.created_at) : '알 수 없음'; const tooltipHTML = `