/** * Client-side bulk import of the shop `artikeltabelle.csv` export format. * * The CSV is semicolon-separated (often wrapped in `="…"` from Excel export) * with columns: * ArtNr; Name; Preis; USt; Pfand; Hersteller; Kategorie; Barcode; V/K * * All work is done through the existing dashback API: * - categories → POST /api/category * - brands → PUT /api/brand * - articles → POST /api/article (+ price / brand / ean / categories PUTs) */ /** Parse one `="value"` / `"value"` / plain Excel-style CSV cell. */ export function stripCell(value) { if (value == null) return ''; let text = String(value).trim(); if (text.startsWith('=')) text = text.slice(1); text = text.trim(); if ( text.length >= 2 && text.startsWith('"') && text.endsWith('"') ) { text = text.slice(1, -1).replace(/""/g, '"'); } return text.trim(); } /** * Split a semicolon CSV line into cells, honoring double-quoted fields. * (Cell values are still raw; use `stripCell` for the Excel `="…"` wrapper.) */ export function splitCsvLine(line) { const cells = []; let cur = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = false; } } else { cur += ch; } } else if (ch === '"') { inQuotes = true; } else if (ch === ';') { cells.push(cur); cur = ''; } else { cur += ch; } } cells.push(cur); return cells.map((c) => stripCell(c)); } /** * Parse a CSV string into row objects keyed by the header names. * Skips blank lines and the optional `sep=;` hint line. */ export function parseArtikelCsv(csvText) { const lines = String(csvText ?? '') .replace(/\r\n/g, '\n') .split('\n') .map((l) => l.trim()) .filter((l) => l !== '' && l !== 'sep=;'); if (lines.length === 0) return []; const headers = splitCsvLine(lines[0]); const rows = []; for (let i = 1; i < lines.length; i++) { const cells = splitCsvLine(lines[i]); const row = {}; headers.forEach((header, idx) => { row[header] = cells[idx] ?? ''; }); if (row.Name) rows.push(row); } return rows; } /** * Resolve a de-DE (or plain) number from a CSV field. * Accepts `2,00`, `1.234,56`, and plain `2.00`. */ export function parseCsvNumber(value) { const text = String(value ?? '').trim(); if (text === '') return null; let normalized = text.replace(/\s/g, ''); if (normalized.includes(',')) { normalized = normalized.replace(/\./g, '').replace(',', '.'); } const parsed = Number(normalized); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } /** EAN from a CSV cell (digits only), or null. */ export function parseCsvEan(value) { const digits = String(value ?? '').replace(/\D/g, ''); return digits || null; } /** * Split a `Kategorie` path (`Haupt | Grow | Erde`) into trimmed segments. * Empty / falsy path → []. */ export function parseCategoryPath(value) { return String(value ?? '') .split('|') .map((s) => s.trim()) .filter(Boolean); } /** * Column index → name. Uses a forgiving match so the exact header text * (with or without `="` wrapper, case) doesn't matter. */ function headerName(name) { return stripCell(name).toLowerCase().trim(); } export const COLUMNS = { artNr: 'artnr', name: 'name', price: 'preis', tax: 'ust', deposit: 'pfand', brand: 'hersteller', category: 'kategorie', ean: 'barcode', vk: 'v/k', }; /** * Normalize one raw CSV row object into the typed import record. * Unknown/missing columns become null/[] so downstream code can skip them. */ export function normalizeRow(row, headers = []) { const byName = new Map(); (headers.length ? headers : Object.keys(row)).forEach((h) => { byName.set(headerName(h), row[h] ?? row[stripCell(h)] ?? ''); }); const get = (key) => byName.get(COLUMNS[key]) ?? byName.get(key) ?? ''; const artNr = get('artNr'); const name = get('name'); const price = parseCsvNumber(get('price')); const tax = parseCsvNumber(get('tax')); const brand = get('brand'); const categoryPath = parseCategoryPath(get('category')); const ean = parseCsvEan(get('ean')); const vk = get('vk'); return { artNr, name, price, tax, deposit: parseCsvNumber(get('deposit')), brand, categoryPath, ean, vk, }; } /** * Parse a full CSV string into typed import records. * @param {string} csvText * @returns {Array>} */ export function parseImportCsv(csvText) { const rawRows = parseArtikelCsv(csvText); const headers = rawRows.length ? Object.keys(rawRows[0]) : []; return rawRows.map((row) => normalizeRow(row, headers)); }