Next.js 14 drink collection tracker with AI-powered search, menu scanning, ratings, wishlist, sharing, and CSV backup/restore. Features: - Auth (credentials + OAuth ready) - Drink collection with ratings and reviews - AI search via Claude/OpenAI with search history - Menu photo scanning with AI extraction - Wishlist / Try Later system - Public sharing via slug URLs - CSV backup and restore (merge/replace modes) - Docker Compose for Postgres + MinIO + dev server Security: docker-compose files use env var interpolation instead of hardcoded secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
122 lines
2.9 KiB
TypeScript
122 lines
2.9 KiB
TypeScript
/**
|
|
* CSV utilities — RFC 4180 compliant, no external dependencies.
|
|
*/
|
|
|
|
/** Escape a field value for CSV (wrap in quotes if needed) */
|
|
function escapeField(value: string): string {
|
|
if (
|
|
value.includes(",") ||
|
|
value.includes('"') ||
|
|
value.includes("\n") ||
|
|
value.includes("\r")
|
|
) {
|
|
return `"${value.replace(/"/g, '""')}"`
|
|
}
|
|
return value
|
|
}
|
|
|
|
/** Convert an array of objects to a CSV string */
|
|
export function objectsToCsv(
|
|
headers: string[],
|
|
rows: Record<string, string>[]
|
|
): string {
|
|
const lines: string[] = []
|
|
|
|
// Header row
|
|
lines.push(headers.map(escapeField).join(","))
|
|
|
|
// Data rows
|
|
for (const row of rows) {
|
|
const fields = headers.map((h) => escapeField(row[h] ?? ""))
|
|
lines.push(fields.join(","))
|
|
}
|
|
|
|
return lines.join("\n")
|
|
}
|
|
|
|
/**
|
|
* Parse a CSV string into an array of objects keyed by header names.
|
|
* Uses a state-machine parser to correctly handle quoted fields.
|
|
*/
|
|
export function csvToObjects(csvText: string): Record<string, string>[] {
|
|
const rows = parseCsvRows(csvText)
|
|
if (rows.length < 1) return []
|
|
|
|
const headers = rows[0]
|
|
const result: Record<string, string>[] = []
|
|
|
|
for (let i = 1; i < rows.length; i++) {
|
|
const row = rows[i]
|
|
// Skip empty rows
|
|
if (row.length === 1 && row[0] === "") continue
|
|
|
|
const obj: Record<string, string> = {}
|
|
for (let j = 0; j < headers.length; j++) {
|
|
obj[headers[j]] = row[j] ?? ""
|
|
}
|
|
result.push(obj)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/** State-machine CSV parser that handles quoted fields correctly */
|
|
function parseCsvRows(text: string): string[][] {
|
|
const rows: string[][] = []
|
|
let currentRow: string[] = []
|
|
let currentField = ""
|
|
let inQuotes = false
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text[i]
|
|
const nextChar = text[i + 1]
|
|
|
|
if (inQuotes) {
|
|
if (char === '"') {
|
|
if (nextChar === '"') {
|
|
// Escaped quote
|
|
currentField += '"'
|
|
i++ // skip next quote
|
|
} else {
|
|
// End of quoted field
|
|
inQuotes = false
|
|
}
|
|
} else {
|
|
currentField += char
|
|
}
|
|
} else {
|
|
if (char === '"' && currentField === "") {
|
|
// Start of quoted field
|
|
inQuotes = true
|
|
} else if (char === ",") {
|
|
currentRow.push(currentField)
|
|
currentField = ""
|
|
} else if (char === "\n") {
|
|
currentRow.push(currentField)
|
|
currentField = ""
|
|
rows.push(currentRow)
|
|
currentRow = []
|
|
} else if (char === "\r") {
|
|
// Skip carriage return (handle \r\n)
|
|
if (nextChar === "\n") {
|
|
i++ // skip \n
|
|
}
|
|
currentRow.push(currentField)
|
|
currentField = ""
|
|
rows.push(currentRow)
|
|
currentRow = []
|
|
} else {
|
|
currentField += char
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle last field/row
|
|
if (currentField !== "" || currentRow.length > 0) {
|
|
currentRow.push(currentField)
|
|
rows.push(currentRow)
|
|
}
|
|
|
|
return rows
|
|
}
|