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);
|
||||
}
|
||||
+45
-1
@@ -1,5 +1,5 @@
|
||||
import "server-only";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { query, queryOne, withTransaction } from "@/lib/db";
|
||||
import type { CodeItem } from "@/lib/types";
|
||||
|
||||
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
|
||||
@@ -39,3 +39,47 @@ export async function updateCode(
|
||||
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
|
||||
await query(`delete from ${table} where code = $1`, [code]);
|
||||
}
|
||||
|
||||
export interface CodeImportRow {
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string | null;
|
||||
name_pl: string | null;
|
||||
}
|
||||
|
||||
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
|
||||
* aktualisiert – ein doppelter Code innerhalb derselben Datei
|
||||
* aktualisiert also einfach zweimal statt einen Fehler zu werfen. */
|
||||
export async function importCodes(
|
||||
table: CodeTable,
|
||||
rows: CodeImportRow[]
|
||||
): Promise<{ created: number; updated: number }> {
|
||||
return withTransaction(async (client) => {
|
||||
const existing = await client.query<{ code: string }>(`select code from ${table}`);
|
||||
const known = new Set(existing.rows.map((r) => r.code));
|
||||
const maxRow = await client.query<{ sort: number }>(
|
||||
`select coalesce(max(sort), 0) as sort from ${table}`
|
||||
);
|
||||
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (known.has(row.code)) {
|
||||
await client.query(
|
||||
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`,
|
||||
[row.name, row.name_en, row.name_pl, row.code]
|
||||
);
|
||||
updated++;
|
||||
} else {
|
||||
await client.query(
|
||||
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
|
||||
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
|
||||
);
|
||||
known.add(row.code);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
return { created, updated };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +106,13 @@ const de = {
|
||||
loadError: "Laden fehlgeschlagen: {message}",
|
||||
saveError: "Speichern fehlgeschlagen: {message}",
|
||||
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||
exportButton: "Exportieren",
|
||||
importButton: "Importieren",
|
||||
exportError: "Export fehlgeschlagen: {message}",
|
||||
importError: "Import fehlgeschlagen: {message}",
|
||||
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
|
||||
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
|
||||
importSkippedRow: "Zeile {row}: {reason}",
|
||||
},
|
||||
codeList: {
|
||||
allergensTitle: "Allergene",
|
||||
@@ -119,6 +126,13 @@ const de = {
|
||||
loadError: "Laden fehlgeschlagen: {message}",
|
||||
saveError: "Speichern fehlgeschlagen: {message}",
|
||||
deleteError: "Löschen fehlgeschlagen: {message}",
|
||||
exportButton: "Exportieren",
|
||||
importButton: "Importieren",
|
||||
exportError: "Export fehlgeschlagen: {message}",
|
||||
importError: "Import fehlgeschlagen: {message}",
|
||||
importSuccess: "{created} neu angelegt, {updated} aktualisiert.",
|
||||
importSkipped: "{count} Zeile(n) übersprungen: {reasons}",
|
||||
importSkippedRow: "Zeile {row}: {reason}",
|
||||
},
|
||||
sectionManager: {
|
||||
title: "Essenszeiten",
|
||||
@@ -362,6 +376,13 @@ const en: Dict = {
|
||||
loadError: "Loading failed: {message}",
|
||||
saveError: "Saving failed: {message}",
|
||||
deleteError: "Deleting failed: {message}",
|
||||
exportButton: "Export",
|
||||
importButton: "Import",
|
||||
exportError: "Export failed: {message}",
|
||||
importError: "Import failed: {message}",
|
||||
importSuccess: "{created} created, {updated} updated.",
|
||||
importSkipped: "{count} row(s) skipped: {reasons}",
|
||||
importSkippedRow: "Row {row}: {reason}",
|
||||
},
|
||||
codeList: {
|
||||
allergensTitle: "Allergens",
|
||||
@@ -375,6 +396,13 @@ const en: Dict = {
|
||||
loadError: "Loading failed: {message}",
|
||||
saveError: "Saving failed: {message}",
|
||||
deleteError: "Deleting failed: {message}",
|
||||
exportButton: "Export",
|
||||
importButton: "Import",
|
||||
exportError: "Export failed: {message}",
|
||||
importError: "Import failed: {message}",
|
||||
importSuccess: "{created} created, {updated} updated.",
|
||||
importSkipped: "{count} row(s) skipped: {reasons}",
|
||||
importSkippedRow: "Row {row}: {reason}",
|
||||
},
|
||||
sectionManager: {
|
||||
title: "Meal times",
|
||||
@@ -616,6 +644,13 @@ const pl: Dict = {
|
||||
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||
exportButton: "Eksportuj",
|
||||
importButton: "Importuj",
|
||||
exportError: "Eksport nie powiódł się: {message}",
|
||||
importError: "Import nie powiódł się: {message}",
|
||||
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
|
||||
importSkipped: "Pominięto wierszy: {count}: {reasons}",
|
||||
importSkippedRow: "Wiersz {row}: {reason}",
|
||||
},
|
||||
codeList: {
|
||||
allergensTitle: "Alergeny",
|
||||
@@ -629,6 +664,13 @@ const pl: Dict = {
|
||||
loadError: "Wczytywanie nie powiodło się: {message}",
|
||||
saveError: "Zapisywanie nie powiodło się: {message}",
|
||||
deleteError: "Usuwanie nie powiodło się: {message}",
|
||||
exportButton: "Eksportuj",
|
||||
importButton: "Importuj",
|
||||
exportError: "Eksport nie powiódł się: {message}",
|
||||
importError: "Import nie powiódł się: {message}",
|
||||
importSuccess: "Utworzono: {created}, zaktualizowano: {updated}.",
|
||||
importSkipped: "Pominięto wierszy: {count}: {reasons}",
|
||||
importSkippedRow: "Wiersz {row}: {reason}",
|
||||
},
|
||||
sectionManager: {
|
||||
title: "Pory posiłków",
|
||||
|
||||
Reference in New Issue
Block a user