/** * Document Proxy Queue * * Manages proxy documents that represent PDFs detected by the extension. * Each proxy is identified by filename + content hash (MD5). * Proxies can be "live" (visible by default) or "archived". * * Uses chrome.storage.local for persistence across service worker restarts. */ import { DetectedPDF } from './pdf-detector'; const STORAGE_KEY = 'documentProxies'; const MAX_ENTRIES = 100; const ARCHIVED_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days /** * Binect document status (server-side state) */ export type BinectStatus = | 'pending' // Not yet uploaded to Binect | 'uploading' // Upload in progress | 'failed' // Upload failed | 'in_basket' // Uploaded, SHIPPABLE, awaiting order | 'ordering' // Order in progress | 'in_production' // PRODUCTION_QUEUE or PRINTING | 'sent' // SENT - terminal | 'canceled'; // CANCELED - terminal // Keep PDFStatus as alias for backward compatibility export type PDFStatus = BinectStatus; /** * Document Proxy - local representation of a PDF * * Identified by filename + contentHash for deduplication. * The archived flag controls visibility (live vs archived view). */ export interface DocumentProxy extends DetectedPDF { // Identification contentHash?: string; // MD5 hash of content (set after upload) // Local state archived: boolean; // If true, shown in archive view instead of live // Binect state (server-side) binectStatus: BinectStatus; // Current status with Binect binectDocumentId?: number; // Document ID on Binect server binectStatusCode?: number; // Raw status code from Binect (1-7) binectStatusText?: string; // Human-readable status from Binect // Document details price?: number; // Price in euro cents recipientAddress?: string; // Extracted recipient address errorMessage?: string; // Error message if failed // Timestamps uploadedAt?: number; // When uploaded to Binect orderedAt?: number; // When order was placed } // Keep PDFQueueEntry as alias for backward compatibility export type PDFQueueEntry = DocumentProxy; /** * Metadata for status updates */ export interface PDFStatusMeta { binectDocumentId?: number; binectStatus?: number; binectStatusText?: string; price?: number; recipientAddress?: string; errorMessage?: string; contentHash?: string; } interface ProxyQueueState { entries: DocumentProxy[]; lastUpdated: number; } /** * Load queue from storage */ export async function loadQueue(): Promise { const result = await chrome.storage.local.get(STORAGE_KEY); if (result[STORAGE_KEY]) { // Migrate old entries that don't have archived field const state = result[STORAGE_KEY] as ProxyQueueState; for (const entry of state.entries) { if (entry.archived === undefined) { // Migrate: dismissed becomes archived, others are live entry.archived = (entry as unknown as { status: string }).status === 'dismissed'; } // Migrate: old 'status' field to 'binectStatus' if (!entry.binectStatus && (entry as unknown as { status: string }).status) { const oldStatus = (entry as unknown as { status: string }).status; if (oldStatus !== 'dismissed') { entry.binectStatus = oldStatus as BinectStatus; } else { entry.binectStatus = 'pending'; } } } return state; } return { entries: [], lastUpdated: Date.now() }; } /** * Save queue to storage */ export async function saveQueue(state: ProxyQueueState): Promise { state.lastUpdated = Date.now(); await chrome.storage.local.set({ [STORAGE_KEY]: state }); } /** * Find existing proxy by filename and content hash * If contentHash is not provided, matches by filename only (for pre-upload detection) */ function findExistingProxy( entries: DocumentProxy[], filename: string, contentHash?: string ): DocumentProxy | undefined { if (contentHash) { // Exact match: filename + hash return entries.find(e => e.filename === filename && e.contentHash === contentHash); } // For pre-upload: check by filename and URL (same source) return undefined; // Don't match without hash - let it be added } /** * Find existing proxy by Binect document ID */ function findProxyByBinectId( entries: DocumentProxy[], binectDocumentId: number ): DocumentProxy | undefined { return entries.find(e => e.binectDocumentId === binectDocumentId); } /** * Add a PDF to the queue (creates a new proxy document) * * Returns the created entry, or existing entry if duplicate found. * Duplicates are identified by filename + contentHash. */ export async function addPDF( pdf: DetectedPDF, contentHash?: string ): Promise { const state = await loadQueue(); // Check for duplicate by filename + hash (if hash provided) if (contentHash) { const existing = findExistingProxy(state.entries, pdf.filename, contentHash); if (existing) { console.log('[Proxy Queue] Duplicate found by hash, returning existing:', pdf.filename); // If it was archived, restore it to live if (existing.archived) { existing.archived = false; await saveQueue(state); } return existing; } } // Check for existing entry with same URL (same source, not yet hashed) const byUrl = state.entries.find(e => e.url === pdf.url && !e.contentHash); if (byUrl) { console.log('[Proxy Queue] Found existing by URL:', pdf.filename); return byUrl; } // Create new proxy document const proxy: DocumentProxy = { ...pdf, contentHash, archived: false, binectStatus: 'pending' }; // Add to beginning (most recent first) state.entries.unshift(proxy); // Enforce max entries await enforceMaxEntries(state); await saveQueue(state); console.log('[Proxy Queue] Created new proxy:', pdf.filename); return proxy; } /** * Create or update a proxy from server document * Used when syncing with Binect server */ export async function syncFromServer( binectDocumentId: number, filename: string, binectStatusCode: number, binectStatusText: string, price?: number, recipientAddress?: string, errorMessage?: string ): Promise { const state = await loadQueue(); // Check if we already have a proxy for this Binect document let proxy = findProxyByBinectId(state.entries, binectDocumentId); if (proxy) { // Update existing proxy proxy.binectStatusCode = binectStatusCode; proxy.binectStatusText = binectStatusText; proxy.binectStatus = mapBinectStatusCode(binectStatusCode); if (price !== undefined) proxy.price = price; if (recipientAddress) proxy.recipientAddress = recipientAddress; if (errorMessage) proxy.errorMessage = errorMessage; } else { // Create new proxy from server data proxy = { id: `server-${binectDocumentId}`, filename, url: '', size: 0, timestamp: Date.now(), sourceDomain: 'binect.de', archived: false, binectStatus: mapBinectStatusCode(binectStatusCode), binectDocumentId, binectStatusCode, binectStatusText, price, recipientAddress, errorMessage }; state.entries.unshift(proxy); } await saveQueue(state); return proxy; } /** * Map Binect status code to BinectStatus */ function mapBinectStatusCode(code: number): BinectStatus { switch (code) { case 1: return 'pending'; // IN_PREPARATION case 2: return 'in_basket'; // SHIPPABLE case 3: return 'in_production'; // PRODUCTION_QUEUE case 4: return 'in_production'; // PRINTING case 5: return 'sent'; // SENT case 6: return 'canceled'; // CANCELED case 7: return 'failed'; // ERRONEOUS default: return 'pending'; } } /** * Update the status of a proxy document */ export async function updatePDFStatus( id: string, status: BinectStatus, meta?: PDFStatusMeta ): Promise { const state = await loadQueue(); const entry = state.entries.find(e => e.id === id); if (!entry) { console.warn('[Proxy Queue] Proxy not found for status update:', id); return; } entry.binectStatus = status; // Update metadata if (meta?.binectDocumentId !== undefined) { entry.binectDocumentId = meta.binectDocumentId; } if (meta?.binectStatus !== undefined) { entry.binectStatusCode = meta.binectStatus; } if (meta?.binectStatusText !== undefined) { entry.binectStatusText = meta.binectStatusText; } if (meta?.price !== undefined) { entry.price = meta.price; } if (meta?.recipientAddress !== undefined) { entry.recipientAddress = meta.recipientAddress; } if (meta?.errorMessage !== undefined) { entry.errorMessage = meta.errorMessage; } if (meta?.contentHash !== undefined) { entry.contentHash = meta.contentHash; } // Clear error message when transitioning to non-erroneous state if (status !== 'failed' && entry.errorMessage) { entry.errorMessage = undefined; } // Set timestamps based on status if (status === 'in_basket' && !entry.uploadedAt) { entry.uploadedAt = Date.now(); } if (status === 'in_production' && !entry.orderedAt) { entry.orderedAt = Date.now(); } await saveQueue(state); console.log('[Proxy Queue] Updated status:', id, status); } /** * Archive a proxy document (move from live to archive) */ export async function archiveProxy(id: string): Promise { const state = await loadQueue(); const entry = state.entries.find(e => e.id === id); if (!entry) { console.warn('[Proxy Queue] Proxy not found for archiving:', id); return; } entry.archived = true; await saveQueue(state); console.log('[Proxy Queue] Archived proxy:', id); } /** * Clear server-side fields from a proxy (when deleted from server) * This makes the proxy "local only" again */ export async function clearServerFields(id: string): Promise { const state = await loadQueue(); const entry = state.entries.find(e => e.id === id); if (!entry) { console.warn('[Proxy Queue] Proxy not found for clearing server fields:', id); return; } // Clear all server-related fields entry.binectDocumentId = undefined; entry.binectStatusCode = undefined; entry.binectStatusText = undefined; entry.binectStatus = 'pending'; // Reset to pending since it's no longer on server entry.price = undefined; entry.recipientAddress = undefined; entry.errorMessage = undefined; entry.uploadedAt = undefined; entry.orderedAt = undefined; await saveQueue(state); console.log('[Proxy Queue] Cleared server fields for proxy:', id); } /** * Attach server document to a proxy * Used when re-linking a local proxy to a server document */ export async function attachServerDocument( id: string, binectDocumentId: number, binectStatusCode: number, binectStatusText: string, price?: number, recipientAddress?: string, errorMessage?: string ): Promise { const state = await loadQueue(); const entry = state.entries.find(e => e.id === id); if (!entry) { console.warn('[Proxy Queue] Proxy not found for attaching server document:', id); return; } entry.binectDocumentId = binectDocumentId; entry.binectStatusCode = binectStatusCode; entry.binectStatusText = binectStatusText; entry.binectStatus = mapBinectStatusCode(binectStatusCode); if (price !== undefined) entry.price = price; if (recipientAddress) entry.recipientAddress = recipientAddress; if (errorMessage) entry.errorMessage = errorMessage; await saveQueue(state); console.log('[Proxy Queue] Attached server document', binectDocumentId, 'to proxy:', id); } /** * Restore a proxy document (move from archive to live) */ export async function restoreProxy(id: string): Promise { const state = await loadQueue(); const entry = state.entries.find(e => e.id === id); if (!entry) { console.warn('[Proxy Queue] Proxy not found for restoring:', id); return; } entry.archived = false; await saveQueue(state); console.log('[Proxy Queue] Restored proxy:', id); } /** * Remove a proxy document completely */ export async function removePDF(id: string): Promise { const state = await loadQueue(); const index = state.entries.findIndex(e => e.id === id); if (index === -1) { console.warn('[Proxy Queue] Proxy not found for removal:', id); return; } state.entries.splice(index, 1); await saveQueue(state); console.log('[Proxy Queue] Removed proxy:', id); } // Keep dismissPDF as alias for archiveProxy (backward compatibility) export const dismissPDF = archiveProxy; /** * Get live proxy documents (not archived) */ export async function getLiveProxies(): Promise { const state = await loadQueue(); return state.entries.filter(e => !e.archived); } /** * Get archived proxy documents */ export async function getArchivedProxies(): Promise { const state = await loadQueue(); return state.entries.filter(e => e.archived); } /** * Get all proxy documents (live and archived) */ export async function getAllPDFs(): Promise { const state = await loadQueue(); return state.entries; } /** * Get proxies that need user action (pending, failed, in_basket) - live only */ export async function getActionablePDFs(): Promise { const state = await loadQueue(); return state.entries.filter(e => !e.archived && ( e.binectStatus === 'pending' || e.binectStatus === 'failed' || e.binectStatus === 'in_basket' ) ); } /** * Get count of PDFs needing action (for badge) */ export async function getActionableCount(): Promise { const actionable = await getActionablePDFs(); return actionable.length; } /** * Get all Binect document IDs that we're tracking */ export async function getTrackedBinectIds(): Promise { const state = await loadQueue(); return state.entries .filter(e => e.binectDocumentId !== undefined) .map(e => e.binectDocumentId!); } // Legacy aliases export const getPendingPDFs = getActionablePDFs; export const getPendingCount = getActionableCount; /** * Clean up old entries to prevent unbounded growth */ export async function cleanupOldEntries(): Promise { const state = await loadQueue(); const now = Date.now(); const initialCount = state.entries.length; state.entries = state.entries.filter(entry => { // Always keep live entries that are active if (!entry.archived && ( entry.binectStatus === 'pending' || entry.binectStatus === 'uploading' || entry.binectStatus === 'in_basket' || entry.binectStatus === 'ordering' || entry.binectStatus === 'in_production' )) { return true; } // Remove old archived entries if (entry.archived) { const age = now - (entry.orderedAt || entry.uploadedAt || entry.timestamp); if (age > ARCHIVED_MAX_AGE_MS) { return false; } } return true; }); if (state.entries.length < initialCount) { await saveQueue(state); console.log('[Proxy Queue] Cleaned up', initialCount - state.entries.length, 'old entries'); } } /** * Enforce maximum entries by removing oldest archived entries */ async function enforceMaxEntries(state: ProxyQueueState): Promise { while (state.entries.length > MAX_ENTRIES) { let removeIndex = -1; let oldestTime = Infinity; // Find oldest archived entry first for (let i = state.entries.length - 1; i >= 0; i--) { const entry = state.entries[i]; if (entry.archived) { const entryTime = entry.orderedAt || entry.uploadedAt || entry.timestamp; if (entryTime < oldestTime) { oldestTime = entryTime; removeIndex = i; } } } // If no archived entries, find oldest terminal live entry if (removeIndex === -1) { const terminalStatuses: BinectStatus[] = ['sent', 'canceled']; for (let i = state.entries.length - 1; i >= 0; i--) { const entry = state.entries[i]; if (terminalStatuses.includes(entry.binectStatus)) { const entryTime = entry.orderedAt || entry.uploadedAt || entry.timestamp; if (entryTime < oldestTime) { oldestTime = entryTime; removeIndex = i; } } } } // If still nothing to remove, we can't shrink if (removeIndex === -1) { console.warn('[Proxy Queue] Max entries reached, but all are active'); break; } state.entries.splice(removeIndex, 1); } }