Spaces:
Sleeping
Sleeping
| /** | |
| * Alldocex - Intelligent Document Processing | |
| * Frontend application logic | |
| */ | |
| // ===== State ===== | |
| let currentTaskId = null; | |
| let pollInterval = null; | |
| // ===== DOM Elements ===== | |
| const $ = (sel) => document.querySelector(sel); | |
| const $$ = (sel) => document.querySelectorAll(sel); | |
| const dropZone = $('#dropZone'); | |
| const fileInput = $('#fileInput'); | |
| const uploadSection = $('#uploadSection'); | |
| const processingSection = $('#processingSection'); | |
| const resultsSection = $('#resultsSection'); | |
| const toastContainer = $('#toastContainer'); | |
| const btnExtractUrl = $('#btnExtractUrl'); | |
| const urlInput = $('#urlInput'); | |
| // ===== Init ===== | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initUpload(); | |
| initTabs(); | |
| initButtons(); | |
| }); | |
| // ===== Health Check ===== | |
| // ===== Upload ===== | |
| function initUpload() { | |
| // Click to upload | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| // File selected | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| handleFile(e.target.files[0]); | |
| } | |
| }); | |
| // URL input | |
| btnExtractUrl.addEventListener('click', () => { | |
| const url = urlInput.value.trim(); | |
| if (url) { | |
| handleUrl(url); | |
| } else { | |
| showToast('Please enter a valid URL', 'error'); | |
| } | |
| }); | |
| // Drag and drop | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('drag-over'); | |
| }); | |
| dropZone.addEventListener('dragleave', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| if (e.dataTransfer.files.length > 0) { | |
| handleFile(e.dataTransfer.files[0]); | |
| } | |
| }); | |
| // Format badge filters | |
| $$('.format-badge').forEach(badge => { | |
| badge.addEventListener('click', (e) => { | |
| e.stopPropagation(); // Don't trigger the main dropZone click | |
| const format = badge.textContent.trim().toLowerCase(); | |
| openFilteredPicker(format); | |
| }); | |
| }); | |
| } | |
| function openFilteredPicker(format) { | |
| const defaultAccept = fileInput.accept; | |
| // Map of extensions | |
| const extMap = { | |
| pdf: '.pdf', | |
| docx: '.docx', | |
| png: '.png', | |
| jpg: '.jpg,.jpeg', | |
| jpeg: '.jpg,.jpeg', | |
| tiff: '.tiff', | |
| bmp: '.bmp', | |
| webp: '.webp' | |
| }; | |
| if (extMap[format]) { | |
| fileInput.accept = extMap[format]; | |
| } | |
| fileInput.click(); | |
| // Reset accept after a short delay so the main zone works normally | |
| setTimeout(() => { | |
| fileInput.accept = defaultAccept; | |
| }, 500); | |
| } | |
| async function handleFile(file) { | |
| // Validate extension | |
| const validExts = ['pdf', 'docx', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'webp']; | |
| const ext = file.name.split('.').pop().toLowerCase(); | |
| if (!validExts.includes(ext)) { | |
| showToast(`Unsupported file type: .${ext}`, 'error'); | |
| return; | |
| } | |
| // Validate size (20MB) | |
| if (file.size > 20 * 1024 * 1024) { | |
| showToast('File too large. Maximum size: 20MB', 'error'); | |
| return; | |
| } | |
| // Show processing UI | |
| showSection('processing'); | |
| resetProcessingSteps(); | |
| // Upload | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json(); | |
| throw new Error(err.detail || 'Upload failed'); | |
| } | |
| const data = await res.json(); | |
| currentTaskId = data.file_id; | |
| // Start polling for results | |
| updateStep('stepExtract', 'active'); | |
| startPolling(data.file_id); | |
| } catch (e) { | |
| showToast(e.message || 'Upload failed', 'error'); | |
| showSection('upload'); | |
| } | |
| } | |
| async function handleUrl(url) { | |
| if (!url.startsWith('http')) { | |
| showToast('URL must start with http:// or https://', 'error'); | |
| return; | |
| } | |
| try { | |
| resetAll(); | |
| showSection('processing'); | |
| updateStep('stepExtract', 'active'); | |
| const response = await fetch('/api/extract/url', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ url: url }) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Failed to start URL extraction'); | |
| } | |
| const data = await response.json(); | |
| currentTaskId = data.file_id; | |
| // Polling results | |
| startPolling(data.file_id); | |
| } catch (error) { | |
| showSection('upload'); | |
| showToast(error.message, 'error'); | |
| } | |
| } | |
| // ===== Polling ===== | |
| function startPolling(taskId) { | |
| if (pollInterval) clearInterval(pollInterval); | |
| pollInterval = setInterval(async () => { | |
| try { | |
| const res = await fetch(`/api/status/${taskId}`); | |
| const data = await res.json(); | |
| if (data.status === 'processing') { | |
| // Update steps based on available data | |
| if (data.extraction) { | |
| updateStep('stepExtract', 'done'); | |
| updateStep('stepSummary', 'active'); | |
| } | |
| if (data.summary) { | |
| updateStep('stepSummary', 'done'); | |
| updateStep('stepEntities', 'active'); | |
| } | |
| if (data.entities) { | |
| updateStep('stepEntities', 'done'); | |
| updateStep('stepSentiment', 'active'); | |
| } | |
| if (data.sentiment) { | |
| updateStep('stepSentiment', 'done'); | |
| } | |
| } | |
| if (data.status === 'completed' || data.status === 'error') { | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| // Mark all steps as done | |
| updateStep('stepExtract', 'done'); | |
| updateStep('stepSummary', 'done'); | |
| updateStep('stepEntities', 'done'); | |
| updateStep('stepSentiment', 'done'); | |
| // Short delay to show completion | |
| setTimeout(() => { | |
| if (data.status === 'error' && !data.extraction) { | |
| showToast(data.error_message || 'Processing failed', 'error'); | |
| showSection('upload'); | |
| } else { | |
| displayResults(data); | |
| showSection('results'); | |
| } | |
| }, 600); | |
| } | |
| } catch (e) { | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| showToast('Lost connection to server', 'error'); | |
| showSection('upload'); | |
| } | |
| }, 800); | |
| } | |
| // ===== Display Results ===== | |
| function displayResults(data) { | |
| // File info bar | |
| const typeIcons = { pdf: '📕', docx: '📘', image: '🖼️' }; | |
| $('#fileTypeIcon').textContent = typeIcons[data.file_type] || '📄'; | |
| $('#fileName').textContent = data.filename; | |
| const meta = data.extraction?.metadata; | |
| const parts = [data.file_type.toUpperCase()]; | |
| if (meta?.word_count) parts.push(`${meta.word_count.toLocaleString()} words`); | |
| if (meta?.page_count) parts.push(`${meta.page_count} pages`); | |
| $('#fileMeta').textContent = parts.join(' • '); | |
| const timeSeconds = (data.processing_time_ms / 1000).toFixed(1); | |
| $('#processingTime').textContent = `⏱ ${timeSeconds}s`; | |
| // Fallback parser in case CDN fails or is blocked | |
| const parseMarkdown = (text) => { | |
| if (!text) return ''; | |
| if (window.marked && window.marked.parse) { | |
| return window.marked.parse(text); | |
| } else if (window.marked) { | |
| return window.marked(text); | |
| } | |
| // Very basic fallback if marked fails to load | |
| return escapeHtml(text).replace(/\n/g, '<br>').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); | |
| }; | |
| // Extracted Text | |
| const textEl = $('#extractedText'); | |
| if (data.extraction?.raw_text) { | |
| textEl.innerHTML = parseMarkdown(data.extraction.raw_text); | |
| } else { | |
| textEl.innerHTML = `<p class="placeholder">${data.extraction?.error_message || 'No text extracted.'}</p>`; | |
| } | |
| // Summary | |
| if (data.summary) { | |
| $('#summaryContent').innerHTML = parseMarkdown(data.summary.summary || 'Summary generation failed.'); | |
| $('#summaryStats').classList.remove('hidden'); | |
| $('#statOriginalLen').textContent = data.summary.original_length.toLocaleString(); | |
| $('#statSummaryLen').textContent = data.summary.summary_length.toLocaleString(); | |
| const pct = Math.round((1 - data.summary.compression_ratio) * 100); | |
| $('#statCompression').textContent = `${pct}%`; | |
| $('#statAlgorithm').textContent = data.summary.algorithm; | |
| // Render Key Highlights | |
| const highlightsContainer = $('#keyHighlightsContainer'); | |
| const highlightsList = $('#highlightsList'); | |
| if (data.summary.key_points && data.summary.key_points.length > 0) { | |
| highlightsContainer.classList.remove('hidden'); | |
| highlightsList.innerHTML = data.summary.key_points | |
| .map(point => { | |
| let escaped = escapeHtml(point); | |
| let bolded = escaped.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); | |
| return `<li>${bolded}</li>`; | |
| }) | |
| .join(''); | |
| } else { | |
| highlightsContainer.classList.add('hidden'); | |
| } | |
| } else { | |
| $('#summaryContent').innerHTML = '<p class="placeholder">Summarization not available.</p>'; | |
| $('#summaryStats').classList.add('hidden'); | |
| $('#keyHighlightsContainer').classList.add('hidden'); | |
| } | |
| // Entities | |
| displayEntities(data.entities); | |
| // Sentiment | |
| displaySentiment(data.sentiment); | |
| // Metadata | |
| displayMetadata(data.extraction?.metadata); | |
| // Activate first tab | |
| activateTab('extracted'); | |
| } | |
| function displayEntities(entityData) { | |
| const catEl = $('#entityCategories'); | |
| const listEl = $('#entityList'); | |
| const countEl = $('#entityCount'); | |
| if (!entityData || entityData.entities.length === 0) { | |
| catEl.innerHTML = '<p class="placeholder">No entities detected in this document.</p>'; | |
| listEl.innerHTML = ''; | |
| countEl.textContent = '0 entities found'; | |
| return; | |
| } | |
| countEl.textContent = `${entityData.total_entities} entities found`; | |
| // Category badges | |
| const catColors = { | |
| PERSON: '#ec4899', ORG: '#3b82f6', GPE: '#10b981', DATE: '#f59e0b', | |
| MONEY: '#8b5cf6', EVENT: '#06b6d4', PRODUCT: '#fb923c', LAW: '#a855f7', | |
| NORP: '#f472b6', EMAIL: '#06b6d4', PHONE: '#3b82f6', URL: '#10b981', | |
| TIME: '#f59e0b', PERCENT: '#8b5cf6', CARDINAL: '#94a3b8', | |
| }; | |
| catEl.innerHTML = Object.entries(entityData.entity_counts) | |
| .sort((a, b) => b[1] - a[1]) | |
| .map(([label, count]) => ` | |
| <div class="entity-category-badge"> | |
| <span class="cat-dot" style="background: ${catColors[label] || '#94a3b8'}"></span> | |
| ${label} | |
| <span class="cat-count">${count}</span> | |
| </div> | |
| `).join(''); | |
| // Entity list | |
| listEl.innerHTML = entityData.entities | |
| .slice(0, 100) | |
| .map(ent => ` | |
| <div class="entity-item"> | |
| <div class="entity-item-left"> | |
| <span class="entity-type-badge badge-${ent.label}">${ent.label}</span> | |
| <span class="entity-text" title="${escapeHtml(ent.text)}">${escapeHtml(ent.text)}</span> | |
| </div> | |
| ${ent.count > 1 ? `<span class="entity-item-count">×${ent.count}</span>` : ''} | |
| </div> | |
| `).join(''); | |
| } | |
| function displaySentiment(sentData) { | |
| const overviewEl = $('#sentimentOverview'); | |
| if (!sentData) { | |
| overviewEl.innerHTML = '<p class="placeholder">Sentiment analysis not available.</p>'; | |
| return; | |
| } | |
| const score = sentData.overall_compound; | |
| const label = sentData.overall_label; | |
| const posW = Math.round(sentData.overall_positive * 100); | |
| const neuW = Math.round(sentData.overall_neutral * 100); | |
| const negW = Math.round(sentData.overall_negative * 100); | |
| // Label color | |
| let labelColor; | |
| if (score >= 0.05) labelColor = 'var(--accent-green)'; | |
| else if (score <= -0.05) labelColor = 'var(--accent-red)'; | |
| else labelColor = 'var(--text-muted)'; | |
| let html = ` | |
| <div class="sentiment-gauge-container"> | |
| <div class="sentiment-label-display" style="color: ${labelColor}">${label}</div> | |
| <div class="sentiment-score">${score >= 0 ? '+' : ''}${score.toFixed(3)}</div> | |
| <div class="sentiment-bar-container"> | |
| <div class="sentiment-bar"> | |
| <div class="sentiment-bar-positive" style="width: ${posW}%"></div> | |
| <div class="sentiment-bar-neutral" style="width: ${neuW}%"></div> | |
| <div class="sentiment-bar-negative" style="width: ${negW}%"></div> | |
| </div> | |
| <div class="sentiment-bar-labels"> | |
| <span><span class="dot dot-pos"></span> Positive ${posW}%</span> | |
| <span><span class="dot dot-neu"></span> Neutral ${neuW}%</span> | |
| <span><span class="dot dot-neg"></span> Negative ${negW}%</span> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| // Sentence breakdown | |
| if (sentData.sentence_breakdown && sentData.sentence_breakdown.length > 0) { | |
| html += ` | |
| <div class="sentiment-sentences"> | |
| <h4>Sentence-Level Breakdown (top ${Math.min(sentData.sentence_breakdown.length, 20)})</h4> | |
| ${sentData.sentence_breakdown.slice(0, 20).map(s => { | |
| let cls = 'sent-neutral'; | |
| if (s.compound >= 0.05) cls = 'sent-positive'; | |
| else if (s.compound <= -0.05) cls = 'sent-negative'; | |
| return ` | |
| <div class="sentence-item"> | |
| <span class="sentence-sentiment-badge ${cls}">${s.label}</span> | |
| <span class="sentence-text">${escapeHtml(s.text)}</span> | |
| </div> | |
| `; | |
| }).join('')} | |
| </div> | |
| `; | |
| } | |
| overviewEl.innerHTML = html; | |
| } | |
| function displayMetadata(meta) { | |
| const metaEl = $('#metadataContent'); | |
| if (!meta) { | |
| metaEl.innerHTML = '<p class="placeholder">No metadata available.</p>'; | |
| return; | |
| } | |
| const rows = [ | |
| ['Title', meta.title], | |
| ['Author', meta.author], | |
| ['File Type', meta.file_type], | |
| ['Page Count', meta.page_count], | |
| ['Word Count', meta.word_count?.toLocaleString()], | |
| ['Character Count', meta.character_count?.toLocaleString()], | |
| ['Created', meta.creation_date], | |
| ['Modified', meta.modification_date], | |
| ]; | |
| // Add extra metadata | |
| if (meta.extra) { | |
| for (const [key, value] of Object.entries(meta.extra)) { | |
| if (value && value !== '' && value !== 0 && value !== false) { | |
| const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); | |
| rows.push([label, String(value)]); | |
| } | |
| } | |
| } | |
| metaEl.innerHTML = ` | |
| <table class="metadata-table"> | |
| ${rows.filter(([, v]) => v && v !== 'None' && v !== 'null' && v !== '') | |
| .map(([k, v]) => `<tr><td>${k}</td><td>${escapeHtml(String(v))}</td></tr>`) | |
| .join('')} | |
| </table> | |
| `; | |
| } | |
| // ===== Tabs ===== | |
| function initTabs() { | |
| $$('.tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| activateTab(tab.dataset.tab); | |
| }); | |
| }); | |
| } | |
| function activateTab(tabName) { | |
| $$('.tab').forEach(t => t.classList.remove('active')); | |
| $$('.tab-panel').forEach(p => p.classList.remove('active')); | |
| const tab = $(`.tab[data-tab="${tabName}"]`); | |
| const panel = $(`#panel${tabName.charAt(0).toUpperCase() + tabName.slice(1)}`); | |
| if (tab) tab.classList.add('active'); | |
| if (panel) panel.classList.add('active'); | |
| } | |
| // ===== Buttons ===== | |
| function initButtons() { | |
| // New upload | |
| $('#btnNewUpload').addEventListener('click', () => { | |
| resetAll(); | |
| showSection('upload'); | |
| }); | |
| // Back to upload (without full reset if possible, or just same as New) | |
| $('#btnBackToUpload').addEventListener('click', () => { | |
| // We reset anyway for now to avoid data conflicts, | |
| // but user specifically asked for "Back" | |
| resetAll(); | |
| showSection('upload'); | |
| }); | |
| // Cancel processing | |
| $('#btnCancelProcessing').addEventListener('click', () => { | |
| if (pollInterval) { | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| } | |
| showSection('upload'); | |
| showToast('Processing cancelled', 'info'); | |
| }); | |
| // Copy buttons | |
| $('#btnCopyText').addEventListener('click', () => { | |
| copyToClipboard($('#extractedText').textContent, '#btnCopyText'); | |
| }); | |
| $('#btnCopySummary').addEventListener('click', () => { | |
| copyToClipboard($('#summaryContent').textContent, '#btnCopySummary'); | |
| }); | |
| // Download button | |
| $('#btnDownloadText').addEventListener('click', () => { | |
| if (currentTaskId) { | |
| window.location.href = `/api/download/${currentTaskId}`; | |
| } else { | |
| showToast('No active document to download', 'error'); | |
| } | |
| }); | |
| } | |
| async function copyToClipboard(text, btnSelector) { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| const btn = $(btnSelector); | |
| btn.classList.add('copied'); | |
| const originalHTML = btn.innerHTML; | |
| btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8l3 3 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Copied!`; | |
| setTimeout(() => { | |
| btn.classList.remove('copied'); | |
| btn.innerHTML = originalHTML; | |
| }, 2000); | |
| } catch (e) { | |
| showToast('Failed to copy to clipboard', 'error'); | |
| } | |
| } | |
| // ===== UI Helpers ===== | |
| function showSection(sectionId) { | |
| [uploadSection, processingSection, resultsSection].forEach(s => s.classList.add('hidden')); | |
| if (sectionId === 'upload') { | |
| uploadSection.classList.remove('hidden'); | |
| } else if (sectionId === 'processing') { | |
| processingSection.classList.remove('hidden'); | |
| } else if (sectionId === 'results') { | |
| resultsSection.classList.remove('hidden'); | |
| } | |
| } | |
| function resetProcessingSteps() { | |
| ['stepExtract', 'stepSummary', 'stepEntities', 'stepSentiment'].forEach(id => { | |
| const el = $(`#${id}`); | |
| el.classList.remove('active', 'done'); | |
| el.querySelector('.step-status').textContent = '⏳'; | |
| }); | |
| } | |
| function updateStep(stepId, state) { | |
| const el = $(`#${stepId}`); | |
| el.classList.remove('active', 'done'); | |
| el.classList.add(state); | |
| el.querySelector('.step-status').textContent = state === 'done' ? '✅' : '⚡'; | |
| } | |
| function resetAll() { | |
| currentTaskId = null; | |
| if (pollInterval) { | |
| clearInterval(pollInterval); | |
| pollInterval = null; | |
| } | |
| fileInput.value = ''; | |
| $('#extractedText').innerHTML = '<p class="placeholder">No text extracted yet.</p>'; | |
| $('#summaryContent').innerHTML = '<p class="placeholder">No summary available.</p>'; | |
| $('#summaryStats').classList.add('hidden'); | |
| $('#keyHighlightsContainer').classList.add('hidden'); | |
| $('#highlightsList').innerHTML = ''; | |
| $('#entityCategories').innerHTML = '<p class="placeholder">No entities detected.</p>'; | |
| $('#entityList').innerHTML = ''; | |
| $('#sentimentOverview').innerHTML = '<p class="placeholder">No sentiment data available.</p>'; | |
| $('#metadataContent').innerHTML = '<p class="placeholder">No metadata available.</p>'; | |
| } | |
| function showToast(message, type = 'info') { | |
| const icons = { info: 'ℹ️', error: '❌', success: '✅' }; | |
| const toast = document.createElement('div'); | |
| toast.className = `toast toast-${type}`; | |
| toast.innerHTML = `<span class="toast-icon">${icons[type]}</span><span>${escapeHtml(message)}</span>`; | |
| toastContainer.appendChild(toast); | |
| setTimeout(() => { | |
| if (toast.parentNode) toast.remove(); | |
| }, 4000); | |
| } | |
| function escapeHtml(text) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |