Implement document proxy concept with archive/live views

- Add content hash (MD5) for document deduplication
- Separate local state (archived) from server state (binectStatus)
- Add archive toggle button to switch between live/archived views
- Add archive/restore/delete actions for documents
- Refactor pdf-queue.ts with DocumentProxy interface
- Add hash.ts utility for content hashing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 17:11:15 +01:00
parent 5cb0194533
commit facae724bf
6 changed files with 697 additions and 261 deletions

View File

@@ -7,7 +7,8 @@ import { loadCredentials, saveCredentials, deleteCredentials, updateLastUse } fr
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';
import { DocumentProxy, PDFQueueEntry, PDFStatus, PDFStatusMeta } from '../utils/pdf-queue';
import { computeMD5 } from '../utils/hash';
// DOM Elements
const authView = document.getElementById('authView')!;
@@ -25,6 +26,9 @@ 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 refreshBtn = document.getElementById('refreshBtn')!;
const helpBtn = document.getElementById('helpBtn')!;
const togglePasswordBtn = document.getElementById('togglePassword') as HTMLButtonElement;
@@ -32,8 +36,9 @@ const eyeIcon = document.getElementById('eyeIcon')!;
const eyeOffIcon = document.getElementById('eyeOffIcon')!;
// State
let pdfQueue: PDFQueueEntry[] = [];
let pdfQueue: DocumentProxy[] = [];
let currentCredentials: { username: string; password: string } | null = null;
let showingArchive = false; // false = live view, true = archive view
/**
* Initialize popup
@@ -76,11 +81,35 @@ async function init() {
function setupEventListeners() {
loginForm.addEventListener('submit', handleLogin);
logoutBtn.addEventListener('click', handleLogout);
archiveToggleBtn.addEventListener('click', handleToggleArchiveView);
refreshBtn.addEventListener('click', handleRefreshAll);
helpBtn.addEventListener('click', handleHelp);
togglePasswordBtn.addEventListener('click', handleTogglePassword);
}
/**
* 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();
}
/**
* Handle password visibility toggle
*/
@@ -152,43 +181,50 @@ async function handleLogin(e: Event) {
* Load PDF queue from background and current tab
*/
async function loadPDFQueue() {
console.log('[Popup] Loading PDF queue...');
console.log('[Popup] Loading PDF queue, showingArchive:', showingArchive);
// 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');
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');
// 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({
// 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
pdf: currentTabPDF
});
if (addResult?.entry) {
console.log('[Popup] Added current tab PDF to persistent queue:', currentTabPDF.filename);
}
}
}
// Reload queue after potential additions
response = await chrome.runtime.sendMessage({ action: 'getAllPDFs' });
pdfQueue = response?.entries || [];
console.log('[Popup] Final queue count:', pdfQueue.length);
// 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();
@@ -244,15 +280,6 @@ async function checkRecentDownloads(): Promise<DetectedPDF[]> {
* 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;
@@ -261,46 +288,66 @@ function renderPDFList() {
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 += `<div class="pdf-section">
<div class="pdf-section-header">Ready to Upload</div>
${pendingUpload.map(pdf => renderPDFItem(pdf, 'pending')).join('')}
</div>`;
}
if (showingArchive) {
// Archive view - show all archived documents in a flat list
pdfCount.textContent = `${pdfQueue.length} archived document${pdfQueue.length !== 1 ? 's' : ''}`;
if (inBasket.length > 0) {
html += `<div class="pdf-section">
<div class="pdf-section-header">In Basket</div>
${inBasket.map(pdf => renderPDFItem(pdf, 'basket')).join('')}
html = `<div class="pdf-section">
<div class="pdf-section-header">Archived Documents</div>
${pdfQueue.map(pdf => renderPDFItem(pdf, 'archived')).join('')}
</div>`;
}
} else {
// Live view - group by Binect status
const pendingUpload = pdfQueue.filter(p => p.binectStatus === 'pending' || p.binectStatus === 'uploading' || p.binectStatus === 'failed');
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');
if (inProduction.length > 0) {
html += `<div class="pdf-section">
<div class="pdf-section-header">In Production</div>
${inProduction.map(pdf => renderPDFItem(pdf, 'production')).join('')}
</div>`;
}
// Count actionable items
const actionableCount = pendingUpload.length + inBasket.length;
if (completed.length > 0) {
// Show only last 5 completed items
const recentCompleted = completed.slice(0, 5);
html += `<div class="pdf-section pdf-section-completed">
<div class="pdf-section-header">Recently Completed</div>
${recentCompleted.map(pdf => renderPDFItem(pdf, 'completed')).join('')}
</div>`;
// 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 += `<div class="pdf-section">
<div class="pdf-section-header">Ready to Upload</div>
${pendingUpload.map(pdf => renderPDFItem(pdf, 'pending')).join('')}
</div>`;
}
if (inBasket.length > 0) {
html += `<div class="pdf-section">
<div class="pdf-section-header">In Basket</div>
${inBasket.map(pdf => renderPDFItem(pdf, 'basket')).join('')}
</div>`;
}
if (inProduction.length > 0) {
html += `<div class="pdf-section">
<div class="pdf-section-header">In Production</div>
${inProduction.map(pdf => renderPDFItem(pdf, 'production')).join('')}
</div>`;
}
if (completed.length > 0) {
// Show only last 5 completed items in live view
const recentCompleted = completed.slice(0, 5);
html += `<div class="pdf-section pdf-section-completed">
<div class="pdf-section-header">Recently Completed</div>
${recentCompleted.map(pdf => renderPDFItem(pdf, 'completed')).join('')}
</div>`;
}
}
pdfList.innerHTML = html;
@@ -313,8 +360,8 @@ function renderPDFList() {
/**
* Render a single PDF item
*/
function renderPDFItem(pdf: PDFQueueEntry, section: 'pending' | 'basket' | 'production' | 'completed'): string {
const statusClass = getStatusClass(pdf.status);
function renderPDFItem(pdf: DocumentProxy, section: 'pending' | 'basket' | 'production' | 'completed' | 'archived'): string {
const statusClass = getStatusClass(pdf.binectStatus);
const statusText = getStatusText(pdf);
const priceText = pdf.price ? `${(pdf.price / 100).toFixed(2)}` : '';
@@ -323,34 +370,42 @@ function renderPDFItem(pdf: PDFQueueEntry, section: 'pending' | 'basket' | 'prod
switch (section) {
case 'pending':
actionsHtml = `
<button class="btn-send-item" data-id="${escapeHtml(pdf.id)}" ${pdf.status === 'uploading' ? 'disabled' : ''}>
${pdf.status === 'uploading' ? 'Uploading...' : (pdf.status === 'failed' ? 'Retry' : 'Upload')}
<button class="btn-send-item" data-id="${escapeHtml(pdf.id)}" ${pdf.binectStatus === 'uploading' ? 'disabled' : ''}>
${pdf.binectStatus === 'uploading' ? 'Uploading...' : (pdf.binectStatus === 'failed' ? 'Retry' : 'Upload')}
</button>
<button class="btn-dismiss" data-id="${escapeHtml(pdf.id)}">Dismiss</button>
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
break;
case 'basket':
actionsHtml = `
<button class="btn-order-item" data-id="${escapeHtml(pdf.id)}" ${pdf.status === 'ordering' ? 'disabled' : ''}>
${pdf.status === 'ordering' ? 'Ordering...' : 'Order'}
<button class="btn-order-item" data-id="${escapeHtml(pdf.id)}" ${pdf.binectStatus === 'ordering' ? 'disabled' : ''}>
${pdf.binectStatus === 'ordering' ? 'Ordering...' : 'Order'}
</button>
<button class="btn-dismiss" data-id="${escapeHtml(pdf.id)}">Cancel</button>
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
break;
case 'production':
// No individual actions - use global refresh button in header
actionsHtml = '';
// Archive button only
actionsHtml = `
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
break;
case 'completed':
actionsHtml = `
<button class="btn-dismiss btn-remove" data-id="${escapeHtml(pdf.id)}">Remove</button>
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
break;
case 'archived':
actionsHtml = `
<button class="btn-restore" data-id="${escapeHtml(pdf.id)}">Restore</button>
<button class="btn-dismiss btn-remove" data-id="${escapeHtml(pdf.id)}">Delete</button>
`;
break;
}
return `
<div class="pdf-list-item ${statusClass}" data-id="${escapeHtml(pdf.id)}">
<div class="pdf-item-icon">${getStatusIcon(pdf.status)}</div>
<div class="pdf-item-icon">${getStatusIcon(pdf.binectStatus)}</div>
<div class="pdf-item-details">
<div class="pdf-item-filename" title="${escapeHtml(pdf.filename)}">${escapeHtml(pdf.filename)}</div>
<div class="pdf-item-meta">
@@ -387,11 +442,27 @@ function setupPDFListEventListeners() {
});
});
// Dismiss/Cancel/Remove buttons
// 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/Remove buttons
pdfList.querySelectorAll('.btn-dismiss').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = (e.target as HTMLElement).dataset.id;
if (id) handleDismissPDF(id);
if (id) handleDeletePDF(id);
});
});
}
@@ -399,8 +470,8 @@ function setupPDFListEventListeners() {
/**
* Get status text for a PDF
*/
function getStatusText(pdf: PDFQueueEntry): string {
switch (pdf.status) {
function getStatusText(pdf: DocumentProxy): string {
switch (pdf.binectStatus) {
case 'pending':
return formatTimestamp(pdf.timestamp);
case 'uploading':
@@ -483,7 +554,7 @@ async function handleSendPDF(id: string) {
}
// Update local state
pdf.status = 'uploading';
pdf.binectStatus = 'uploading';
renderPDFList();
// Notify background
@@ -497,6 +568,9 @@ async function handleSendPDF(id: string) {
// 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,
@@ -521,7 +595,8 @@ async function handleSendPDF(id: string) {
const meta: PDFStatusMeta = {
binectDocumentId: document.id,
binectStatus: document.status.code,
binectStatusText: document.status.text
binectStatusText: document.status.text,
contentHash
};
if (document.letter?.letterData) {
@@ -538,10 +613,11 @@ async function handleSendPDF(id: string) {
});
// Update local state
pdf.status = 'in_basket';
pdf.binectStatus = 'in_basket';
pdf.binectDocumentId = document.id;
pdf.binectStatus = document.status.code;
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;
@@ -591,7 +667,7 @@ async function handleSendPDF(id: string) {
});
// Update local state
pdf.status = 'failed';
pdf.binectStatus = 'failed';
pdf.errorMessage = errorMessage;
renderPDFList();
@@ -609,7 +685,7 @@ async function handleOrderPDF(id: string) {
}
// Update local state
pdf.status = 'ordering';
pdf.binectStatus = 'ordering';
renderPDFList();
// Notify background
@@ -646,8 +722,8 @@ async function handleOrderPDF(id: string) {
});
// Update local state
pdf.status = 'in_production';
pdf.binectStatus = result.status;
pdf.binectStatus = 'in_production';
pdf.binectStatusCode = result.status;
pdf.binectStatusText = result.statusText;
renderPDFList();
@@ -670,7 +746,7 @@ async function handleOrderPDF(id: string) {
});
// Update local state
pdf.status = 'in_basket';
pdf.binectStatus = 'in_basket';
pdf.errorMessage = errorMessage;
renderPDFList();
@@ -689,7 +765,7 @@ async function handleRefreshAll() {
// Find all documents that have been uploaded and are still visible
const uploadedDocs = pdfQueue.filter(p =>
p.binectDocumentId &&
(p.status === 'in_basket' || p.status === 'in_production')
(p.binectStatus === 'in_basket' || p.binectStatus === 'in_production')
);
if (uploadedDocs.length === 0) {
@@ -748,7 +824,7 @@ async function handleRefreshStatus(id: string) {
}
// Determine new local status based on Binect status code
let newStatus: PDFStatus = pdf.status;
let newStatus: PDFStatus = pdf.binectStatus;
if (result.status === 5) {
newStatus = 'sent';
} else if (result.status === 6) {
@@ -775,8 +851,8 @@ async function handleRefreshStatus(id: string) {
});
// Update local state
pdf.status = newStatus;
pdf.binectStatus = result.status;
pdf.binectStatus = newStatus;
pdf.binectStatusCode = result.status;
pdf.binectStatusText = result.statusText;
if (result.price) pdf.price = result.price;
if (result.recipientAddress) pdf.recipientAddress = result.recipientAddress;
@@ -795,20 +871,32 @@ async function handleRefreshStatus(id: string) {
}
/**
* Handle dismiss PDF
* Handle archive PDF (move from live to archive)
*/
async function handleDismissPDF(id: string) {
const pdf = pdfQueue.find(p => p.id === id);
if (!pdf) return;
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);
}
// 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 });
}
/**
* 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 delete PDF (permanently remove)
*/
async function handleDeletePDF(id: string) {
await chrome.runtime.sendMessage({ action: 'removePDF', id });
pdfQueue = pdfQueue.filter(p => p.id !== id);
renderPDFList();
}