import { http } from './http.js'; export const company = { /** GET /api/company → { companies: [...] } */ // #region GET /api/company — list list() { return http('/api/company'); }, // #endregion /** Uses GET /api/company until a dedicated endpoint exists. */ // #region GET /api/company — getById async getById(companyId) { const data = await http('/api/company'); const found = data.companies?.find((item) => item.id === companyId); if (!found) throw new Error('Unternehmen nicht gefunden.'); return { company: found }; }, // #endregion // #region POST /api/company — create create({ name, iso639, taxrate, reducedtaxrate }) { return http('/api/company', { method: 'POST', body: { name, iso639, taxrate, reducedtaxrate }, }); }, // #endregion /** * PUT /api/company/:companyId * Body: { name, iso639, taxrate, reducedtaxrate } */ // #region PUT /api/company/:companyId — update update(companyId, { name, iso639, taxrate, reducedtaxrate }) { return http(`/api/company/${companyId}`, { method: 'PUT', body: { name, iso639, taxrate, reducedtaxrate }, }); }, // #endregion // #region PUT /api/auth/selected-company — select select(companyId) { return http('/api/auth/selected-company', { method: 'PUT', body: { companyId }, }); }, // #endregion /** * POST /api/company/import * Uploads and streams catalog import (.zip) archive via Server-Sent Events. * Tracks client upload progress (bytes/percentage) and processes incoming SSE events in real-time. * * @param {File|Blob|null} file * @param {object} [callbacks] * @param {(progress: { loaded: number, total: number, percent: number }) => void} [callbacks.onUploadProgress] * @param {(message: string) => void} [callbacks.onStatus] * @param {(progress: { categoriestotal: number, categoriesdone: number, articlestotal: number, articlesdone: number, imagestotal: number, imagesdone: number, receiptstotal: number, receiptsdone: number }) => void} [callbacks.onProgress] Counter-based progress snapshot across all phases. * @param {(images: string[]) => void} [callbacks.onImages] Batched `images` events (~every 100ms) carrying Base62 SkeleHash strings. * @param {(stats: object) => void} [callbacks.onDone] * @param {(error: string) => void} [callbacks.onError] * @param {object} [options] * @param {AbortSignal} [options.signal] */ // #region POST /api/company/import — importCatalog importCatalog(file, callbacks = {}, { signal } = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/company/import'); xhr.withCredentials = true; xhr.setRequestHeader('Accept', 'text/event-stream'); if (signal) { if (signal.aborted) { return reject(new DOMException('Aborted', 'AbortError')); } signal.addEventListener('abort', () => { xhr.abort(); reject(new DOMException('Aborted', 'AbortError')); }); } // Track request body upload progress if (file && xhr.upload) { xhr.upload.onprogress = (event) => { if (event.lengthComputable && event.total > 0) { const percent = Math.min(100, Math.round((event.loaded / event.total) * 100)); callbacks.onUploadProgress?.({ loaded: event.loaded, total: event.total, percent, }); } }; } // Stream and parse incoming SSE chunks as the server processes the archive let processedIndex = 0; let buffer = ''; const processChunks = () => { const text = xhr.responseText || ''; if (text.length <= processedIndex) return; const newChunk = text.slice(processedIndex); processedIndex = text.length; buffer += newChunk; const messages = buffer.split('\n\n'); buffer = messages.pop() || ''; for (const msg of messages) { if (!msg.trim()) continue; const eventMatch = msg.match(/^event:\s*(\w+)/m); const dataMatch = msg.match(/^data:\s*(.+)$/m); const eventType = eventMatch ? eventMatch[1] : 'message'; let eventData = null; if (dataMatch) { try { eventData = JSON.parse(dataMatch[1]); } catch { eventData = dataMatch[1]; } } switch (eventType) { case 'status': callbacks.onStatus?.(eventData?.message); break; case 'progress': callbacks.onProgress?.(eventData); break; case 'images': { // Payload is a plain array of Base62 SkeleHash strings; // also tolerate the { images: [...] } wrapper shape. const list = Array.isArray(eventData) ? eventData : Array.isArray(eventData?.images) ? eventData.images : []; if (list.length) callbacks.onImages?.(list); break; } case 'image': // Legacy: single image event (pre-batching). if (eventData) callbacks.onImages?.([eventData]); break; case 'done': callbacks.onDone?.(eventData?.stats ?? eventData); break; case 'error': callbacks.onError?.(eventData?.message || 'Fehler beim Importieren'); break; } } }; xhr.onprogress = processChunks; xhr.onload = () => { processChunks(); if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { let errMsg = `Upload fehlgeschlagen (HTTP ${xhr.status})`; try { const parsed = JSON.parse(xhr.responseText); errMsg = parsed.error || parsed.message || errMsg; } catch { // ignore } reject(new Error(errMsg)); } }; xhr.onerror = () => { reject(new Error('Netzwerkfehler beim Upload der Datei')); }; xhr.onabort = () => { reject(new DOMException('Aborted', 'AbortError')); }; const formData = new FormData(); if (file) { formData.append('file', file); } xhr.send(formData); }); }, // #endregion /** * GET /api/company/export * Packages company catalog and streams export (.zip) archive via Server-Sent Events with base64 chunks. * * @param {object} [callbacks] * @param {(message: string) => void} [callbacks.onStatus] * @param {(progress: { stage: string, percent?: number, [key: string]: any }) => void} [callbacks.onProgress] * @param {(chunk: string) => void} [callbacks.onChunk] * @param {(donePayload: { ok: boolean, filename: string, size: number, totalChunks: number, stats?: object }) => void} [callbacks.onDone] * @param {(error: string) => void} [callbacks.onError] * @param {object} [options] * @param {AbortSignal} [options.signal] * @param {boolean} [options.triggerDownload=true] Automatically trigger browser download when complete * @returns {Promise<{ blob: Blob, filename: string, size: number, totalChunks: number, stats?: object }>} */ // #region GET /api/company/export — exportCatalog async exportCatalog(callbacks = {}, { signal, triggerDownload = true } = {}) { const response = await fetch('/api/company/export', { method: 'GET', headers: { Accept: 'text/event-stream' }, credentials: 'include', signal, }); if (!response.ok) { let errMessage = 'Export failed'; try { const error = await response.json(); errMessage = error.error || error.message || errMessage; } catch { // ignore JSON parse error } throw new Error(errMessage); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const base64Chunks = []; let filename = 'company-export.zip'; let donePayload = null; try { while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; // keep trailing incomplete message for (const part of parts) { const lines = part.split('\n'); let event = 'message'; let data = ''; for (const line of lines) { if (line.startsWith('event: ')) event = line.slice(7).trim(); else if (line.startsWith('data: ')) data = line.slice(6); } if (event === 'status') { try { const payload = JSON.parse(data); callbacks.onStatus?.(payload.message || data); } catch { callbacks.onStatus?.(data); } } else if (event === 'progress') { try { const payload = JSON.parse(data); callbacks.onProgress?.(payload); } catch { // ignore } } else if (event === 'chunk') { base64Chunks.push(data); callbacks.onChunk?.(data); } else if (event === 'done') { try { const payload = JSON.parse(data); donePayload = payload; if (payload.filename) filename = payload.filename; callbacks.onDone?.(payload); } catch { // ignore } } else if (event === 'error') { let errorMsg = 'Export error'; try { const payload = JSON.parse(data); errorMsg = payload.message || errorMsg; } catch { errorMsg = data || errorMsg; } callbacks.onError?.(errorMsg); throw new Error(errorMsg); } } } } finally { reader.releaseLock(); } // Reconstruct Blob from base64 chunks const byteArrays = base64Chunks.map((b64) => { const binaryStr = atob(b64); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i); } return bytes; }); const blob = new Blob(byteArrays, { type: 'application/zip' }); if (triggerDownload) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } return { blob, filename, size: blob.size, totalChunks: base64Chunks.length, stats: donePayload?.stats, }; }, // #endregion }; export const exportCompanyCatalog = (callbacks, options) => company.exportCatalog(callbacks, options);