/** * 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 { DocumentProxy, PDFQueueEntry, PDFStatus, PDFStatusMeta } from '../utils/pdf-queue'; import { computeMD5 } from '../utils/hash'; // 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 archiveToggleBtn = document.getElementById('archiveToggleBtn')!; const archiveIcon = document.getElementById('archiveIcon')!; const liveIcon = document.getElementById('liveIcon')!; const helpBtn = document.getElementById('helpBtn')!; const togglePasswordBtn = document.getElementById('togglePassword') as HTMLButtonElement; const eyeIcon = document.getElementById('eyeIcon')!; const eyeOffIcon = document.getElementById('eyeOffIcon')!; // Issue Report Modal Elements const issueModal = document.getElementById('issueModal')!; const reportIssueBtn = document.getElementById('reportIssueBtn')!; const closeModalBtn = document.getElementById('closeModalBtn')!; const modalBackdrop = issueModal.querySelector('.modal-backdrop')!; const issueTitleInput = document.getElementById('issueTitle') as HTMLInputElement; const issueDescriptionInput = document.getElementById('issueDescription') as HTMLTextAreaElement; const copyToClipboardBtn = document.getElementById('copyToClipboardBtn') as HTMLButtonElement; // Context section content elements const extensionInfoContent = document.getElementById('extensionInfoContent')!; const browserInfoContent = document.getElementById('browserInfoContent')!; const documentStatusContent = document.getElementById('documentStatusContent')!; const recentErrorsContent = document.getElementById('recentErrorsContent')!; // Error tracking for issue reports const recentErrors: Array<{ timestamp: number; message: string; stack?: string }> = []; // Pin Reminder Elements const pinReminder = document.getElementById('pinReminder')!; const dismissPinReminderBtn = document.getElementById('dismissPinReminder')!; // State let pdfQueue: DocumentProxy[] = []; let currentCredentials: { username: string; password: string } | null = null; let showingArchive = false; // false = live view, true = archive view // Auto-refresh state const REFRESH_INTERVALS = [10, 20, 30, 50, 80, 130, 210]; // seconds let refreshTimeouts: ReturnType[] = []; let refreshIndex = 0; /** * Initialize popup */ async function init() { // Check and show first-run pin reminder await checkFirstRunReminder(); // 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(); } /** * Check if this is first run and show pin reminder */ async function checkFirstRunReminder() { const STORAGE_KEY = 'pinReminderDismissed'; try { const result = await chrome.storage.local.get(STORAGE_KEY); if (!result[STORAGE_KEY]) { // First run - show the reminder pinReminder.style.display = 'block'; } } catch (error) { console.error('[Popup] Error checking first run:', error); } } /** * Dismiss the pin reminder and remember the choice */ async function dismissPinReminder() { const STORAGE_KEY = 'pinReminderDismissed'; pinReminder.style.display = 'none'; try { await chrome.storage.local.set({ [STORAGE_KEY]: true }); } catch (error) { console.error('[Popup] Error saving pin reminder state:', error); } } /** * Setup event listeners */ function setupEventListeners() { loginForm.addEventListener('submit', handleLogin); logoutBtn.addEventListener('click', handleLogout); archiveToggleBtn.addEventListener('click', handleToggleArchiveView); helpBtn.addEventListener('click', handleHelp); // Password toggle - ensure button exists and attach handler if (togglePasswordBtn) { togglePasswordBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); handleTogglePassword(); }); } // Pin reminder dismiss button if (dismissPinReminderBtn) { dismissPinReminderBtn.addEventListener('click', dismissPinReminder); } } /** * Handle toggle between live and archive view */ async function handleToggleArchiveView() { showingArchive = !showingArchive; // Update toggle button appearance if (showingArchive) { archiveIcon.style.display = 'none'; liveIcon.style.display = 'block'; archiveToggleBtn.title = 'Show live documents'; archiveToggleBtn.classList.add('active'); } else { archiveIcon.style.display = 'block'; liveIcon.style.display = 'none'; archiveToggleBtn.title = 'Show archived'; archiveToggleBtn.classList.remove('active'); } // Reload the appropriate list await loadPDFQueue(); } /** * Start auto-refresh sequence after user interaction */ function startAutoRefresh() { // Clear any existing refresh timeouts cancelAutoRefresh(); refreshIndex = 0; scheduleNextRefresh(); } /** * Schedule the next refresh in the sequence */ function scheduleNextRefresh() { if (refreshIndex >= REFRESH_INTERVALS.length) { return; // Sequence complete } const delay = REFRESH_INTERVALS[refreshIndex] * 1000; const timeout = setTimeout(async () => { console.log(`[Popup] Auto-refresh #${refreshIndex + 1} after ${REFRESH_INTERVALS[refreshIndex]}s`); await handleRefreshAll(); refreshIndex++; scheduleNextRefresh(); }, delay); refreshTimeouts.push(timeout); } /** * Cancel all pending auto-refresh timeouts */ function cancelAutoRefresh() { for (const timeout of refreshTimeouts) { clearTimeout(timeout); } refreshTimeouts = []; } /** * Extract just the filename from a full path */ function extractFilename(fullPath: string): string { // Handle both forward and back slashes const parts = fullPath.split(/[/\\]/); return parts[parts.length - 1] || fullPath; } /** * 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, showingArchive:', showingArchive); if (showingArchive) { // Get archived proxies only const response = await chrome.runtime.sendMessage({ action: 'getArchivedProxies' }); pdfQueue = response?.entries || []; console.log('[Popup] Got', pdfQueue.length, 'archived entries'); } else { // Get live proxies let response = await chrome.runtime.sendMessage({ action: 'getLiveProxies' }); pdfQueue = response?.entries || []; console.log('[Popup] Got', pdfQueue.length, 'live entries from background'); // Sync with server to discover documents uploaded elsewhere or missing locally if (currentCredentials) { await syncWithServer(); } // Auto-restore any archived documents that are in active states (in_basket, in_production) // These should never be archived as it confuses user expectations const archivedResponse = await chrome.runtime.sendMessage({ action: 'getArchivedProxies' }); const archivedEntries: DocumentProxy[] = archivedResponse?.entries || []; for (const entry of archivedEntries) { if (entry.binectStatus === 'in_basket' || entry.binectStatus === 'in_production') { console.log('[Popup] Auto-restoring active document from archive:', entry.filename); await chrome.runtime.sendMessage({ action: 'restoreProxy', id: entry.id }); } } // Reload live proxies after potential restorations and server sync response = await chrome.runtime.sendMessage({ action: 'getLiveProxies' }); pdfQueue = response?.entries || []; console.log('[Popup] After auto-restore and sync, got', pdfQueue.length, 'live entries'); // 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) 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) await chrome.runtime.sendMessage({ action: 'addPDF', pdf }); } } // Reload live queue after potential additions response = await chrome.runtime.sendMessage({ action: 'getLiveProxies' }); pdfQueue = response?.entries || []; console.log('[Popup] Final live queue count:', pdfQueue.length); } // Render the list renderPDFList(); } /** * Sync local proxies with server documents * Creates local proxies for documents that exist on server but not locally */ async function syncWithServer() { if (!currentCredentials) { return; } try { // Get list of documents from server const result = await chrome.runtime.sendMessage({ action: 'listServerDocuments', username: currentCredentials.username, password: currentCredentials.password }); if (!result.success || !result.documents) { console.warn('[Popup] Failed to list server documents:', result.error); return; } const serverDocs = result.documents as Array<{ id: number; filename: string; status: number; statusText: string; price?: number; recipientAddress?: string; errorDetails?: string; }>; console.log('[Popup] Syncing', serverDocs.length, 'server documents'); // Sync each server document to local proxy for (const doc of serverDocs) { await chrome.runtime.sendMessage({ action: 'syncFromServer', binectDocumentId: doc.id, filename: doc.filename, binectStatusCode: doc.status, binectStatusText: doc.statusText, price: doc.price, recipientAddress: doc.recipientAddress, errorDetails: doc.errorDetails }); } } catch (error) { console.error('[Popup] Server sync error:', error); } } /** * 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() { if (pdfQueue.length === 0) { showNoPDF(); return; } noPdfView.style.display = 'none'; pdfListView.style.display = 'block'; let html = ''; if (showingArchive) { // Archive view - show all archived documents in a flat list pdfCount.textContent = `${pdfQueue.length} archived document${pdfQueue.length !== 1 ? 's' : ''}`; html = `
Archived Documents
${pdfQueue.map(pdf => renderPDFItem(pdf, 'archived')).join('')}
`; } else { // Live view - group by Binect status // Separate truly local pending uploads from server documents with errors const pendingUpload = pdfQueue.filter(p => (p.binectStatus === 'pending' || p.binectStatus === 'uploading') || (p.binectStatus === 'failed' && !p.binectDocumentId) // Local failed uploads ); const erroneous = pdfQueue.filter(p => p.binectStatus === 'failed' && p.binectDocumentId // Server documents with errors ); const inBasket = pdfQueue.filter(p => p.binectStatus === 'in_basket' || p.binectStatus === 'ordering'); const inProduction = pdfQueue.filter(p => p.binectStatus === 'in_production'); const completed = pdfQueue.filter(p => p.binectStatus === 'sent' || p.binectStatus === 'canceled'); // Count actionable items const actionableCount = pendingUpload.length + erroneous.length + inBasket.length; // 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 if (completed.length > 0) { pdfCount.textContent = `${completed.length} completed`; } else { pdfCount.textContent = 'No documents'; } if (pendingUpload.length > 0) { html += `
Ready to Upload
${pendingUpload.map(pdf => renderPDFItem(pdf, 'pending')).join('')}
`; } if (erroneous.length > 0) { html += `
Has Errors
${erroneous.map(pdf => renderPDFItem(pdf, 'erroneous')).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 in live view 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: DocumentProxy, section: 'pending' | 'erroneous' | 'basket' | 'production' | 'completed' | 'archived'): string { const statusClass = getStatusClass(pdf.binectStatus); const statusText = getStatusText(pdf); const priceText = pdf.price ? `${(pdf.price / 100).toFixed(2)} โ‚ฌ` : ''; const displayFilename = extractFilename(pdf.filename); const isLocalOnly = !pdf.binectDocumentId; let actionsHtml = ''; // Check if document can be deleted from server (erroneous or canceled) const canDeleteFromServer = pdf.binectDocumentId && (pdf.binectStatusCode === 7 || pdf.binectStatusCode === 6); switch (section) { case 'pending': actionsHtml = ` `; break; case 'erroneous': // Server documents with errors - can only delete from server actionsHtml = ` `; break; case 'basket': // No archive button for in_basket - these are active documents actionsHtml = ` `; break; case 'production': // No archive button for in_production - these are active documents actionsHtml = ''; break; case 'completed': // For sent/canceled documents - offer delete from server if applicable if (canDeleteFromServer) { actionsHtml = ` `; } else { actionsHtml = ` `; } break; case 'archived': actionsHtml = ` `; break; } // Show detailed error info for erroneous documents const hasErrorDetails = pdf.binectStatus === 'failed' && pdf.errorMessage && pdf.binectStatusCode === 7; // Build metadata parts - only include what we have const metaParts: string[] = []; if (pdf.size > 0) { metaParts.push(formatFileSize(pdf.size)); } if (pdf.sourceDomain && pdf.sourceDomain !== 'binect.de') { metaParts.push(escapeHtml(pdf.sourceDomain)); } else if (pdf.binectDocumentId && pdf.sourceDomain === 'binect.de') { metaParts.push(`ID: ${pdf.binectDocumentId}`); } if (priceText) { metaParts.push(`${priceText}`); } return `
${getStatusIcon(pdf.binectStatus)}
${escapeHtml(displayFilename)} ${isLocalOnly ? `local` : ''}
${metaParts.length > 0 ? `
${metaParts.join(' ยท ')}
` : ''} ${pdf.recipientAddress ? `
${escapeHtml(pdf.recipientAddress.split('\n')[0])}
` : ''}
${statusText}
${hasErrorDetails ? `
${escapeHtml(pdf.errorMessage!)}
` : ''}
${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); }); }); // Archive buttons pdfList.querySelectorAll('.btn-archive').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleArchivePDF(id); }); }); // Restore buttons pdfList.querySelectorAll('.btn-restore').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleRestorePDF(id); }); }); // Delete from server buttons pdfList.querySelectorAll('.btn-delete-server').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleDeleteFromServer(id); }); }); // Delete/Remove buttons pdfList.querySelectorAll('.btn-dismiss').forEach(btn => { btn.addEventListener('click', (e) => { const id = (e.target as HTMLElement).dataset.id; if (id) handleDeletePDF(id); }); }); // Local tag clicks - check server for document pdfList.querySelectorAll('.tag-local').forEach(tag => { tag.addEventListener('click', (e) => { e.stopPropagation(); // Don't bubble to parent const id = (e.target as HTMLElement).dataset.id; if (id) handleCheckServer(id); }); }); } /** * Get status text for a PDF */ function getStatusText(pdf: DocumentProxy): string { switch (pdf.binectStatus) { 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 'Sending...'; 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.binectStatus = 'uploading'; renderPDFList(); // Notify background await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'uploading' }); try { // Fetch PDF bytes const pdfBytes = await fetchPDFBytes(pdf.url); // Compute content hash for deduplication const contentHash = await computeMD5(pdfBytes); // Upload to Binect with credentials const document = await uploadPDF( pdfBytes, pdf.filename, currentCredentials.username, currentCredentials.password ); // Track transfer await addTrackingEntry({ timestamp: Date.now(), sourceDomain: pdf.sourceDomain, destinationUrl: 'https://api.binect.de/binectapi/v1/documents', pdfSize: pdfBytes.byteLength, result: document.status.code === 7 ? 'failure' : 'success', errorMessage: document.status.code === 7 ? document.status.text : undefined }); // 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, contentHash }; if (document.letter?.letterData) { meta.price = document.letter.letterData.price?.priceAfterTax; meta.recipientAddress = document.letter.letterData.recipientAddress; } // Check if document is erroneous (status 7) if (document.status.code === 7) { // Extract error message let errorMessage = document.status.text || 'Document has errors'; if (document.letter?.errors && document.letter.errors.length > 0) { errorMessage = document.letter.errors.map(e => e.text || e.blankText).join('; '); } meta.errorMessage = errorMessage; // Update status to failed (erroneous) await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'failed', meta }); // Update local state pdf.binectStatus = 'failed'; pdf.binectDocumentId = document.id; pdf.binectStatusCode = document.status.code; pdf.binectStatusText = document.status.text; pdf.contentHash = contentHash; pdf.errorMessage = errorMessage; renderPDFList(); showStatus(`Document has errors: ${errorMessage}`, 'error'); } else { // Document is shippable (status 2) - in basket // Update status to in_basket await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'in_basket', meta }); // Update local state pdf.binectStatus = 'in_basket'; pdf.binectDocumentId = document.id; pdf.binectStatusCode = document.status.code; pdf.binectStatusText = document.status.text; pdf.contentHash = contentHash; if (document.letter?.letterData) { pdf.price = document.letter.letterData.price?.priceAfterTax; pdf.recipientAddress = document.letter.letterData.recipientAddress; } renderPDFList(); showStatus(`Uploaded! Ready to send (${(pdf.price || 0) / 100} โ‚ฌ)`, 'success'); } // Start auto-refresh sequence startAutoRefresh(); // 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.binectStatus = '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.binectStatus = '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.binectStatus = 'in_production'; pdf.binectStatusCode = result.status; pdf.binectStatusText = result.statusText; renderPDFList(); showStatus('Sent! Document is in production.', 'success'); // Start auto-refresh sequence startAutoRefresh(); // Hide status after 3 seconds setTimeout(() => { hideStatus(); }, 3000); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Send failed'; // Revert to in_basket status await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: 'in_basket', meta: { errorMessage } }); // Update local state pdf.binectStatus = 'in_basket'; pdf.errorMessage = errorMessage; renderPDFList(); showStatus(errorMessage, 'error'); } } /** * Handle refresh all documents that have been uploaded to server */ async function handleRefreshAll() { if (!currentCredentials) { return; } // Find all documents that have been uploaded and might change status // Include erroneous docs - they can be fixed on the server and become shippable const uploadedDocs = pdfQueue.filter(p => p.binectDocumentId && (p.binectStatus === 'in_basket' || p.binectStatus === 'in_production' || (p.binectStatus === 'failed' && p.binectStatusCode === 7)) // Erroneous on server ); if (uploadedDocs.length === 0) { return; } console.log(`[Popup] Refreshing ${uploadedDocs.length} document(s)...`); let successCount = 0; let errorCount = 0; for (const pdf of uploadedDocs) { try { await handleRefreshStatus(pdf.id); successCount++; } catch { errorCount++; } } if (errorCount > 0) { console.log(`[Popup] Refresh complete: ${successCount} ok, ${errorCount} failed`); } } /** * 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) { // Check if document was deleted on server (404) if (result.errorCode === 404) { console.log('[Popup] Document deleted on server, archiving:', id); await handleArchivePDF(id); showStatus('Document no longer exists on server', 'error'); return; } throw new Error(result.error || 'Failed to get status'); } // Determine new local status based on Binect status code let newStatus: PDFStatus = pdf.binectStatus; if (result.status === 5) { newStatus = 'sent'; } else if (result.status === 6) { newStatus = 'canceled'; } else if (result.status === 7) { newStatus = 'failed'; // Erroneous } 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, errorMessage: result.errorDetails }; // Update in background await chrome.runtime.sendMessage({ action: 'updatePDFStatus', id, status: newStatus, meta }); // Update local state pdf.binectStatus = newStatus; pdf.binectStatusCode = result.status; pdf.binectStatusText = result.statusText; if (result.price !== undefined) pdf.price = result.price; if (result.recipientAddress) pdf.recipientAddress = result.recipientAddress; // Clear or set error message based on status pdf.errorMessage = result.errorDetails || undefined; renderPDFList(); } catch (error) { console.error('[Popup] Refresh error:', error); // Only show errors that aren't already handled (like 404) } } /** * Handle archive PDF (move from live to archive) */ async function handleArchivePDF(id: string) { await chrome.runtime.sendMessage({ action: 'archiveProxy', id }); pdfQueue = pdfQueue.filter(p => p.id !== id); renderPDFList(); showStatus('Document archived', 'success'); setTimeout(() => hideStatus(), 2000); } /** * Handle restore PDF (move from archive to live) */ async function handleRestorePDF(id: string) { await chrome.runtime.sendMessage({ action: 'restoreProxy', id }); pdfQueue = pdfQueue.filter(p => p.id !== id); renderPDFList(); showStatus('Document restored', 'success'); setTimeout(() => hideStatus(), 2000); } /** * Handle check server for a local document * Checks if document exists on server (by ID if known, or by filename) */ async function handleCheckServer(id: string) { const pdf = pdfQueue.find(p => p.id === id); if (!pdf || !currentCredentials) { return; } // Find the tag element to show visual feedback const tagElement = pdfList.querySelector(`.tag-local[data-id="${id}"]`) as HTMLElement; if (tagElement) { tagElement.classList.add('checking'); tagElement.textContent = '...'; } try { // First, try to find by known document ID if available if (pdf.binectDocumentId) { try { const result = await chrome.runtime.sendMessage({ action: 'getDocumentStatus', documentId: pdf.binectDocumentId, username: currentCredentials.username, password: currentCredentials.password }); if (result.success) { // Document still exists on server, re-attach it await chrome.runtime.sendMessage({ action: 'attachServerDocument', id, binectDocumentId: pdf.binectDocumentId, binectStatusCode: result.status, binectStatusText: result.statusText, price: result.price, recipientAddress: result.recipientAddress, errorMessage: result.errorDetails }); // Update local state pdf.binectStatusCode = result.status; pdf.binectStatusText = result.statusText; if (result.price !== undefined) pdf.price = result.price; if (result.recipientAddress) pdf.recipientAddress = result.recipientAddress; renderPDFList(); showStatus('Document found on server and re-linked', 'success'); setTimeout(() => hideStatus(), 2000); return; } // If 404, continue to filename search } catch { // Document not found by ID, try filename search } } // Try to find by filename in server documents const listResult = await chrome.runtime.sendMessage({ action: 'listServerDocuments', username: currentCredentials.username, password: currentCredentials.password }); if (!listResult.success || !listResult.documents) { throw new Error('Failed to list server documents'); } // Extract just the filename for comparison (without path) const targetFilename = extractFilename(pdf.filename).toLowerCase(); // Find matching document by filename const match = (listResult.documents as Array<{ id: number; filename: string; status: number; statusText: string; price?: number; recipientAddress?: string; errorDetails?: string; }>).find(doc => { const docFilename = extractFilename(doc.filename).toLowerCase(); return docFilename === targetFilename; }); if (match) { // Found a matching document, attach it await chrome.runtime.sendMessage({ action: 'attachServerDocument', id, binectDocumentId: match.id, binectStatusCode: match.status, binectStatusText: match.statusText, price: match.price, recipientAddress: match.recipientAddress, errorMessage: match.errorDetails }); // Update local state pdf.binectDocumentId = match.id; pdf.binectStatusCode = match.status; pdf.binectStatusText = match.statusText; if (match.price !== undefined) pdf.price = match.price; if (match.recipientAddress) pdf.recipientAddress = match.recipientAddress; if (match.errorDetails) pdf.errorMessage = match.errorDetails; renderPDFList(); showStatus(`Found on server (ID: ${match.id})`, 'success'); setTimeout(() => hideStatus(), 2000); } else { // No matching document found if (tagElement) { tagElement.classList.remove('checking'); tagElement.textContent = 'local'; } showStatus('Not found on server', 'error'); setTimeout(() => hideStatus(), 2000); } } catch (error) { console.error('[Popup] Check server error:', error); if (tagElement) { tagElement.classList.remove('checking'); tagElement.textContent = 'local'; } const errorMessage = error instanceof Error ? error.message : 'Check failed'; showStatus(errorMessage, 'error'); setTimeout(() => hideStatus(), 2000); } } /** * Handle delete from server (for erroneous or canceled documents) * After deleting, the proxy is archived and becomes local-only */ async function handleDeleteFromServer(id: string) { const pdf = pdfQueue.find(p => p.id === id); if (!pdf || !currentCredentials || !pdf.binectDocumentId) { return; } try { const result = await chrome.runtime.sendMessage({ action: 'deleteServerDocument', documentId: pdf.binectDocumentId, username: currentCredentials.username, password: currentCredentials.password }); if (!result.success) { throw new Error(result.error || 'Failed to delete from server'); } // Clear server fields to make it local-only await chrome.runtime.sendMessage({ action: 'clearServerFields', id }); // Archive the proxy (it's now local-only) await chrome.runtime.sendMessage({ action: 'archiveProxy', id }); // Update local state pdf.binectDocumentId = undefined; pdf.binectStatusCode = undefined; pdf.binectStatusText = undefined; pdf.binectStatus = 'pending'; pdf.price = undefined; pdf.recipientAddress = undefined; pdf.errorMessage = undefined; // Remove from current view (it's now archived) pdfQueue = pdfQueue.filter(p => p.id !== id); renderPDFList(); showStatus('Document deleted from server and archived', 'success'); setTimeout(() => hideStatus(), 2000); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Delete failed'; showStatus(errorMessage, 'error'); setTimeout(() => hideStatus(), 3000); } } /** * Handle delete PDF (permanently remove) */ async function handleDeletePDF(id: string) { await chrome.runtime.sendMessage({ action: 'removePDF', 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(); } } // ============================================================================= // Issue Report Modal // ============================================================================= /** * Track errors for issue reports */ function trackError(error: Error | string, stack?: string) { const errorEntry = { timestamp: Date.now(), message: typeof error === 'string' ? error : error.message, stack: stack || (error instanceof Error ? error.stack : undefined) }; recentErrors.unshift(errorEntry); // Keep only last 10 errors if (recentErrors.length > 10) { recentErrors.pop(); } } /** * Get extension info for issue report */ function getExtensionInfo(): string { const manifest = chrome.runtime.getManifest(); return JSON.stringify({ name: manifest.name, version: manifest.version, manifestVersion: manifest.manifest_version }, null, 2); } /** * Get browser info for issue report */ function getBrowserInfo(): string { const ua = navigator.userAgent; let browserName = 'Unknown'; let browserVersion = 'Unknown'; // Parse user agent for browser info if (ua.includes('Chrome/')) { browserName = 'Chrome'; const match = ua.match(/Chrome\/(\d+\.\d+\.\d+\.\d+)/); if (match) browserVersion = match[1]; } else if (ua.includes('Firefox/')) { browserName = 'Firefox'; const match = ua.match(/Firefox\/(\d+\.\d+)/); if (match) browserVersion = match[1]; } else if (ua.includes('Edge/')) { browserName = 'Edge'; const match = ua.match(/Edge\/(\d+\.\d+)/); if (match) browserVersion = match[1]; } return JSON.stringify({ browser: browserName, version: browserVersion, platform: navigator.platform, language: navigator.language, userAgent: ua }, null, 2); } /** * Get document status for issue report */ function getDocumentStatusInfo(): string { if (pdfQueue.length === 0) { return 'No documents in queue'; } const summary = { total: pdfQueue.length, byStatus: {} as Record, documents: pdfQueue.map(doc => ({ id: doc.id.substring(0, 8) + '...', binectStatus: doc.binectStatus, binectStatusCode: doc.binectStatusCode, hasError: !!doc.errorMessage })) }; // Count by status for (const doc of pdfQueue) { summary.byStatus[doc.binectStatus] = (summary.byStatus[doc.binectStatus] || 0) + 1; } return JSON.stringify(summary, null, 2); } /** * Get recent errors for issue report */ function getRecentErrorsInfo(): string { if (recentErrors.length === 0) { return 'No recent errors'; } return recentErrors.map(err => { const date = new Date(err.timestamp).toISOString(); let text = `[${date}] ${err.message}`; if (err.stack) { text += `\n Stack: ${err.stack.split('\n').slice(0, 3).join('\n ')}`; } return text; }).join('\n\n'); } /** * Open the issue report modal */ function openIssueModal() { // Clear previous input issueTitleInput.value = ''; issueDescriptionInput.value = ''; // Reset checkboxes issueModal.querySelectorAll('input[type="checkbox"]').forEach(cb => { (cb as HTMLInputElement).checked = false; }); // Populate context sections extensionInfoContent.textContent = getExtensionInfo(); browserInfoContent.textContent = getBrowserInfo(); documentStatusContent.textContent = getDocumentStatusInfo(); recentErrorsContent.textContent = getRecentErrorsInfo(); // Close all sections by default issueModal.querySelectorAll('.context-section-content').forEach(section => { (section as HTMLElement).style.display = 'none'; }); issueModal.querySelectorAll('.toggle-icon').forEach(icon => { icon.textContent = 'โ–ถ'; }); // Show modal issueModal.style.display = 'flex'; } /** * Close the issue report modal */ function closeIssueModal() { issueModal.style.display = 'none'; } /** * Toggle a context section */ function toggleContextSection(sectionId: string) { const content = document.getElementById(sectionId); const button = issueModal.querySelector(`[data-section="${sectionId}"]`); const icon = button?.querySelector('.toggle-icon'); if (content && icon) { const isVisible = content.style.display !== 'none'; content.style.display = isVisible ? 'none' : 'block'; icon.textContent = isVisible ? 'โ–ถ' : 'โ–ผ'; } } /** * Format issue report as Markdown and copy to clipboard */ async function copyIssueToClipboard() { const title = issueTitleInput.value.trim(); const description = issueDescriptionInput.value.trim(); // Build markdown content let markdown = ''; if (title) { markdown += `# ${title}\n\n`; } if (description) { markdown += `## Description\n\n${description}\n\n`; } // Add context sections (only those not excluded) const sections = [ { id: 'extensionInfo', name: 'Extension Info', content: extensionInfoContent.textContent }, { id: 'browserInfo', name: 'Browser Info', content: browserInfoContent.textContent }, { id: 'documentStatus', name: 'Document Status', content: documentStatusContent.textContent }, { id: 'recentErrors', name: 'Recent Errors', content: recentErrorsContent.textContent } ]; let hasContext = false; for (const section of sections) { const checkbox = issueModal.querySelector(`input[data-exclude="${section.id}"]`) as HTMLInputElement; if (!checkbox?.checked && section.content && section.content !== 'No recent errors' && section.content !== 'No documents in queue') { if (!hasContext) { markdown += `## Context\n\n`; hasContext = true; } markdown += `### ${section.name}\n\n\`\`\`json\n${section.content}\n\`\`\`\n\n`; } } // Copy to clipboard try { await navigator.clipboard.writeText(markdown); // Show success feedback const originalText = copyToClipboardBtn.textContent; copyToClipboardBtn.textContent = 'Copied!'; copyToClipboardBtn.classList.add('btn-copy-success'); setTimeout(() => { copyToClipboardBtn.textContent = originalText; copyToClipboardBtn.classList.remove('btn-copy-success'); }, 2000); } catch (error) { console.error('Failed to copy to clipboard:', error); trackError(error as Error); // Show error feedback copyToClipboardBtn.textContent = 'Copy failed'; setTimeout(() => { copyToClipboardBtn.textContent = 'Copy to Clipboard'; }, 2000); } } /** * Setup issue modal event listeners */ function setupIssueModalListeners() { // Open modal reportIssueBtn.addEventListener('click', openIssueModal); // Close modal closeModalBtn.addEventListener('click', closeIssueModal); modalBackdrop.addEventListener('click', closeIssueModal); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && issueModal.style.display !== 'none') { closeIssueModal(); } }); // Context section toggles issueModal.querySelectorAll('.context-toggle').forEach(toggle => { toggle.addEventListener('click', () => { const sectionId = (toggle as HTMLElement).dataset.section; if (sectionId) { toggleContextSection(sectionId); } }); }); // Copy to clipboard copyToClipboardBtn.addEventListener('click', copyIssueToClipboard); } // Initialize on load init(); // Setup issue modal listeners after DOM is ready setupIssueModalListeners();