/** * Popup UI Logic */ import './popup.css'; import { loadCredentials, saveCredentials, deleteCredentials, updateLastUse } from '../utils/storage'; import { uploadPDF, testConnection, BinectAPIError, Document } from '../utils/binect-api'; import { fetchPDFBytes, DetectedPDF } from '../utils/pdf-detector'; import { addTrackingEntry } from '../tracking/tracker'; import { PDFQueueEntry, PDFStatus, PDFStatusMeta } from '../utils/pdf-queue'; // DOM Elements const authView = document.getElementById('authView')!; const mainView = document.getElementById('mainView')!; const noPdfView = document.getElementById('noPdfView')!; const pdfListView = document.getElementById('pdfListView')!; const pdfList = document.getElementById('pdfList')!; const pdfCount = document.getElementById('pdfCount')!; const statusMessage = document.getElementById('statusMessage')!; const loginForm = document.getElementById('loginForm') as HTMLFormElement; const usernameInput = document.getElementById('username') as HTMLInputElement; const passwordInput = document.getElementById('password') as HTMLInputElement; const loginBtn = document.getElementById('loginBtn') as HTMLButtonElement; const authError = document.getElementById('authError')!; const logoutBtn = document.getElementById('logoutBtn')!; const helpBtn = document.getElementById('helpBtn')!; const togglePasswordBtn = document.getElementById('togglePassword') as HTMLButtonElement; const eyeIcon = document.getElementById('eyeIcon')!; const eyeOffIcon = document.getElementById('eyeOffIcon')!; // State let pdfQueue: PDFQueueEntry[] = []; let currentCredentials: { username: string; password: string } | null = null; /** * Initialize popup */ async function init() { // Check if user has credentials const credentials = await loadCredentials(); if (credentials) { // Try to test connection try { const isConnected = await testConnection(credentials.username, credentials.password); if (isConnected) { currentCredentials = credentials; await updateLastUse(); showMainView(); await loadPDFQueue(); } else { // Authentication failed, credentials may be invalid showAuthView(); } } catch (error) { // Connection test failed console.error('[Popup] Connection test failed:', error); showAuthView(); } } else { showAuthView(); } // Setup event listeners setupEventListeners(); } /** * Setup event listeners */ function setupEventListeners() { loginForm.addEventListener('submit', handleLogin); logoutBtn.addEventListener('click', handleLogout); helpBtn.addEventListener('click', handleHelp); togglePasswordBtn.addEventListener('click', handleTogglePassword); } /** * Handle password visibility toggle */ function handleTogglePassword() { const isPassword = passwordInput.type === 'password'; if (isPassword) { // Show password passwordInput.type = 'text'; eyeIcon.style.display = 'none'; eyeOffIcon.style.display = 'block'; togglePasswordBtn.setAttribute('aria-label', 'Hide password'); togglePasswordBtn.setAttribute('title', 'Hide password'); } else { // Hide password passwordInput.type = 'password'; eyeIcon.style.display = 'block'; eyeOffIcon.style.display = 'none'; togglePasswordBtn.setAttribute('aria-label', 'Show password'); togglePasswordBtn.setAttribute('title', 'Show password'); } } /** * Handle login */ async function handleLogin(e: Event) { e.preventDefault(); const username = usernameInput.value.trim(); const password = passwordInput.value; if (!username || !password) { showError('Please enter username and password'); return; } loginBtn.disabled = true; loginBtn.textContent = 'Signing in...'; hideError(); try { const isConnected = await testConnection(username, password); if (!isConnected) { showError('Invalid credentials. Please check your username and password.'); return; } // Save credentials currentCredentials = { username, password }; await saveCredentials({ username, password }); showMainView(); await loadPDFQueue(); } catch (error) { if (error instanceof BinectAPIError) { showError(error.message); } else { showError('Authentication failed. Please try again.'); } } finally { loginBtn.disabled = false; loginBtn.textContent = 'Sign In'; } } /** * Load PDF queue from background and current tab */ async function loadPDFQueue() { console.log('[Popup] Loading PDF queue...'); // Get all PDFs from background script (including completed ones) let response = await chrome.runtime.sendMessage({ action: 'getAllPDFs' }); pdfQueue = response?.entries || []; console.log('[Popup] Got', pdfQueue.length, 'entries from background'); // Check current tab for PDF and add to persistent queue via background const currentTabPDF = await checkCurrentTabForPDF(); if (currentTabPDF) { // Add via background service (will check for duplicates/dismissed) const addResult = await chrome.runtime.sendMessage({ action: 'addPDF', pdf: currentTabPDF }); if (addResult?.entry) { console.log('[Popup] Added current tab PDF to persistent queue:', currentTabPDF.filename); } } // Fallback: check recent downloads if queue is still empty if (pdfQueue.length === 0) { console.log('[Popup] Queue empty, checking recent downloads...'); const recentPDFs = await checkRecentDownloads(); for (const pdf of recentPDFs) { // Add each PDF via background service (will check for duplicates/dismissed) await chrome.runtime.sendMessage({ action: 'addPDF', pdf }); } } // Reload queue after potential additions response = await chrome.runtime.sendMessage({ action: 'getAllPDFs' }); pdfQueue = response?.entries || []; console.log('[Popup] Final queue count:', pdfQueue.length); // Render the list renderPDFList(); } /** * Check recent downloads for PDFs (fallback mechanism) */ async function checkRecentDownloads(): Promise { return new Promise((resolve) => { chrome.downloads.search( { limit: 20, orderBy: ['-startTime'] }, (items) => { console.log('[Popup] Checked recent downloads:', items.length, 'items'); const pdfs: DetectedPDF[] = []; for (const item of items) { if ( item.state === 'complete' && (item.filename.toLowerCase().endsWith('.pdf') || item.mime === 'application/pdf') ) { let domain = 'unknown'; try { const urlObj = new URL(item.url); domain = urlObj.hostname; } catch (e) { // Keep default } pdfs.push({ id: `download-${item.id}`, filename: item.filename.split('/').pop() || item.filename, url: item.url, size: item.fileSize, timestamp: new Date(item.startTime).getTime(), sourceDomain: domain }); } } console.log('[Popup] Found', pdfs.length, 'PDF downloads'); resolve(pdfs); } ); }); } /** * Render the PDF list with grouped sections */ function renderPDFList() { // Group PDFs by status category const pendingUpload = pdfQueue.filter(p => p.status === 'pending' || p.status === 'uploading' || p.status === 'failed'); const inBasket = pdfQueue.filter(p => p.status === 'in_basket' || p.status === 'ordering'); const inProduction = pdfQueue.filter(p => p.status === 'in_production'); const completed = pdfQueue.filter(p => p.status === 'sent' || p.status === 'canceled'); // Count actionable items const actionableCount = pendingUpload.length + inBasket.length; if (pdfQueue.length === 0) { showNoPDF(); return; } noPdfView.style.display = 'none'; pdfListView.style.display = 'block'; // Update count if (actionableCount > 0) { pdfCount.textContent = `${actionableCount} PDF${actionableCount > 1 ? 's' : ''} need attention`; } else if (inProduction.length > 0) { pdfCount.textContent = `${inProduction.length} in production`; } else { pdfCount.textContent = `${completed.length} completed`; } // Build HTML for each section let html = ''; if (pendingUpload.length > 0) { html += `
Ready to Upload
${pendingUpload.map(pdf => renderPDFItem(pdf, 'pending')).join('')}
`; } if (inBasket.length > 0) { html += `
In Basket
${inBasket.map(pdf => renderPDFItem(pdf, 'basket')).join('')}
`; } if (inProduction.length > 0) { html += `
In Production
${inProduction.map(pdf => renderPDFItem(pdf, 'production')).join('')}
`; } if (completed.length > 0) { // Show only last 5 completed items const recentCompleted = completed.slice(0, 5); html += `
Recently Completed
${recentCompleted.map(pdf => renderPDFItem(pdf, 'completed')).join('')}
`; } pdfList.innerHTML = html; // Add event listeners setupPDFListEventListeners(); hideStatus(); } /** * Render a single PDF item */ function renderPDFItem(pdf: PDFQueueEntry, section: 'pending' | 'basket' | 'production' | 'completed'): string { const statusClass = getStatusClass(pdf.status); const statusText = getStatusText(pdf); const priceText = pdf.price ? `${(pdf.price / 100).toFixed(2)} โ‚ฌ` : ''; let actionsHtml = ''; switch (section) { case 'pending': actionsHtml = ` `; break; case 'basket': actionsHtml = ` `; break; case 'production': actionsHtml = ` `; break; case 'completed': actionsHtml = ` `; break; } return `
${getStatusIcon(pdf.status)}
${escapeHtml(pdf.filename)}
${formatFileSize(pdf.size)} ยท ${escapeHtml(pdf.sourceDomain)} ${priceText ? ` ยท ${priceText}` : ''}
${pdf.recipientAddress ? `
${escapeHtml(pdf.recipientAddress.split('\n')[0])}
` : ''}
${statusText}
${actionsHtml}
`; } /** * Setup event listeners for PDF list items */ function setupPDFListEventListeners() { // Upload/Send buttons pdfList.querySelectorAll('.btn-send-item').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleSendPDF(id); }); }); // Order buttons pdfList.querySelectorAll('.btn-order-item').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleOrderPDF(id); }); }); // Refresh buttons pdfList.querySelectorAll('.btn-refresh-item').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleRefreshStatus(id); }); }); // Dismiss/Cancel/Remove buttons pdfList.querySelectorAll('.btn-dismiss').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleDismissPDF(id); }); }); } /** * Get status text for a PDF */ function getStatusText(pdf: PDFQueueEntry): string { switch (pdf.status) { case 'pending': return formatTimestamp(pdf.timestamp); case 'uploading': return 'Uploading...'; case 'failed': return pdf.errorMessage || 'Upload failed'; case 'in_basket': return pdf.binectStatusText || 'Ready to order'; case 'ordering': return 'Ordering...'; case 'in_production': return pdf.binectStatusText || 'In production'; case 'sent': return 'Sent successfully'; case 'canceled': return 'Canceled'; default: return formatTimestamp(pdf.timestamp); } } /** * Get CSS class for status */ function getStatusClass(status: PDFStatus): string { switch (status) { case 'pending': return 'pending'; case 'uploading': case 'ordering': return 'uploading'; case 'failed': return 'failed'; case 'in_basket': return 'in-basket'; case 'in_production': return 'in-production'; case 'sent': return 'sent'; case 'canceled': return 'canceled'; default: return ''; } } /** * Get icon for status */ function getStatusIcon(status: PDFStatus): string { switch (status) { case 'pending': return '๐Ÿ“„'; case 'uploading': return 'โณ'; case 'failed': return 'โŒ'; case 'in_basket': return '๐Ÿ›’'; case 'ordering': return 'โณ'; case 'in_production': return '๐Ÿญ'; case 'sent': return 'โœ…'; case 'canceled': return '๐Ÿšซ'; default: return '๐Ÿ“„'; } } /** * Handle send PDF (upload to Binect) */ async function handleSendPDF(id: string) { const pdf = pdfQueue.find(p => p.id === id); if (!pdf || !currentCredentials) { return; } // Update local state pdf.status = 'uploading'; renderPDFList(); // Notify background await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'uploading' }); try { // Fetch PDF bytes const pdfBytes = await fetchPDFBytes(pdf.url); // Upload to Binect with credentials const document = await uploadPDF( pdfBytes, pdf.filename, currentCredentials.username, currentCredentials.password ); // Track successful transfer await addTrackingEntry({ timestamp: Date.now(), sourceDomain: pdf.sourceDomain, destinationUrl: 'https://api.binect.de/binectapi/v1/documents', pdfSize: pdfBytes.byteLength, result: 'success' }); // Update last use timestamp await updateLastUse(); // Extract price and recipient from document response const meta: PDFStatusMeta = { binectDocumentId: document.id, binectStatus: document.status.code, binectStatusText: document.status.text }; if (document.letter?.letterData) { meta.price = document.letter.letterData.price?.priceAfterTax; meta.recipientAddress = document.letter.letterData.recipientAddress; } // Update status to in_basket (document is now SHIPPABLE) await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'in_basket', meta }); // Update local state pdf.status = 'in_basket'; pdf.binectDocumentId = document.id; pdf.binectStatus = document.status.code; pdf.binectStatusText = document.status.text; if (document.letter?.letterData) { pdf.price = document.letter.letterData.price?.priceAfterTax; pdf.recipientAddress = document.letter.letterData.recipientAddress; } renderPDFList(); showStatus(`Uploaded! Ready to order (${(pdf.price || 0) / 100} โ‚ฌ)`, 'success'); // Hide status after 3 seconds setTimeout(() => { hideStatus(); }, 3000); } catch (error) { let errorMessage = 'Upload failed'; if (error instanceof BinectAPIError) { errorMessage = error.message; // If auth error, might need to re-login if (error.statusCode === 401 || error.statusCode === 403) { errorMessage = 'Invalid credentials. Please sign in again.'; setTimeout(() => { handleLogout(); }, 2000); } } else if (error instanceof Error) { errorMessage = error.message; } // Track failed transfer await addTrackingEntry({ timestamp: Date.now(), sourceDomain: pdf.sourceDomain, destinationUrl: 'https://api.binect.de/binectapi/v1/documents', pdfSize: pdf.size || 0, result: 'failure', errorMessage }); // Update status to failed await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'failed', meta: { errorMessage } }); // Update local state pdf.status = 'failed'; pdf.errorMessage = errorMessage; renderPDFList(); showStatus(errorMessage, 'error'); } } /** * Handle order PDF (ship document to production) */ async function handleOrderPDF(id: string) { const pdf = pdfQueue.find(p => p.id === id); if (!pdf || !currentCredentials || !pdf.binectDocumentId) { return; } // Update local state pdf.status = 'ordering'; renderPDFList(); // Notify background await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'ordering' }); try { // Ship the document const result = await chrome.runtime.sendMessage({ action: 'shipDocument', documentId: pdf.binectDocumentId, username: currentCredentials.username, password: currentCredentials.password }); if (!result.success) { throw new Error(result.error || 'Failed to ship document'); } // Update status to in_production const meta: PDFStatusMeta = { binectStatus: result.status, binectStatusText: result.statusText }; await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'in_production', meta }); // Update local state pdf.status = 'in_production'; pdf.binectStatus = result.status; pdf.binectStatusText = result.statusText; renderPDFList(); showStatus('Order placed! Document is in production.', 'success'); // Hide status after 3 seconds setTimeout(() => { hideStatus(); }, 3000); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Order failed'; // Revert to in_basket status await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'in_basket', meta: { errorMessage } }); // Update local state pdf.status = 'in_basket'; pdf.errorMessage = errorMessage; renderPDFList(); showStatus(errorMessage, 'error'); } } /** * Handle refresh document status */ async function handleRefreshStatus(id: string) { const pdf = pdfQueue.find(p => p.id === id); if (!pdf || !currentCredentials || !pdf.binectDocumentId) { return; } try { // Get current status from Binect const result = await chrome.runtime.sendMessage({ action: 'getDocumentStatus', documentId: pdf.binectDocumentId, username: currentCredentials.username, password: currentCredentials.password }); if (!result.success) { throw new Error(result.error || 'Failed to get status'); } // Determine new local status based on Binect status code let newStatus: PDFStatus = pdf.status; if (result.status === 5) { newStatus = 'sent'; } else if (result.status === 6) { newStatus = 'canceled'; } else if (result.status === 3 || result.status === 4) { newStatus = 'in_production'; } else if (result.status === 2) { newStatus = 'in_basket'; } const meta: PDFStatusMeta = { binectStatus: result.status, binectStatusText: result.statusText, price: result.price, recipientAddress: result.recipientAddress }; // Update in background await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: newStatus, meta }); // Update local state pdf.status = newStatus; pdf.binectStatus = result.status; pdf.binectStatusText = result.statusText; if (result.price) pdf.price = result.price; if (result.recipientAddress) pdf.recipientAddress = result.recipientAddress; renderPDFList(); showStatus(`Status: ${result.statusText}`, 'success'); setTimeout(() => { hideStatus(); }, 3000); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to refresh'; showStatus(errorMessage, 'error'); } } /** * Handle dismiss PDF */ async function handleDismissPDF(id: string) { const pdf = pdfQueue.find(p => p.id === id); if (!pdf) return; // For pending/failed/in_basket items, mark as dismissed to prevent re-showing // For completed items (sent/canceled), actually remove from storage if (pdf.status === 'sent' || pdf.status === 'canceled') { await chrome.runtime.sendMessage({ action: 'removePDF', id }); } else { await chrome.runtime.sendMessage({ action: 'dismissPDF', id }); } pdfQueue = pdfQueue.filter(p => p.id !== id); renderPDFList(); } /** * Handle logout */ async function handleLogout() { await deleteCredentials(); currentCredentials = null; pdfQueue = []; // Clear form loginForm.reset(); showAuthView(); } /** * Handle help button */ function handleHelp() { // Open tracking page chrome.tabs.create({ url: chrome.runtime.getURL('tracking.html') }); } /** * Check if the current active tab is viewing a PDF */ async function checkCurrentTabForPDF(): Promise { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab || !tab.url) { return null; } // Check if the URL is a PDF const url = tab.url; const isPDF = url.toLowerCase().endsWith('.pdf') || url.includes('type=application/pdf') || url.includes('mime=application/pdf'); if (isPDF) { // Extract filename from URL let filename = 'document.pdf'; try { const urlObj = new URL(url); const pathname = urlObj.pathname; const pathParts = pathname.split('/'); const lastPart = pathParts[pathParts.length - 1]; if (lastPart && lastPart.toLowerCase().endsWith('.pdf')) { filename = decodeURIComponent(lastPart); } else if (tab.title && tab.title !== 'about:blank' && !tab.title.startsWith('chrome://')) { // Use tab title if available filename = tab.title.endsWith('.pdf') ? tab.title : `${tab.title}.pdf`; } } catch (e) { // Use tab title as fallback if (tab.title && tab.title !== 'about:blank') { filename = tab.title.endsWith('.pdf') ? tab.title : `${tab.title}.pdf`; } } // Extract domain let domain = 'unknown'; try { const urlObj = new URL(url); domain = urlObj.hostname; } catch (e) { // Keep default } return { id: `tab-${tab.id}`, filename, url, size: 0, // Unknown size for viewed PDFs timestamp: Date.now(), sourceDomain: domain }; } return null; } catch (error) { console.error('Error checking current tab for PDF:', error); return null; } } /** * Show auth view */ function showAuthView() { authView.style.display = 'block'; mainView.style.display = 'none'; } /** * Show main view */ function showMainView() { authView.style.display = 'none'; mainView.style.display = 'block'; } /** * Show no PDF view */ function showNoPDF() { noPdfView.style.display = 'block'; pdfListView.style.display = 'none'; hideStatus(); } /** * Show error message */ function showError(message: string) { authError.textContent = message; authError.style.display = 'block'; } /** * Hide error message */ function hideError() { authError.style.display = 'none'; } /** * Show status message */ function showStatus(message: string, type: 'uploading' | 'success' | 'error') { statusMessage.textContent = message; statusMessage.className = `status-message ${type}`; statusMessage.style.display = 'block'; } /** * Hide status message */ function hideStatus() { statusMessage.style.display = 'none'; } /** * Escape HTML to prevent XSS */ function escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Format file size */ function formatFileSize(bytes: number): string { if (bytes === 0) { return 'Size unknown'; } else if (bytes < 1024) { return `${bytes} B`; } else if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } else { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } } /** * Format timestamp */ function formatTimestamp(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; if (diff < 60 * 1000) { return 'Just now'; } else if (diff < 60 * 60 * 1000) { const minutes = Math.floor(diff / (60 * 1000)); return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; } else if (diff < 24 * 60 * 60 * 1000) { const hours = Math.floor(diff / (60 * 60 * 1000)); return `${hours} hour${hours > 1 ? 's' : ''} ago`; } else { return new Date(timestamp).toLocaleDateString(); } } // Initialize on load init();