// helpers-core.js — foundational helpers extracted from helpers.js. // escHtml + file/link button builder + chat-bubble/image-zoom/download UI + // dynamic CDN loaders (SheetJS, Luckysheet, PDF.js) + timeAgo/openOllamaInfo. // Plain globals; loaded before the other split helper files and before app.js. function escHtml(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(/\\/g,'\').replace(/`/g,'`').replace(/\$/g,'$').replace(//g,'>').replace(/"/g,'"'); } function _normalizeFileUrl(url) { // Convert sandbox:/mnt/... or /mnt/user-data/... paths to /api/files/... URLs if (/^sandbox:/i.test(url)) { var p = url.replace(/^sandbox:\/*/i, ''); var idx = p.indexOf('uploads/'); return '/api/files/' + (idx !== -1 ? p.slice(idx) : 'uploads/' + p.split('/').pop()); } if (/^\/mnt\/(user-data|data)\//i.test(url)) { var idx = url.indexOf('uploads/'); return '/api/files/' + (idx !== -1 ? url.slice(idx) : 'uploads/' + url.split('/').pop()); } return url; } function _renderLinkButton(text, url, opts) { var hideDownload = opts && opts.hideDownload; url = _normalizeFileUrl(url); var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3']; var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); }) || fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; }); var decodedUrl = decodeURIComponent(url).toLowerCase(); var isPptx = decodedUrl.indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx'); var isPdf = decodedUrl.indexOf('.pdf') !== -1 || text.toLowerCase().endsWith('.pdf'); var isXlsx = /\.(xlsx|xls)(\?.*)?$/i.test(decodedUrl) || /\.(xlsx|xls)$/i.test(text.toLowerCase()); if (isPptx) { var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9); return '
' + '' + '⬇ ' + text + '' + ' ' + '' + '
'; } if (isPdf) { var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9); return '
' + '
' + '' + '📄 ' + text + '' + '' + (hideDownload ? '' : '⬇ 저장') + '
' + '' + '
'; } if (isXlsx) { var xlsxSafeUrl = escHtml(url); return '
' + '
' + '📊' + '' + escHtml(text) + '' + '' + '' + '⬇ 다운로드' + '
' + '' + '
'; } var isTxt = /\.txt(\?[^"]*)?$/i.test(decodedUrl) || /\.txt$/i.test(text.toLowerCase()); if (isTxt) { var txtLabel = escHtml(text.replace(/^📝\s*/, '')); return '
' + '
' + '📝' + '' + txtLabel + '' + '' + '⬇ 저장' + '
' + '' + '
'; } if (isDownload) { return '' + '⬇ ' + text + ''; } return '' + text + ''; } function appendChatBubble(role, html) { var wrap = document.getElementById('chat-messages'); if (!wrap) return; var div = document.createElement('div'); div.className = 'msg msg-' + role; div.innerHTML = '
' + html + '
'; wrap.appendChild(div); wrap.scrollTop = wrap.scrollHeight; } function openImageZoom(src) { var modal = document.getElementById('image-zoom-modal'); var img = document.getElementById('image-zoom-img'); if (modal && img) { img.src = src; modal.style.display = 'flex'; } } function closeImageZoom() { var modal = document.getElementById('image-zoom-modal'); if (modal) modal.style.display = 'none'; } // Cache of checked /api/files/ URLs: url -> true (exists) | false (not found) var _dlLinkCache = {}; function markDownloadLinkError(btn) { var href = btn.getAttribute ? btn.getAttribute('href') : (btn.href || ''); var filename = decodeURIComponent((href || '').split('/').pop() || '파일'); _dlLinkCache[href] = false; var span = document.createElement('span'); span.className = 'download-btn-error'; span.title = '파일을 찾을 수 없음: ' + filename; span.textContent = '⚠ ' + filename + ' (파일 없음)'; if (btn.parentNode) btn.parentNode.replaceChild(span, btn); } function validateDownloadLinks(container) { var buttons = (container || document).querySelectorAll('a.download-btn[href]'); buttons.forEach(function(btn) { var url = btn.getAttribute('href'); if (!url || url.indexOf('/api/files/') === -1) return; if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; } if (_dlLinkCache[url] === true || _dlLinkCache[url] === 'pending') return; _dlLinkCache[url] = 'pending'; fetch(url, { method: 'HEAD', headers: authHeaders() }) .then(function(r) { _dlLinkCache[url] = r.ok; if (!r.ok) { var found = (container || document).querySelector('a.download-btn[href="' + url.replace(/"/g, '\\"') + '"]'); if (found) markDownloadLinkError(found); } }) .catch(function() { _dlLinkCache[url] = false; }); }); } function downloadFile(e, el) { e.preventDefault(); e.stopPropagation(); var url = el.getAttribute('href'); if (!url) return; // Ensure absolute URL (handle sandboxed environments where origin may be sandbox:/) if (!url.match(/^https?:\/\//)) { if (url.charAt(0) === '/') { // Already an absolute path — use relative to current host url = url; } else { url = new URL(url, window.location.origin).href; } } var filename = decodeURIComponent(url.split('/').pop() || 'download'); fetch(url, { headers: authHeaders() }) .then(function(r) { if (!r.ok) throw new Error(r.status); return r.blob(); }) .then(function(blob) { var blobUrl = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); }) .catch(function(err) { markDownloadLinkError(el); }); } var _sheetJsLoaded = false; function loadSheetJS(cb) { if (_sheetJsLoaded) return cb(); var s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'; s.onload = function() { _sheetJsLoaded = true; cb(); }; document.head.appendChild(s); } var _luckyLoaded = false; function loadLuckysheet(cb) { if (_luckyLoaded) return cb(); var cssList = [ 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/css/pluginsCss.css', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/plugins.css', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/css/luckysheet.css', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/assets/iconfont/iconfont.css', ]; cssList.forEach(function(href) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l); }); var scripts = [ 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/plugins/js/plugin.js', 'https://cdn.jsdelivr.net/npm/luckysheet@2.1.13/dist/luckysheet.umd.js', 'https://cdn.jsdelivr.net/npm/luckyexcel@1.0.1/dist/luckyexcel.umd.js', ]; function next(i) { if (i >= scripts.length) { _luckyLoaded = true; cb(); return; } var s = document.createElement('script'); s.src = scripts[i]; s.onload = function() { next(i + 1); }; s.onerror = function() { next(i + 1); }; // 실패해도 진행 document.head.appendChild(s); } next(0); } var _pdfJsLoaded = false; function loadPdfJs(cb) { if (_pdfJsLoaded) return cb(); var s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; s.onload = function() { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; _pdfJsLoaded = true; cb(); }; document.head.appendChild(s); } function timeAgo(ts) { const diff = Date.now() - ts; if (diff < 60000) return 'just now'; if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`; return `${Math.floor(diff/86400000)}d ago`; } function openOllamaInfo() { window.open('http://localhost:11434','_blank'); }