generated from coulomb/repo-seed
- Separate "Has Errors" section for erroneous server documents (already uploaded but have validation errors) - "Ready to Upload" section now only shows truly local documents - Erroneous server docs show only "Delete from server" button (no retry) - Improved metadata display: show document ID for server docs, hide unknown file size - Clean up verbose debug logging Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
601 lines
16 KiB
TypeScript
601 lines
16 KiB
TypeScript
/**
|
|
* Binect API client
|
|
*
|
|
* This module wraps the @binect/js library to provide a simplified API
|
|
* for the Chrome extension. It delegates all API integration to the
|
|
* upstream library.
|
|
*/
|
|
|
|
import {
|
|
BinectClient,
|
|
BinectApiError,
|
|
BinectAuthError,
|
|
DocumentStatus,
|
|
isErroneous,
|
|
getErrors,
|
|
getStatusDescription,
|
|
type Document as BinectDocument,
|
|
type DocumentUploadOptions,
|
|
EnvelopeType,
|
|
FrankingType,
|
|
} from '@binect/js';
|
|
|
|
// Re-export types for backward compatibility
|
|
export interface Options {
|
|
simplex?: boolean; // if false, it's duplex
|
|
color?: boolean; // if false, it's black and white
|
|
envelope?: 'DINLANG' | 'C4';
|
|
franking?: 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING';
|
|
}
|
|
|
|
// Document type matching what popup.ts expects
|
|
export interface Document {
|
|
id: number;
|
|
filename: string;
|
|
numberOfPages?: number;
|
|
status: {
|
|
code: number;
|
|
text: string;
|
|
};
|
|
documentType: 'Letter' | 'SerialLetter';
|
|
letter?: {
|
|
letterType: 'LetterData' | 'Error';
|
|
letterData?: {
|
|
recipientAddress: string;
|
|
price: {
|
|
priceBeforeTax: number;
|
|
priceAfterTax: number;
|
|
unit: string;
|
|
taxInPercent: number;
|
|
};
|
|
international: boolean;
|
|
options: Options;
|
|
};
|
|
errors?: Array<{
|
|
code: number;
|
|
text: string;
|
|
blankText: string;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Custom error class for Binect API errors
|
|
* Wraps errors from @binect/js for backward compatibility
|
|
*/
|
|
export class BinectAPIError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public statusCode?: number,
|
|
public response?: unknown
|
|
) {
|
|
super(message);
|
|
this.name = 'BinectAPIError';
|
|
}
|
|
|
|
/**
|
|
* Create from a @binect/js error
|
|
*/
|
|
static fromBinectError(error: BinectApiError | BinectAuthError): BinectAPIError {
|
|
if (error instanceof BinectAuthError) {
|
|
return new BinectAPIError('Invalid credentials', 401);
|
|
}
|
|
return new BinectAPIError(
|
|
error.message,
|
|
error.status,
|
|
error.response
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert ArrayBuffer to base64 string (browser-compatible)
|
|
*/
|
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
/**
|
|
* Map local Options to DocumentUploadOptions
|
|
*/
|
|
function mapOptions(options?: Options): Partial<DocumentUploadOptions> {
|
|
if (!options) {
|
|
return {
|
|
simplex: false, // duplex by default
|
|
color: false, // black and white by default
|
|
};
|
|
}
|
|
|
|
const mapped: Partial<DocumentUploadOptions> = {
|
|
simplex: options.simplex ?? false,
|
|
color: options.color ?? false,
|
|
};
|
|
|
|
if (options.envelope) {
|
|
mapped.envelope = options.envelope === 'C4' ? EnvelopeType.C4 : EnvelopeType.DINLANG;
|
|
}
|
|
|
|
if (options.franking) {
|
|
switch (options.franking) {
|
|
case 'STANDARD_FRANKING':
|
|
mapped.franking = FrankingType.STANDARD_FRANKING;
|
|
break;
|
|
case 'DV_FRANKING':
|
|
mapped.franking = FrankingType.DV_FRANKING;
|
|
break;
|
|
default:
|
|
mapped.franking = FrankingType.UNSPECIFIED;
|
|
}
|
|
}
|
|
|
|
return mapped;
|
|
}
|
|
|
|
/**
|
|
* Convert BinectDocument to our Document interface
|
|
*/
|
|
function mapDocument(doc: BinectDocument): Document {
|
|
// Build letter data if present
|
|
let letterData: Document['letter'] = undefined;
|
|
|
|
if (doc.letter) {
|
|
const ld = doc.letter.letterData;
|
|
letterData = {
|
|
letterType: (doc.letter.letterType || 'LetterData') as 'LetterData' | 'Error',
|
|
letterData: ld ? {
|
|
recipientAddress: ld.recipientAddress || '',
|
|
price: ld.price || { priceBeforeTax: 0, priceAfterTax: 0, unit: 'EUROCENT', taxInPercent: 0 },
|
|
international: ld.international || false,
|
|
options: {
|
|
simplex: ld.options?.simplex,
|
|
color: ld.options?.color,
|
|
envelope: ld.options?.envelope as 'DINLANG' | 'C4' | undefined,
|
|
franking: ld.options?.franking as 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING' | undefined,
|
|
},
|
|
} : undefined,
|
|
errors: doc.letter.errors?.map(e => ({
|
|
code: parseInt(e.code, 10) || 0,
|
|
text: e.message,
|
|
blankText: e.message,
|
|
})),
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: doc.id,
|
|
filename: doc.filename || 'document.pdf',
|
|
numberOfPages: doc.numberOfPages,
|
|
status: {
|
|
code: doc.status.code,
|
|
text: doc.status.text,
|
|
},
|
|
documentType: (doc.documentType === 'SERIALLETTER' ? 'SerialLetter' : 'Letter') as 'Letter' | 'SerialLetter',
|
|
letter: letterData,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upload PDF to Binect API
|
|
*
|
|
* Uses the @binect/js library to upload the PDF.
|
|
*
|
|
* @param pdfData - PDF file as ArrayBuffer
|
|
* @param filename - Name of the PDF file
|
|
* @param username - Binect username for authentication
|
|
* @param password - Binect password for authentication
|
|
* @param options - Optional printing options (simplex/duplex, color/bw, etc.)
|
|
* @returns Document object with ID and status
|
|
*/
|
|
export async function uploadPDF(
|
|
pdfData: ArrayBuffer,
|
|
filename: string,
|
|
username: string,
|
|
password: string,
|
|
options?: Options
|
|
): Promise<Document> {
|
|
console.log('[Binect API] Uploading PDF to Binect via @binect/js...');
|
|
console.log('[Binect API] Filename:', filename);
|
|
console.log('[Binect API] PDF size:', pdfData.byteLength, 'bytes');
|
|
|
|
try {
|
|
// Create client with credentials
|
|
const client = new BinectClient({
|
|
username,
|
|
password,
|
|
});
|
|
|
|
// Convert PDF to base64
|
|
console.log('[Binect API] Converting PDF to base64...');
|
|
const base64Content = arrayBufferToBase64(pdfData);
|
|
console.log('[Binect API] Base64 length:', base64Content.length, 'characters');
|
|
|
|
// Map options
|
|
const uploadOptions = mapOptions(options);
|
|
console.log('[Binect API] Upload options:', uploadOptions);
|
|
|
|
// Upload document
|
|
const doc = await client.documents.upload({
|
|
content: base64Content,
|
|
filename,
|
|
...uploadOptions,
|
|
});
|
|
|
|
console.log('[Binect API] Upload successful!');
|
|
console.log('[Binect API] Document ID:', doc.id);
|
|
console.log('[Binect API] Document status:', doc.status);
|
|
|
|
// 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);
|
|
} catch (error) {
|
|
console.error('[Binect API] Upload error:', error);
|
|
|
|
// Handle @binect/js errors
|
|
if (error instanceof BinectAuthError) {
|
|
throw new BinectAPIError('Invalid credentials', 401);
|
|
}
|
|
|
|
if (error instanceof BinectApiError) {
|
|
// Map specific status codes
|
|
if (error.status === 400) {
|
|
throw new BinectAPIError(
|
|
'Invalid request. Please check the PDF format and size.',
|
|
400
|
|
);
|
|
}
|
|
if (error.status === 413) {
|
|
throw new BinectAPIError('File size exceeds limit (12 MB)', 413);
|
|
}
|
|
throw BinectAPIError.fromBinectError(error);
|
|
}
|
|
|
|
// Already a BinectAPIError
|
|
if (error instanceof BinectAPIError) {
|
|
throw error;
|
|
}
|
|
|
|
// Network or other errors
|
|
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
throw new BinectAPIError(
|
|
'Cannot reach Binect API. Please check your internet connection.'
|
|
);
|
|
}
|
|
|
|
throw new BinectAPIError(
|
|
`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test API connectivity by fetching account information
|
|
*
|
|
* @param username - Binect username
|
|
* @param password - Binect password
|
|
* @returns true if authentication successful, false otherwise
|
|
*/
|
|
export async function testConnection(username: string, password: string): Promise<boolean> {
|
|
console.log('[Binect API] Testing connection via @binect/js...');
|
|
|
|
try {
|
|
const client = new BinectClient({
|
|
username,
|
|
password,
|
|
});
|
|
|
|
// Attempt to get account info
|
|
await client.accounts.get();
|
|
|
|
console.log('[Binect API] Connection successful');
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof BinectAuthError) {
|
|
console.log('[Binect API] Authentication failed');
|
|
return false;
|
|
}
|
|
|
|
if (error instanceof BinectApiError) {
|
|
console.warn('[Binect API] API error:', error.message);
|
|
// If we get any other API error, credentials might still be valid
|
|
// but there's another issue
|
|
return false;
|
|
}
|
|
|
|
console.error('[Binect API] Connection test error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Document status information returned by getDocumentStatus
|
|
*/
|
|
export interface DocumentStatusInfo {
|
|
status: number;
|
|
statusText: string;
|
|
price?: number;
|
|
recipientAddress?: string;
|
|
errorDetails?: string; // Error details for erroneous documents
|
|
}
|
|
|
|
/**
|
|
* Ship a document (place order for production)
|
|
*
|
|
* This announces the document for delivery, transitioning it from
|
|
* SHIPPABLE to PRODUCTION_QUEUE.
|
|
*
|
|
* @param documentId - Binect document ID
|
|
* @param username - Binect username
|
|
* @param password - Binect password
|
|
* @returns Updated status info
|
|
*/
|
|
export async function shipDocument(
|
|
documentId: number,
|
|
username: string,
|
|
password: string
|
|
): Promise<DocumentStatusInfo> {
|
|
console.log('[Binect API] Shipping document:', documentId);
|
|
|
|
try {
|
|
const client = new BinectClient({
|
|
username,
|
|
password,
|
|
});
|
|
|
|
// Send the document for production
|
|
const sending = await client.sendings.send(String(documentId));
|
|
|
|
console.log('[Binect API] Document shipped successfully');
|
|
console.log('[Binect API] New status:', sending.status);
|
|
|
|
return {
|
|
status: sending.status,
|
|
statusText: getStatusDescription(sending.status),
|
|
price: sending.price,
|
|
};
|
|
} catch (error) {
|
|
console.error('[Binect API] Ship error:', error);
|
|
|
|
if (error instanceof BinectAuthError) {
|
|
throw new BinectAPIError('Invalid credentials', 401);
|
|
}
|
|
|
|
if (error instanceof BinectApiError) {
|
|
// Check for insufficient balance (error code 2330)
|
|
if (error.message.includes('2330') || error.message.includes('balance')) {
|
|
throw new BinectAPIError('Insufficient account balance', 402);
|
|
}
|
|
throw BinectAPIError.fromBinectError(error);
|
|
}
|
|
|
|
if (error instanceof BinectAPIError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new BinectAPIError(
|
|
`Failed to ship document: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current status of a document
|
|
*
|
|
* @param documentId - Binect document ID
|
|
* @param username - Binect username
|
|
* @param password - Binect password
|
|
* @returns Current status info
|
|
*/
|
|
export async function getDocumentStatus(
|
|
documentId: number,
|
|
username: string,
|
|
password: string
|
|
): Promise<DocumentStatusInfo> {
|
|
console.log('[Binect API] Getting document status:', documentId);
|
|
|
|
try {
|
|
const client = new BinectClient({
|
|
username,
|
|
password,
|
|
});
|
|
|
|
// Fetch document details
|
|
const doc = await client.documents.get(String(documentId));
|
|
|
|
console.log('[Binect API] Document status:', doc.status);
|
|
|
|
// Extract price and recipient if available
|
|
let price: number | undefined;
|
|
let recipientAddress: string | undefined;
|
|
let errorDetails: string | undefined;
|
|
|
|
if (doc.letter?.letterData) {
|
|
price = doc.letter.letterData.price?.priceAfterTax;
|
|
recipientAddress = doc.letter.letterData.recipientAddress;
|
|
}
|
|
|
|
// 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 || getStatusDescription(doc.status.code),
|
|
price,
|
|
recipientAddress,
|
|
errorDetails,
|
|
};
|
|
} catch (error) {
|
|
console.error('[Binect API] Get status error:', error);
|
|
|
|
if (error instanceof BinectAuthError) {
|
|
throw new BinectAPIError('Invalid credentials', 401);
|
|
}
|
|
|
|
if (error instanceof BinectApiError) {
|
|
// Check for 404 - document not found (deleted on server)
|
|
if (error.status === 404) {
|
|
throw new BinectAPIError('Document not found on server', 404);
|
|
}
|
|
throw BinectAPIError.fromBinectError(error);
|
|
}
|
|
|
|
if (error instanceof BinectAPIError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new BinectAPIError(
|
|
`Failed to get document status: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Server document info for sync
|
|
*/
|
|
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)
|
|
const shippableResponse = await client.documents.list();
|
|
const shippable = shippableResponse.items || [];
|
|
|
|
// Get erroneous documents (status 7)
|
|
const errorsResponse = await client.documents.listErrors();
|
|
const erroneous = errorsResponse.items || [];
|
|
|
|
// Combine and map to our format
|
|
const allDocs = [...shippable, ...erroneous];
|
|
|
|
console.log('[Binect API] Found', allDocs.length, 'documents on server (', shippable.length, 'shippable,', erroneous.length, 'erroneous)');
|
|
|
|
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 };
|