Server sync, erroneous doc handling, and @binect/js v0.1.0 integration

- Add server sync to discover documents uploaded elsewhere (fixes missing
  basket documents issue)
- Handle erroneous uploads: preserve binectDocumentId for delete button
- Add "Delete from server" button for erroneous and canceled documents
- Remove archive button for active documents (in_basket, in_production)
- Auto-restore archived documents that have active status
- Refactor to use @binect/js v0.1.0 features:
  - DocumentStatus enum instead of magic numbers
  - isErroneous(), getErrors() helper functions
  - getStatusDescription() for status text
- Add binect-js improvement requirements document

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 22:41:43 +01:00
parent f4c0481eda
commit 327943bc18
6 changed files with 739 additions and 69 deletions

View File

@@ -18,10 +18,11 @@ import {
dismissPDF,
removePDF,
cleanupOldEntries,
syncFromServer,
PDFStatus,
PDFStatusMeta
} from '../utils/pdf-queue';
import { shipDocument, getDocumentStatus } from '../utils/binect-api';
import { shipDocument, getDocumentStatus, deleteDocument, listServerDocuments } from '../utils/binect-api';
/**
* Initialize extension on install
@@ -267,6 +268,72 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
return true;
}
// Delete a document from the server
if (request.action === 'deleteServerDocument') {
const { documentId, username, password } = request as {
documentId: number;
username: string;
password: string;
};
deleteDocument(documentId, username, password)
.then(() => {
sendResponse({ success: true });
})
.catch(error => {
sendResponse({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete document'
});
});
return true;
}
// List all documents from the server (for sync)
if (request.action === 'listServerDocuments') {
const { username, password } = request as {
username: string;
password: string;
};
listServerDocuments(username, password)
.then(documents => {
sendResponse({ success: true, documents });
})
.catch(error => {
sendResponse({
success: false,
error: error instanceof Error ? error.message : 'Failed to list documents'
});
});
return true;
}
// Sync a server document to local proxy (create or update)
if (request.action === 'syncFromServer') {
const { binectDocumentId, filename, binectStatusCode, binectStatusText, price, recipientAddress, errorDetails } = request as {
binectDocumentId: number;
filename: string;
binectStatusCode: number;
binectStatusText: string;
price?: number;
recipientAddress?: string;
errorDetails?: string;
};
syncFromServer(binectDocumentId, filename, binectStatusCode, binectStatusText, price, recipientAddress, errorDetails)
.then(proxy => {
sendResponse({ success: true, proxy });
})
.catch(error => {
sendResponse({
success: false,
error: error instanceof Error ? error.message : 'Failed to sync document'
});
});
return true;
}
// Legacy handlers for backward compatibility
if (request.action === 'getLastPDF') {
getPendingPDFs().then(entries => {

View File

@@ -611,6 +611,23 @@ body {
background: var(--binect-blue-deep);
}
/* Delete from server button */
.btn-delete-server {
padding: 4px 8px;
font-size: 10px;
background: transparent;
color: var(--red);
border: 1px solid var(--red);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.btn-delete-server:hover {
background: var(--red);
color: white;
}
/* Order button */
.btn-order-item {
padding: 6px 12px;

View File

@@ -253,6 +253,27 @@ async function loadPDFQueue() {
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) {
@@ -289,6 +310,70 @@ async function loadPDFQueue() {
renderPDFList();
}
/**
* Sync local proxies with server documents
* Creates local proxies for documents that exist on server but not locally
*/
async function syncWithServer() {
if (!currentCredentials) {
console.log('[Popup] No credentials, skipping server sync');
return;
}
try {
console.log('[Popup] Syncing with Binect server...');
// Get list of documents from server
const result = await chrome.runtime.sendMessage({
action: 'listServerDocuments',
username: currentCredentials.username,
password: currentCredentials.password
});
console.log('[Popup] listServerDocuments result:', result);
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] Found', serverDocs.length, 'documents on server');
for (const doc of serverDocs) {
console.log('[Popup] Server doc:', doc.id, doc.filename, 'status:', doc.status, doc.statusText);
}
// Sync each server document to local proxy
for (const doc of serverDocs) {
console.log('[Popup] Syncing doc', doc.id, 'to local proxy...');
const syncResult = 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
});
console.log('[Popup] Sync result for doc', doc.id, ':', syncResult);
}
console.log('[Popup] Server sync complete');
} catch (error) {
console.error('[Popup] Server sync error:', error);
}
}
/**
* Check recent downloads for PDFs (fallback mechanism)
*/
@@ -428,33 +513,44 @@ function renderPDFItem(pdf: DocumentProxy, section: 'pending' | 'basket' | 'prod
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 = `
<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-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
${canDeleteFromServer
? `<button class="btn-delete-server" data-id="${escapeHtml(pdf.id)}">Delete from server</button>`
: `<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>`
}
`;
break;
case 'basket':
// No archive button for in_basket - these are active documents
actionsHtml = `
<button class="btn-order-item" data-id="${escapeHtml(pdf.id)}" ${pdf.binectStatus === 'ordering' ? 'disabled' : ''}>
${pdf.binectStatus === 'ordering' ? 'Sending...' : 'Send'}
</button>
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
break;
case 'production':
// Archive button only
actionsHtml = `
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
// No archive button for in_production - these are active documents
actionsHtml = '';
break;
case 'completed':
actionsHtml = `
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
// For sent/canceled documents - offer delete from server if applicable
if (canDeleteFromServer) {
actionsHtml = `
<button class="btn-delete-server" data-id="${escapeHtml(pdf.id)}">Delete from server</button>
`;
} else {
actionsHtml = `
<button class="btn-archive" data-id="${escapeHtml(pdf.id)}">Archive</button>
`;
}
break;
case 'archived':
actionsHtml = `
@@ -526,6 +622,14 @@ function setupPDFListEventListeners() {
});
});
// 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) => {
@@ -647,13 +751,14 @@ async function handleSendPDF(id: string) {
currentCredentials.password
);
// Track successful transfer
// Track transfer
await addTrackingEntry({
timestamp: Date.now(),
sourceDomain: pdf.sourceDomain,
destinationUrl: 'https://api.binect.de/binectapi/v1/documents',
pdfSize: pdfBytes.byteLength,
result: 'success'
result: document.status.code === 7 ? 'failure' : 'success',
errorMessage: document.status.code === 7 ? document.status.text : undefined
});
// Update last use timestamp
@@ -672,27 +777,57 @@ async function handleSendPDF(id: string) {
meta.recipientAddress = document.letter.letterData.recipientAddress;
}
// Update status to in_basket (document is now SHIPPABLE)
await chrome.runtime.sendMessage({
action: 'updatePDFStatus',
id,
status: 'in_basket',
meta
});
// 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 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;
// 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');
}
renderPDFList();
showStatus(`Uploaded! Ready to order (${(pdf.price || 0) / 100} €)`, 'success');
// Start auto-refresh sequence
startAutoRefresh();
@@ -961,6 +1096,42 @@ async function handleRestorePDF(id: string) {
setTimeout(() => hideStatus(), 2000);
}
/**
* Handle delete from server (for erroneous or canceled documents)
*/
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');
}
// Remove from local queue after successful server deletion
await chrome.runtime.sendMessage({ action: 'removePDF', id });
pdfQueue = pdfQueue.filter(p => p.id !== id);
renderPDFList();
showStatus('Document deleted from server', '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)
*/

View File

@@ -10,6 +10,10 @@ import {
BinectClient,
BinectApiError,
BinectAuthError,
DocumentStatus,
isErroneous,
getErrors,
getStatusDescription,
type Document as BinectDocument,
type DocumentUploadOptions,
EnvelopeType,
@@ -225,25 +229,14 @@ export async function uploadPDF(
console.log('[Binect API] Document ID:', doc.id);
console.log('[Binect API] Document status:', doc.status);
// Check if document has errors
if (doc.letter?.letterType === 'Error' && doc.letter.errors) {
console.warn('[Binect API] Document has errors:', doc.letter.errors);
const errorMessages = doc.letter.errors.map(e => e.message).join('; ');
throw new BinectAPIError(
`Document validation failed: ${errorMessages}`,
200,
doc
);
}
// Status code 7 = erroneous
if (doc.status.code === 7) {
console.error('[Binect API] Document is erroneous:', doc.status.text);
throw new BinectAPIError(
`Document is erroneous: ${doc.status.text}`,
200,
doc
);
// Log if document has errors (status ERRONEOUS)
// But still return the document so we can track it and offer delete
if (isErroneous(doc)) {
console.warn('[Binect API] Document is erroneous:', doc.status.text);
const errors = getErrors(doc);
if (errors.length > 0) {
console.warn('[Binect API] Document errors:', errors.map(e => e.message).join('; '));
}
}
return mapDocument(doc);
@@ -369,7 +362,7 @@ export async function shipDocument(
return {
status: sending.status,
statusText: getStatusText(sending.status),
statusText: getStatusDescription(sending.status),
price: sending.price,
};
} catch (error) {
@@ -433,15 +426,18 @@ export async function getDocumentStatus(
recipientAddress = doc.letter.letterData.recipientAddress;
}
// Extract error details for erroneous documents (status 7)
if (doc.status.code === 7 && doc.letter?.errors && doc.letter.errors.length > 0) {
errorDetails = doc.letter.errors.map(e => e.message).join('; ');
console.log('[Binect API] Document errors:', errorDetails);
// Extract error details for erroneous documents
if (isErroneous(doc)) {
const errors = getErrors(doc);
if (errors.length > 0) {
errorDetails = errors.map(e => e.message).join('; ');
console.log('[Binect API] Document errors:', errorDetails);
}
}
return {
status: doc.status.code,
statusText: doc.status.text || getStatusText(doc.status.code),
statusText: doc.status.text || getStatusDescription(doc.status.code),
price,
recipientAddress,
errorDetails,
@@ -472,17 +468,139 @@ export async function getDocumentStatus(
}
/**
* Get human-readable status text for a Binect status code
* Server document info for sync
*/
function getStatusText(statusCode: number): string {
switch (statusCode) {
case 1: return 'In preparation';
case 2: return 'Ready to ship';
case 3: return 'In production queue';
case 4: return 'Printing';
case 5: return 'Sent';
case 6: return 'Canceled';
case 7: return 'Has errors';
default: return 'Unknown status';
export interface ServerDocument {
id: number;
filename: string;
status: number;
statusText: string;
price?: number;
recipientAddress?: string;
errorDetails?: string;
}
/**
* List all shippable documents from the server
*
* @param username - Binect username
* @param password - Binect password
* @returns Array of server documents
*/
export async function listServerDocuments(
username: string,
password: string
): Promise<ServerDocument[]> {
console.log('[Binect API] Listing server documents...');
try {
const client = new BinectClient({
username,
password,
});
// Get shippable documents (status 2)
console.log('[Binect API] Fetching shippable documents...');
const shippableResponse = await client.documents.list();
console.log('[Binect API] Shippable response:', JSON.stringify(shippableResponse));
const shippable = shippableResponse.items || [];
console.log('[Binect API] Found', shippable.length, 'shippable documents');
// Get erroneous documents (status 7)
console.log('[Binect API] Fetching erroneous documents...');
const errorsResponse = await client.documents.listErrors();
console.log('[Binect API] Errors response:', JSON.stringify(errorsResponse));
const erroneous = errorsResponse.items || [];
console.log('[Binect API] Found', erroneous.length, 'erroneous documents');
// Combine and map to our format
const allDocs = [...shippable, ...erroneous];
console.log('[Binect API] Total documents on server:', allDocs.length);
return allDocs.map(doc => {
let errorDetails: string | undefined;
if (isErroneous(doc)) {
const errors = getErrors(doc);
if (errors.length > 0) {
errorDetails = errors.map(e => e.message).join('; ');
}
}
return {
id: doc.id,
filename: doc.filename || 'document.pdf',
status: doc.status.code,
statusText: doc.status.text || getStatusDescription(doc.status.code),
price: doc.letter?.letterData?.price?.priceAfterTax,
recipientAddress: doc.letter?.letterData?.recipientAddress,
errorDetails,
};
});
} catch (error) {
console.error('[Binect API] List documents error:', error);
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
throw BinectAPIError.fromBinectError(error);
}
throw new BinectAPIError(
`Failed to list documents: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Delete a document from the server
*
* @param documentId - Binect document ID
* @param username - Binect username
* @param password - Binect password
*/
export async function deleteDocument(
documentId: number,
username: string,
password: string
): Promise<void> {
console.log('[Binect API] Deleting document:', documentId);
try {
const client = new BinectClient({
username,
password,
});
await client.documents.delete(String(documentId));
console.log('[Binect API] Document deleted successfully');
} catch (error) {
console.error('[Binect API] Delete error:', error);
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
if (error.status === 404) {
// Already deleted, treat as success
console.log('[Binect API] Document already deleted (404)');
return;
}
throw BinectAPIError.fromBinectError(error);
}
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Failed to delete document: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// Re-export DocumentStatus enum for use in other modules
export { DocumentStatus };

View File

@@ -205,7 +205,8 @@ export async function syncFromServer(
binectStatusCode: number,
binectStatusText: string,
price?: number,
recipientAddress?: string
recipientAddress?: string,
errorMessage?: string
): Promise<DocumentProxy> {
const state = await loadQueue();
@@ -219,6 +220,7 @@ export async function syncFromServer(
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 = {
@@ -234,7 +236,8 @@ export async function syncFromServer(
binectStatusCode,
binectStatusText,
price,
recipientAddress
recipientAddress,
errorMessage
};
state.entries.unshift(proxy);
}