Adds export (GET /api/items/export) and import (POST /api/items/import) routes backed by a pure csv.service with no external deps, plus useExportItems/useImportItems hooks and an Import/Export section in the Settings page. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
7.1 KiB
TypeScript
247 lines
7.1 KiB
TypeScript
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<string, number>();
|
|
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;
|
|
}
|