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>
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
import { query } from "@/lib/db";
|
||
import { loadMenuEntries } from "@/lib/db/menu-entries";
|
||
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
|
||
import { localized, SITE_TITLE } from "@/lib/types";
|
||
import type { Section } from "@/lib/types";
|
||
|
||
// RSS-Feed mit dem Speiseplan – ein Eintrag pro Tag mit Gerichten,
|
||
// damit man ihn in einem Feedreader abonnieren kann und täglich sieht,
|
||
// was es gibt. Immer auf Deutsch: Feedreader senden kein Sprach-Cookie,
|
||
// eine feste Sprache ist hier verständlicher als ein zufälliger Locale-Default.
|
||
export const dynamic = "force-dynamic";
|
||
|
||
function escapeXml(text: string): string {
|
||
return text.replace(/[&<>"']/g, (c) => {
|
||
switch (c) {
|
||
case "&":
|
||
return "&";
|
||
case "<":
|
||
return "<";
|
||
case ">":
|
||
return ">";
|
||
case '"':
|
||
return """;
|
||
default:
|
||
return "'";
|
||
}
|
||
});
|
||
}
|
||
|
||
function formatFeedDate(iso: string): string {
|
||
const d = new Date(`${iso}T00:00:00Z`);
|
||
return d.toLocaleDateString("de-DE", {
|
||
weekday: "long",
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
timeZone: "UTC",
|
||
});
|
||
}
|
||
|
||
export async function GET() {
|
||
const appUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||
const monday = mondayOf(todayISO());
|
||
// Aktuelle + nächste Woche, wie auch die öffentliche Speisekarte per
|
||
// Wochennavigation anzeigt.
|
||
const rangeEnd = addDays(monday, 11);
|
||
|
||
const [sections, entries] = await Promise.all([
|
||
query<Section>("select * from sections order by sort"),
|
||
loadMenuEntries(monday, rangeEnd),
|
||
]);
|
||
|
||
const days = Array.from(new Set(entries.map((e) => e.menu_date))).sort();
|
||
|
||
const items = days
|
||
.map((iso) => {
|
||
const dayContent = sections
|
||
.map((s) => {
|
||
const dishes = entriesFor(entries, iso, s.key);
|
||
if (dishes.length === 0) return "";
|
||
const list = dishes
|
||
.map(
|
||
(e) =>
|
||
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
|
||
)
|
||
.join("");
|
||
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
|
||
s.time_range
|
||
)})</strong></p><ul>${list}</ul>`;
|
||
})
|
||
.join("");
|
||
if (!dayContent) return "";
|
||
|
||
const [y, m, d] = iso.split("-").map(Number);
|
||
const pubDate = new Date(Date.UTC(y, m - 1, d, 6, 0, 0)).toUTCString();
|
||
|
||
return ` <item>
|
||
<title>${escapeXml(formatFeedDate(iso))}</title>
|
||
<link>${appUrl}/?woche=${mondayOf(iso)}</link>
|
||
<guid isPermaLink="false">${appUrl}/feed.xml#${iso}</guid>
|
||
<pubDate>${pubDate}</pubDate>
|
||
<description><![CDATA[${dayContent}]]></description>
|
||
<content:encoded><![CDATA[${dayContent}]]></content:encoded>
|
||
</item>`;
|
||
})
|
||
.join("\n");
|
||
|
||
// "description" gilt bei vielen Feed-Readern (Thunderbird, Outlook,
|
||
// etliche Apps) als reine Kurzfassung/Vorschau und wird eigenmächtig
|
||
// gekürzt – dadurch war z. B. bei mehreren Essenszeiten pro Tag oft nur
|
||
// die erste sichtbar. "content:encoded" (RSS-Content-Modul) gilt als
|
||
// vollständiger Artikeltext und wird von den meisten Readern
|
||
// ungekürzt angezeigt, wenn vorhanden – redundant zu description für
|
||
// Reader ohne Unterstützung des Moduls.
|
||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||
<channel>
|
||
<title>${escapeXml(SITE_TITLE)} – Speiseplan</title>
|
||
<link>${appUrl}</link>
|
||
<description>Täglicher Speiseplan der Kantine</description>
|
||
<language>de</language>
|
||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||
${items}
|
||
</channel>
|
||
</rss>
|
||
`;
|
||
|
||
return new Response(xml, {
|
||
headers: {
|
||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||
"Cache-Control": "public, max-age=1800",
|
||
},
|
||
});
|
||
}
|