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

@@ -1,104 +1,67 @@
/**
* PDF Queue storage utilities
* 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".
*
* Manages a persistent list of detected PDFs with upload status tracking.
* Uses chrome.storage.local for persistence across service worker restarts.
*/
import { DetectedPDF } from './pdf-detector';
const STORAGE_KEY = 'pdfQueue';
const MAX_ENTRIES = 50;
const SENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const FAILED_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
const DISMISSED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const STORAGE_KEY = 'documentProxies';
const MAX_ENTRIES = 100;
const ARCHIVED_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export type PDFStatus =
| 'pending' // Not yet uploaded
/**
* 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
| 'dismissed'; // User dismissed, don't show again
| 'canceled'; // CANCELED - terminal
export interface PDFQueueEntry extends DetectedPDF {
status: PDFStatus;
binectDocumentId?: number;
binectStatus?: number; // DocumentStatus code from Binect (1-7)
binectStatusText?: string; // Human-readable status from Binect
price?: number; // Price in euro cents
recipientAddress?: string; // Extracted recipient address
errorMessage?: string;
uploadedAt?: number;
orderedAt?: number;
}
interface PDFQueueState {
entries: PDFQueueEntry[];
lastUpdated: number;
}
// Keep PDFStatus as alias for backward compatibility
export type PDFStatus = BinectStatus;
/**
* Load queue from storage
*/
export async function loadQueue(): Promise<PDFQueueState> {
const result = await chrome.storage.local.get(STORAGE_KEY);
if (result[STORAGE_KEY]) {
return result[STORAGE_KEY] as PDFQueueState;
}
return { entries: [], lastUpdated: Date.now() };
}
/**
* Save queue to storage
*/
export async function saveQueue(state: PDFQueueState): Promise<void> {
state.lastUpdated = Date.now();
await chrome.storage.local.set({ [STORAGE_KEY]: state });
}
/**
* Add a PDF to the queue
* Document Proxy - local representation of a PDF
*
* Returns the created entry, or null if the PDF was already uploaded.
* If the PDF already exists as pending/failed, returns the existing entry.
* Identified by filename + contentHash for deduplication.
* The archived flag controls visibility (live vs archived view).
*/
export async function addPDF(pdf: DetectedPDF): Promise<PDFQueueEntry | null> {
const state = await loadQueue();
export interface DocumentProxy extends DetectedPDF {
// Identification
contentHash?: string; // MD5 hash of content (set after upload)
// Check for duplicate by URL
const existing = state.entries.find(e => e.url === pdf.url);
if (existing) {
// Skip if already processed (uploaded, dismissed, etc.)
const processedStatuses: PDFStatus[] = ['in_basket', 'ordering', 'in_production', 'sent', 'canceled', 'dismissed'];
if (processedStatuses.includes(existing.status)) {
console.log('[PDF Queue] PDF already processed, skipping:', pdf.filename, existing.status);
return null;
}
console.log('[PDF Queue] PDF already in queue:', pdf.filename);
return existing;
}
// Local state
archived: boolean; // If true, shown in archive view instead of live
// Create new entry
const entry: PDFQueueEntry = {
...pdf,
status: 'pending'
};
// 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
// Add to beginning (most recent first)
state.entries.unshift(entry);
// Document details
price?: number; // Price in euro cents
recipientAddress?: string; // Extracted recipient address
errorMessage?: string; // Error message if failed
// Enforce max entries
await enforceMaxEntries(state);
await saveQueue(state);
console.log('[PDF Queue] Added PDF:', pdf.filename);
return entry;
// 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
*/
@@ -109,32 +72,217 @@ export interface PDFStatusMeta {
price?: number;
recipientAddress?: string;
errorMessage?: string;
contentHash?: string;
}
interface ProxyQueueState {
entries: DocumentProxy[];
lastUpdated: number;
}
/**
* Update the status of a PDF in the queue
* Load queue from storage
*/
export async function loadQueue(): Promise<ProxyQueueState> {
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<void> {
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<DocumentProxy | null> {
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
): Promise<DocumentProxy> {
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;
} 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
};
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: PDFStatus,
status: BinectStatus,
meta?: PDFStatusMeta
): Promise<void> {
const state = await loadQueue();
const entry = state.entries.find(e => e.id === id);
if (!entry) {
console.warn('[PDF Queue] PDF not found for status update:', id);
console.warn('[Proxy Queue] Proxy not found for status update:', id);
return;
}
entry.status = status;
entry.binectStatus = status;
// Update Binect-specific fields
// Update metadata
if (meta?.binectDocumentId !== undefined) {
entry.binectDocumentId = meta.binectDocumentId;
}
if (meta?.binectStatus !== undefined) {
entry.binectStatus = meta.binectStatus;
entry.binectStatusCode = meta.binectStatus;
}
if (meta?.binectStatusText !== undefined) {
entry.binectStatusText = meta.binectStatusText;
@@ -148,6 +296,9 @@ export async function updatePDFStatus(
if (meta?.errorMessage !== undefined) {
entry.errorMessage = meta.errorMessage;
}
if (meta?.contentHash !== undefined) {
entry.contentHash = meta.contentHash;
}
// Set timestamps based on status
if (status === 'in_basket' && !entry.uploadedAt) {
@@ -158,61 +309,98 @@ export async function updatePDFStatus(
}
await saveQueue(state);
console.log('[PDF Queue] Updated status:', id, status);
console.log('[Proxy Queue] Updated status:', id, status);
}
/**
* Dismiss a PDF (mark as dismissed so it won't reappear)
* Archive a proxy document (move from live to archive)
*/
export async function dismissPDF(id: string): Promise<void> {
export async function archiveProxy(id: string): Promise<void> {
const state = await loadQueue();
const entry = state.entries.find(e => e.id === id);
if (!entry) {
console.warn('[PDF Queue] PDF not found for dismissal:', id);
console.warn('[Proxy Queue] Proxy not found for archiving:', id);
return;
}
entry.status = 'dismissed';
entry.archived = true;
await saveQueue(state);
console.log('[PDF Queue] Dismissed PDF:', id);
console.log('[Proxy Queue] Archived proxy:', id);
}
/**
* Remove a PDF from the queue (complete removal, used for cleanup)
* Restore a proxy document (move from archive to live)
*/
export async function restoreProxy(id: string): Promise<void> {
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<void> {
const state = await loadQueue();
const index = state.entries.findIndex(e => e.id === id);
if (index === -1) {
console.warn('[PDF Queue] PDF not found for removal:', id);
console.warn('[Proxy Queue] Proxy not found for removal:', id);
return;
}
state.entries.splice(index, 1);
await saveQueue(state);
console.log('[PDF Queue] Removed PDF:', id);
console.log('[Proxy Queue] Removed proxy:', id);
}
// Keep dismissPDF as alias for archiveProxy (backward compatibility)
export const dismissPDF = archiveProxy;
/**
* Get all PDFs for display in popup (excludes dismissed)
* Get live proxy documents (not archived)
*/
export async function getAllPDFs(): Promise<PDFQueueEntry[]> {
export async function getLiveProxies(): Promise<DocumentProxy[]> {
const state = await loadQueue();
// Filter out dismissed entries - they're kept for duplicate detection but not displayed
return state.entries.filter(e => e.status !== 'dismissed');
return state.entries.filter(e => !e.archived);
}
/**
* Get PDFs that need user action (pending, failed, in_basket)
* Get archived proxy documents
*/
export async function getActionablePDFs(): Promise<PDFQueueEntry[]> {
export async function getArchivedProxies(): Promise<DocumentProxy[]> {
const state = await loadQueue();
return state.entries.filter(e => e.archived);
}
/**
* Get all proxy documents (live and archived)
*/
export async function getAllPDFs(): Promise<DocumentProxy[]> {
const state = await loadQueue();
return state.entries;
}
/**
* Get proxies that need user action (pending, failed, in_basket) - live only
*/
export async function getActionablePDFs(): Promise<DocumentProxy[]> {
const state = await loadQueue();
return state.entries.filter(e =>
e.status === 'pending' ||
e.status === 'failed' ||
e.status === 'in_basket'
!e.archived && (
e.binectStatus === 'pending' ||
e.binectStatus === 'failed' ||
e.binectStatus === 'in_basket'
)
);
}
@@ -225,18 +413,18 @@ export async function getActionableCount(): Promise<number> {
}
/**
* Legacy: Get pending PDFs (for backward compatibility)
* Get all Binect document IDs that we're tracking
*/
export async function getPendingPDFs(): Promise<PDFQueueEntry[]> {
return getActionablePDFs();
export async function getTrackedBinectIds(): Promise<number[]> {
const state = await loadQueue();
return state.entries
.filter(e => e.binectDocumentId !== undefined)
.map(e => e.binectDocumentId!);
}
/**
* Legacy: Get pending count (for backward compatibility)
*/
export async function getPendingCount(): Promise<number> {
return getActionableCount();
}
// Legacy aliases
export const getPendingPDFs = getActionablePDFs;
export const getPendingCount = getActionableCount;
/**
* Clean up old entries to prevent unbounded growth
@@ -247,37 +435,21 @@ export async function cleanupOldEntries(): Promise<void> {
const initialCount = state.entries.length;
state.entries = state.entries.filter(entry => {
// Always keep active entries
if (
entry.status === 'pending' ||
entry.status === 'uploading' ||
entry.status === 'in_basket' ||
entry.status === 'ordering' ||
entry.status === 'in_production'
) {
// 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 sent/canceled entries older than 7 days
if (entry.status === 'sent' || entry.status === 'canceled') {
// Remove old archived entries
if (entry.archived) {
const age = now - (entry.orderedAt || entry.uploadedAt || entry.timestamp);
if (age > SENT_MAX_AGE_MS) {
return false;
}
}
// Remove failed entries older than 24 hours
if (entry.status === 'failed') {
const age = now - entry.timestamp;
if (age > FAILED_MAX_AGE_MS) {
return false;
}
}
// Remove dismissed entries older than 7 days
if (entry.status === 'dismissed') {
const age = now - entry.timestamp;
if (age > DISMISSED_MAX_AGE_MS) {
if (age > ARCHIVED_MAX_AGE_MS) {
return false;
}
}
@@ -287,24 +459,22 @@ export async function cleanupOldEntries(): Promise<void> {
if (state.entries.length < initialCount) {
await saveQueue(state);
console.log('[PDF Queue] Cleaned up', initialCount - state.entries.length, 'old entries');
console.log('[Proxy Queue] Cleaned up', initialCount - state.entries.length, 'old entries');
}
}
/**
* Enforce maximum entries by removing oldest terminal entries
* Enforce maximum entries by removing oldest archived entries
*/
async function enforceMaxEntries(state: PDFQueueState): Promise<void> {
const terminalStatuses: PDFStatus[] = ['sent', 'canceled', 'failed'];
async function enforceMaxEntries(state: ProxyQueueState): Promise<void> {
while (state.entries.length > MAX_ENTRIES) {
let removeIndex = -1;
let oldestTime = Infinity;
// Find oldest terminal entry (sent, canceled, failed)
// Find oldest archived entry first
for (let i = state.entries.length - 1; i >= 0; i--) {
const entry = state.entries[i];
if (terminalStatuses.includes(entry.status)) {
if (entry.archived) {
const entryTime = entry.orderedAt || entry.uploadedAt || entry.timestamp;
if (entryTime < oldestTime) {
oldestTime = entryTime;
@@ -313,9 +483,24 @@ async function enforceMaxEntries(state: PDFQueueState): Promise<void> {
}
}
// If no terminal entries, we can't remove any more
// If no archived entries, find oldest terminal live entry
if (removeIndex === -1) {
console.warn('[PDF Queue] Max entries reached, but all are active');
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;
}