feat: add CSV import/export for gear collection
Some checks failed
CI / ci (pull_request) Failing after 22s
CI / e2e (pull_request) Has been skipped

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>
This commit is contained in:
2026-04-03 18:12:07 +02:00
parent 8c1fe47a99
commit 15f146ee89
6 changed files with 645 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateItem } from "../../shared/types";
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
import { apiDelete, apiGet, apiPost, apiPut, apiUpload } from "../lib/api";
interface Item {
id: number;
@@ -96,3 +96,27 @@ export function useDuplicateItem() {
},
});
}
export function useExportItems() {
return function exportItems() {
window.location.href = "/api/items/export";
};
}
interface ImportResult {
imported: number;
createdCategories: string[];
errors: string[];
}
export function useImportItems() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) =>
apiUpload<ImportResult>("/api/items/import", file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] });
queryClient.invalidateQueries({ queryKey: ["totals"] });
},
});
}

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { useRef, useState } from "react";
import {
useApiKeys,
useAuth,
@@ -8,6 +8,7 @@ import {
useDeleteApiKey,
} from "../hooks/useAuth";
import { useCurrency } from "../hooks/useCurrency";
import { useExportItems, useImportItems } from "../hooks/useItems";
import { useUpdateSetting } from "../hooks/useSettings";
import { useWeightUnit } from "../hooks/useWeightUnit";
import type { Currency, WeightUnit } from "../lib/formatters";
@@ -172,6 +173,95 @@ function ApiKeySection() {
);
}
function ImportExportSection() {
const exportItems = useExportItems();
const importItems = useImportItems();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importResult, setImportResult] = useState<{
imported: number;
createdCategories: string[];
errors: string[];
} | null>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setImportResult(null);
try {
const result = await importItems.mutateAsync(file);
setImportResult(result);
} catch (err) {
setImportResult({
imported: 0,
createdCategories: [],
errors: [(err as Error).message],
});
}
// Reset so the same file can be imported again if needed
if (fileInputRef.current) fileInputRef.current.value = "";
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">Import / Export</h3>
<p className="text-xs text-gray-500">
Export your gear collection as a CSV file, or import items from a CSV.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={exportItems}
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors"
>
Export CSV
</button>
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
{importItems.isPending ? "Importing..." : "Import CSV"}
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
disabled={importItems.isPending}
onChange={handleFileChange}
/>
</label>
</div>
{importResult && (
<div
className={`rounded-lg p-3 text-xs space-y-1 ${
importResult.errors.length > 0 && importResult.imported === 0
? "bg-red-50 border border-red-200 text-red-700"
: "bg-green-50 border border-green-200 text-green-700"
}`}
>
{importResult.imported > 0 && (
<p className="font-medium">
{importResult.imported} item
{importResult.imported !== 1 ? "s" : ""} imported.
</p>
)}
{importResult.createdCategories.length > 0 && (
<p>New categories: {importResult.createdCategories.join(", ")}</p>
)}
{importResult.errors.map((err, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
<p key={i} className="text-red-600">
{err}
</p>
))}
{importResult.imported === 0 && importResult.errors.length === 0 && (
<p>No items found in the CSV.</p>
)}
</div>
)}
</div>
);
}
function SettingsPage() {
const unit = useWeightUnit();
const currency = useCurrency();
@@ -255,6 +345,10 @@ function SettingsPage() {
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ImportExportSection />
</div>
{auth?.user && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6 mt-4">
<ChangePasswordSection />

View File

@@ -4,6 +4,7 @@ import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import { exportItemsCsv, importItemsCsv } from "../services/csv.service.ts";
import {
createItem,
deleteItem,
@@ -17,6 +18,27 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/export", (c) => {
const db = c.get("db");
const csv = exportItemsCsv(db);
c.header("Content-Type", "text/csv");
c.header("Content-Disposition", 'attachment; filename="gearbox-export.csv"');
return c.body(csv);
});
app.post("/import", async (c) => {
const db = c.get("db");
const body = await c.req.parseBody();
// Accept either "file" (direct) or "image" (via apiUpload helper)
const file = body.file ?? body.image;
if (!file || typeof file === "string") {
return c.json({ error: "No CSV file provided" }, 400);
}
const content = await file.text();
const result = importItemsCsv(db, content);
return c.json(result);
});
app.get("/", (c) => {
const db = c.get("db");
const items = getAllItems(db);

View File

@@ -0,0 +1,246 @@
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;
}