import { eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { categories, items } from "../../db/schema.ts"; type Db = typeof prodDb; // ─── CSV serialisation helpers ──────────────────────────────────────────────── function escapeField(value: string | number | null | undefined): string { if (value === null || value === undefined) return ""; const str = String(value); // Wrap in quotes if the field contains a comma, double-quote, or newline if ( str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r") ) { return `"${str.replace(/"/g, '""')}"`; } return str; } function buildCsvRow(fields: (string | number | null | undefined)[]): string { return fields.map(escapeField).join(","); } // ─── CSV parsing helpers ─────────────────────────────────────────────────────── function parseCsvLine(line: string): string[] { const fields: string[] = []; let i = 0; while (i <= line.length) { if (i === line.length) { // End of line — push empty trailing field only if we were expecting one // (handled by the loop condition above + break below) break; } if (line[i] === '"') { // Quoted field let field = ""; i++; // skip opening quote while (i < line.length) { if (line[i] === '"') { if (i + 1 < line.length && line[i + 1] === '"') { // Escaped quote field += '"'; i += 2; } else { // Closing quote i++; break; } } else { field += line[i]; i++; } } fields.push(field); // Skip comma separator if (i < line.length && line[i] === ",") i++; } else { // Unquoted field — read until comma or end of line const start = i; while (i < line.length && line[i] !== ",") i++; fields.push(line.slice(start, i)); if (i < line.length) i++; // skip comma } } return fields; } function parseCsv(content: string): { headers: string[]; rows: string[][] } { const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); const nonEmpty = lines.filter((l) => l.trim() !== ""); if (nonEmpty.length === 0) return { headers: [], rows: [] }; const headers = parseCsvLine(nonEmpty[0]); const rows = nonEmpty.slice(1).map(parseCsvLine); return { headers, rows }; } // ─── Export ─────────────────────────────────────────────────────────────────── export function exportItemsCsv(db: Db = prodDb): string { const rows = db .select({ name: items.name, quantity: items.quantity, weightGrams: items.weightGrams, priceCents: items.priceCents, categoryName: categories.name, notes: items.notes, productUrl: items.productUrl, }) .from(items) .innerJoin(categories, eq(items.categoryId, categories.id)) .all(); const header = "name,quantity,weightGrams,priceCents,category,notes,productUrl"; const dataLines = rows.map((row) => buildCsvRow([ row.name, row.quantity, row.weightGrams, row.priceCents, row.categoryName, row.notes, row.productUrl, ]), ); return [header, ...dataLines].join("\n"); } // ─── Import ─────────────────────────────────────────────────────────────────── export interface ImportResult { imported: number; createdCategories: string[]; errors: string[]; } export function importItemsCsv( db: Db = prodDb, csvContent: string, ): ImportResult { const { headers, rows } = parseCsv(csvContent); const result: ImportResult = { imported: 0, createdCategories: [], errors: [], }; if (headers.length === 0) return result; // Normalise header names for lookup (case-insensitive) const headerIndex = (name: string): number => headers.findIndex((h) => h.trim().toLowerCase() === name.toLowerCase()); const nameIdx = headerIndex("name"); const quantityIdx = headerIndex("quantity"); const weightIdx = headerIndex("weightGrams"); const priceIdx = headerIndex("priceCents"); const categoryIdx = headerIndex("category"); const notesIdx = headerIndex("notes"); const urlIdx = headerIndex("productUrl"); // Build a local category cache (name → id) seeded from the DB const categoryCache = new Map(); const existingCategories = db .select({ id: categories.id, name: categories.name }) .from(categories) .all(); for (const cat of existingCategories) { categoryCache.set(cat.name.toLowerCase(), cat.id); } for (let rowNum = 0; rowNum < rows.length; rowNum++) { const row = rows[rowNum]; const lineNum = rowNum + 2; // 1-based, +1 for header try { const name = nameIdx >= 0 ? row[nameIdx]?.trim() : undefined; if (!name) { result.errors.push(`Row ${lineNum}: missing required field "name"`); continue; } // Category resolution let categoryId: number; const rawCategory = categoryIdx >= 0 ? row[categoryIdx]?.trim() : ""; const categoryName = rawCategory || "Uncategorized"; const cacheKey = categoryName.toLowerCase(); if (categoryCache.has(cacheKey)) { categoryId = categoryCache.get(cacheKey)!; } else { // Create a new category const inserted = db .insert(categories) .values({ name: categoryName, icon: "package" }) .returning() .get(); categoryId = inserted.id; categoryCache.set(cacheKey, categoryId); result.createdCategories.push(categoryName); } // Parse optional numeric fields const rawQuantity = quantityIdx >= 0 ? row[quantityIdx]?.trim() : ""; const quantity = rawQuantity ? Number.parseInt(rawQuantity, 10) : 1; if (rawQuantity && Number.isNaN(quantity)) { result.errors.push( `Row ${lineNum}: invalid quantity "${rawQuantity}", skipping`, ); continue; } const rawWeight = weightIdx >= 0 ? row[weightIdx]?.trim() : ""; const weightGrams = rawWeight ? Number.parseFloat(rawWeight) : null; if (rawWeight && Number.isNaN(weightGrams as number)) { result.errors.push( `Row ${lineNum}: invalid weightGrams "${rawWeight}", skipping`, ); continue; } const rawPrice = priceIdx >= 0 ? row[priceIdx]?.trim() : ""; const priceCents = rawPrice ? Number.parseInt(rawPrice, 10) : null; if (rawPrice && Number.isNaN(priceCents as number)) { result.errors.push( `Row ${lineNum}: invalid priceCents "${rawPrice}", skipping`, ); continue; } const notes = notesIdx >= 0 ? row[notesIdx]?.trim() || null : null; const productUrl = urlIdx >= 0 ? row[urlIdx]?.trim() || null : null; db.insert(items) .values({ name, quantity, weightGrams, priceCents, categoryId, notes, productUrl, imageFilename: null, imageSourceUrl: null, }) .run(); result.imported++; } catch (err) { result.errors.push(`Row ${lineNum}: ${(err as Error).message}`); } } return result; }