feat: CSV-Import/-Export für Gerichte/Allergene/Zusatzstoffe, RSS-Fix für mehrere Essenszeiten
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>
This commit is contained in:
+98
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user