// 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 '
';
}
if (isPdf) {
var pdfId = 'pdf-embed-' + Math.random().toString(36).substr(2, 9);
return ''
+ '
'
+ '
'
+ ''
+ '
'
+ '
';
}
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'); }