generated from coulomb/repo-seed
- Local tag is now clickable - checks if document exists on server by ID or filename, and re-links if found - Delete from server now archives the proxy instead of removing it, making it a local-only document that can be re-uploaded - Added first-run pin reminder banner to help users pin the extension - Added issue report modal with context sections (extension info, browser info, document status, recent errors) and copy to clipboard as Markdown - Added clearServerFields and attachServerDocument functions to pdf-queue - Improved local tag styling with hover states and visual feedback Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
579 lines
16 KiB
TypeScript
579 lines
16 KiB
TypeScript
/**
|
|
* 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<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,
|
|
errorMessage?: 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;
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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('[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<DocumentProxy[]> {
|
|
const state = await loadQueue();
|
|
return state.entries.filter(e => !e.archived);
|
|
}
|
|
|
|
/**
|
|
* Get archived proxy documents
|
|
*/
|
|
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.archived && (
|
|
e.binectStatus === 'pending' ||
|
|
e.binectStatus === 'failed' ||
|
|
e.binectStatus === 'in_basket'
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get count of PDFs needing action (for badge)
|
|
*/
|
|
export async function getActionableCount(): Promise<number> {
|
|
const actionable = await getActionablePDFs();
|
|
return actionable.length;
|
|
}
|
|
|
|
/**
|
|
* Get all Binect document IDs that we're tracking
|
|
*/
|
|
export async function getTrackedBinectIds(): Promise<number[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|