CSV-Import/-Export (Gerichte, Allergene, Zusatzstoffe): neue Export-/ Import-Routen unter app/api/*/export und */import, hand-geschriebener CSV-Parser/-Serializer (lib/csv.ts, keine neue Abhängigkeit), Upsert über Kürzel (Allergene/Zusatzstoffe) bzw. exakten Namen (Gerichte, mit Ambiguitäts-Check bei Namensdopplung). UI-Buttons in DishManager und CodeListManager. RSS-Feed: <content:encoded> zusätzlich zu <description> ausgeben, da mehrere Feed-Reader description als kürzbare Vorschau behandeln und dadurch bei mehreren Essenszeiten pro Tag nur die erste zeigten. Version 1.7.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
99 lines
2.5 KiB
TypeScript
99 lines
2.5 KiB
TypeScript
import "server-only";
|
||
|
||
// Minimaler RFC-4180-artiger CSV-Parser/-Serializer für Import/Export der
|
||
// Gerichte-/Allergen-/Zusatzstofflisten. Bewusst ohne npm-Abhängigkeit
|
||
// (Projekt-Stil: kein ORM, keine Validierungs-Library, minimale Deps) –
|
||
// deckt quoted Felder mit eingebetteten Kommas/Anführungszeichen/
|
||
// Zeilenumbrüchen ab, da Gerichtsnamen Kommas enthalten können.
|
||
|
||
export function parseCsv(text: string): string[][] {
|
||
const rows: string[][] = [];
|
||
let row: string[] = [];
|
||
let field = "";
|
||
let inQuotes = false;
|
||
let i = 0;
|
||
const len = text.length;
|
||
|
||
function pushField() {
|
||
row.push(field);
|
||
field = "";
|
||
}
|
||
function pushRow() {
|
||
pushField();
|
||
rows.push(row);
|
||
row = [];
|
||
}
|
||
|
||
while (i < len) {
|
||
const c = text[i];
|
||
if (inQuotes) {
|
||
if (c === '"') {
|
||
if (text[i + 1] === '"') {
|
||
field += '"';
|
||
i += 2;
|
||
continue;
|
||
}
|
||
inQuotes = false;
|
||
i++;
|
||
continue;
|
||
}
|
||
field += c;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (c === '"') {
|
||
inQuotes = true;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (c === ",") {
|
||
pushField();
|
||
i++;
|
||
continue;
|
||
}
|
||
if (c === "\r") {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (c === "\n") {
|
||
pushRow();
|
||
i++;
|
||
continue;
|
||
}
|
||
field += c;
|
||
i++;
|
||
}
|
||
// Letzte Zeile ohne abschließenden Zeilenumbruch noch anhängen.
|
||
if (field !== "" || row.length > 0) pushRow();
|
||
// Leere Zeilen überspringen (z. B. durch einen abschließenden Zeilenumbruch).
|
||
return rows.filter((r) => !(r.length === 1 && r[0].trim() === ""));
|
||
}
|
||
|
||
function csvField(value: string): string {
|
||
if (/[",\r\n]/.test(value)) {
|
||
return `"${value.replace(/"/g, '""')}"`;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function toCsv(rows: string[][]): string {
|
||
return rows.map((row) => row.map(csvField).join(",")).join("\r\n") + "\r\n";
|
||
}
|
||
|
||
/** Kopfzeile in Kleinbuchstaben, damit der Import tolerant gegenüber Groß-/
|
||
* Kleinschreibung und Spaltenreihenfolge ist. */
|
||
export function parseCsvWithHeader(text: string): { header: string[]; rows: string[][] } {
|
||
const table = parseCsv(text);
|
||
if (table.length === 0) return { header: [], rows: [] };
|
||
const [header, ...rows] = table;
|
||
return { header: header.map((h) => h.trim().toLowerCase()), rows };
|
||
}
|
||
|
||
/** Codes einer Zelle wie "A;G;2" in einzelne, getrimmte Kürzel zerlegen. */
|
||
export function splitCodes(cell: string | undefined): string[] {
|
||
return (cell ?? "")
|
||
.split(";")
|
||
.map((c) => c.trim())
|
||
.filter(Boolean);
|
||
}
|