4376c40369
Die AI-Rewrite-Regeln (DEFAULT_AI_RULES) waren im Block hardcodiert und hätten pro Generator (40 Instanzen) dupliziert werden müssen, um im CMS editierbar zu sein. Stattdessen ein Singleton stellungnahme_config (Slug "default") mit Feld aiRules: der Resolver lädt es einmal und hängt block.aiRules an alle Generator-Blöcke. Komponente seedet daraus, mit DEFAULT_AI_RULES als Code-Fallback; Reset-Button setzt auf den CMS-Wert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
887 lines
27 KiB
TypeScript
887 lines
27 KiB
TypeScript
import type { PostEntry } from "./cms";
|
|
import {
|
|
getPosts,
|
|
getPostBySlug,
|
|
getTags,
|
|
getTextFragmentBySlug,
|
|
getTextFragmentsByTag,
|
|
getEntryBySlug,
|
|
getStellungnahmeConfig,
|
|
getCalendarBySlug,
|
|
getCalendarItemBySlug,
|
|
getContacts,
|
|
getCalendarItems,
|
|
} from "./cms";
|
|
import { STELLUNGNAHME_CONFIG_SLUG } from "./constants";
|
|
import type { RowContentLayout } from "./block-types";
|
|
import type { CalendarItemData } from "./block-types";
|
|
import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image";
|
|
|
|
/** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
|
|
export type TagMeta = {
|
|
name: string;
|
|
icon?: string;
|
|
color?: string;
|
|
};
|
|
|
|
/** Slug → TagMeta aus der Tag-API. Für Auflösung von post.postTag. */
|
|
export async function getTagsMap(): Promise<Map<string, TagMeta>> {
|
|
const tags = await getTags();
|
|
const m = new Map<string, TagMeta>();
|
|
for (const t of tags) {
|
|
const slug =
|
|
(t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? "";
|
|
if (!slug) continue;
|
|
const name = (t as { name?: string }).name?.trim() ?? slug;
|
|
const icon = (t as { icon?: string }).icon?.trim();
|
|
const color = (t as { color?: string }).color?.trim();
|
|
m.set(slug, {
|
|
name,
|
|
...(icon ? { icon } : {}),
|
|
...(color ? { color } : {}),
|
|
});
|
|
}
|
|
return m;
|
|
}
|
|
|
|
export type ResolvedPostTag = {
|
|
_slug: string;
|
|
name: string;
|
|
icon?: string;
|
|
color?: string;
|
|
};
|
|
|
|
/** Setzt post.postTag auf { _slug, name, icon?, color? }[]; Metadaten aus slugToMeta bzw. bereits aufgelösten Refs. */
|
|
export function resolvePostTagsInPost(
|
|
post: PostEntry,
|
|
slugToMeta: Map<string, TagMeta>,
|
|
): void {
|
|
const raw = post.postTag ?? [];
|
|
if (!Array.isArray(raw)) return;
|
|
(post as { postTag?: ResolvedPostTag[] }).postTag = raw.map((t) => {
|
|
if (typeof t === "string") {
|
|
const meta = slugToMeta.get(t);
|
|
return {
|
|
_slug: t,
|
|
name: meta?.name ?? t,
|
|
...(meta?.icon ? { icon: meta.icon } : {}),
|
|
...(meta?.color ? { color: meta.color } : {}),
|
|
};
|
|
}
|
|
const o = t as {
|
|
_slug?: string;
|
|
name?: string;
|
|
icon?: string;
|
|
color?: string;
|
|
};
|
|
const slug = o._slug ?? o?.name ?? "";
|
|
const meta = slug ? slugToMeta.get(slug) : undefined;
|
|
const icon = (o.icon ?? meta?.icon)?.trim();
|
|
const color = (o.color ?? meta?.color)?.trim();
|
|
return {
|
|
_slug: slug,
|
|
name: o?.name ?? meta?.name ?? slug,
|
|
...(icon ? { icon } : {}),
|
|
...(color ? { color } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
const POSTS_PER_PAGE = 10;
|
|
|
|
export function getPostsPerPage(): number {
|
|
return POSTS_PER_PAGE;
|
|
}
|
|
|
|
/** Filtert Posts mit hideFromListing: true aus Übersichten heraus. */
|
|
export function filterHiddenPosts(posts: PostEntry[]): PostEntry[] {
|
|
return posts.filter(
|
|
(p) => !(p as PostEntry & { hideFromListing?: boolean }).hideFromListing,
|
|
);
|
|
}
|
|
|
|
/** Sortiert Posts nach Datum (neueste zuerst). */
|
|
export function sortPostsByDate(posts: PostEntry[]): PostEntry[] {
|
|
return [...posts].sort((a, b) => {
|
|
const da = a.created ?? "";
|
|
const db = b.created ?? "";
|
|
if (!da) return 1;
|
|
if (!db) return -1;
|
|
return new Date(db).getTime() - new Date(da).getTime();
|
|
});
|
|
}
|
|
|
|
/** Filtert Posts nach Tag-Slug (post.postTag enthält den Slug). */
|
|
export function filterPostsByTag(
|
|
posts: PostEntry[],
|
|
tagSlug: string | null,
|
|
): PostEntry[] {
|
|
if (!tagSlug) return posts;
|
|
return posts.filter((p) => {
|
|
const tags = p.postTag ?? [];
|
|
return tags.some((t) => {
|
|
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
|
|
return slug === tagSlug;
|
|
});
|
|
});
|
|
}
|
|
|
|
export type PostEventLocation = {
|
|
text?: string;
|
|
lat?: number;
|
|
lng?: number;
|
|
};
|
|
|
|
export type PostEventInfo = {
|
|
isEvent: boolean;
|
|
eventDate: string | null;
|
|
eventLocation: PostEventLocation | null;
|
|
};
|
|
|
|
/**
|
|
* Liest event-Daten aus dem aufgelösten calendarItem-Reference des Posts.
|
|
* Voraussetzung: post mit resolve=["all"] oder explizit "calendarItem" geladen,
|
|
* sodass `post.calendarItem` als Objekt mit terminZeit/location vorliegt.
|
|
*/
|
|
export function getPostEventInfo(
|
|
post: PostEntry | null | undefined,
|
|
): PostEventInfo {
|
|
const ci = (post as { calendarItem?: unknown } | null | undefined)?.calendarItem;
|
|
if (!ci || typeof ci !== "object") {
|
|
return { isEvent: false, eventDate: null, eventLocation: null };
|
|
}
|
|
const obj = ci as {
|
|
terminZeit?: string;
|
|
location?: PostEventLocation;
|
|
};
|
|
if (!obj.terminZeit) {
|
|
return { isEvent: false, eventDate: null, eventLocation: null };
|
|
}
|
|
return {
|
|
isEvent: true,
|
|
eventDate: obj.terminZeit,
|
|
eventLocation: obj.location ?? null,
|
|
};
|
|
}
|
|
|
|
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
|
|
export function getPostImageUrl(
|
|
postImage: PostEntry["postImage"],
|
|
): string | null {
|
|
return getPostImageField(postImage)?.url ?? null;
|
|
}
|
|
|
|
/**
|
|
* URL + Focal + Alt aus post.postImage. Unterstützt RustyCMS-image-Typ
|
|
* und legacy file.url / fields.file.url.
|
|
*/
|
|
export function getPostImageField(
|
|
postImage: PostEntry["postImage"],
|
|
): CmsImageField | null {
|
|
if (!postImage) return null;
|
|
if (typeof postImage === "string") return extractCmsImageField(postImage);
|
|
const field = extractCmsImageField(postImage);
|
|
if (field) return field;
|
|
const obj = postImage as {
|
|
fields?: { file?: { url?: string } };
|
|
};
|
|
const legacy = obj?.fields?.file?.url;
|
|
if (typeof legacy === "string" && legacy.trim()) {
|
|
return { url: legacy.startsWith("//") ? `https:${legacy}` : legacy };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */
|
|
export function filterPostsByTagSlugs(
|
|
posts: PostEntry[],
|
|
tagSlugs: string[],
|
|
): PostEntry[] {
|
|
if (!tagSlugs?.length) return posts;
|
|
return posts.filter((p) => {
|
|
const tags = p.postTag ?? [];
|
|
return tags.some((t) => {
|
|
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
|
|
return tagSlugs.includes(slug ?? "");
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Filtert Posts: entfernt solche, die mindestens einen der Tag-Slugs haben. */
|
|
export function excludePostsByTagSlugs(
|
|
posts: PostEntry[],
|
|
tagSlugs: string[],
|
|
): PostEntry[] {
|
|
if (!tagSlugs?.length) return posts;
|
|
return posts.filter((p) => {
|
|
const tags = p.postTag ?? [];
|
|
return !tags.some((t) => {
|
|
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
|
|
return tagSlugs.includes(slug ?? "");
|
|
});
|
|
});
|
|
}
|
|
|
|
export function getTotalPages(count: number, perPage: number): number {
|
|
return Math.max(1, Math.ceil(count / perPage));
|
|
}
|
|
|
|
export function paginate<T>(items: T[], page: number, perPage: number): T[] {
|
|
const start = (page - 1) * perPage;
|
|
return items.slice(start, start + perPage);
|
|
}
|
|
|
|
export type PostSearchIndexEntry = {
|
|
slug: string;
|
|
headline: string;
|
|
excerpt: string;
|
|
subheadline: string;
|
|
tags: string[];
|
|
image: string | null;
|
|
created: string | null;
|
|
isEvent: boolean;
|
|
eventDate: string | null;
|
|
};
|
|
|
|
type PostWithImageIndex = PostEntry & {
|
|
_resolvedImageUrl?: string;
|
|
};
|
|
|
|
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
|
|
return posts.map((p) => {
|
|
const ev = getPostEventInfo(p);
|
|
return {
|
|
slug: p.slug ?? p._slug ?? "",
|
|
headline: p.headline ?? "",
|
|
excerpt: p.excerpt ?? "",
|
|
subheadline: p.subheadline ?? "",
|
|
tags: (p.postTag ?? [])
|
|
.map((t) =>
|
|
typeof t === "string"
|
|
? t
|
|
: (t as { name?: string; _slug?: string }).name ??
|
|
(t as { _slug?: string })._slug ??
|
|
"",
|
|
)
|
|
.filter(Boolean),
|
|
image: (p as PostWithImageIndex)._resolvedImageUrl ?? null,
|
|
created: p.created ?? null,
|
|
isEvent: ev.isEvent,
|
|
eventDate: ev.eventDate,
|
|
};
|
|
});
|
|
}
|
|
|
|
export type PostSortOrder = "newest" | "oldest";
|
|
|
|
export type LoadPostsResult = {
|
|
posts: PostEntry[];
|
|
tags: Awaited<ReturnType<typeof getTags>>;
|
|
activeTag: string | null;
|
|
currentPage: number;
|
|
totalPages: number;
|
|
totalPosts: number;
|
|
sort: PostSortOrder;
|
|
upcomingEvents: PostEntry[];
|
|
searchIndex: PostSearchIndexEntry[];
|
|
tagName: string | null;
|
|
cmsError: string | null;
|
|
};
|
|
|
|
/** Gemeinsame Ladefunktion für /posts, /posts/page/[n], /posts/tag/[t], /posts/tag/[t]/page/[n]. */
|
|
export async function loadPostsList(opts: {
|
|
tagSlug?: string | null;
|
|
page?: number;
|
|
sort?: PostSortOrder;
|
|
}): Promise<LoadPostsResult> {
|
|
const tagSlug = opts.tagSlug ?? null;
|
|
const requestedPage = Math.max(1, opts.page ?? 1);
|
|
const sort: PostSortOrder = opts.sort === "oldest" ? "oldest" : "newest";
|
|
const perPage = getPostsPerPage();
|
|
|
|
let posts: PostEntry[] = [];
|
|
let tags: Awaited<ReturnType<typeof getTags>> = [];
|
|
let cmsError: string | null = null;
|
|
|
|
try {
|
|
// Sparse fieldset für die Listen-Ansicht: Body/Rows brauchen wir hier
|
|
// nicht, nur Karten-Felder + image + tag-Metadaten. _depth=2 reicht
|
|
// (post → postImage → img-Sidecar; post → postTag → name/icon/color).
|
|
[posts, tags] = await Promise.all([
|
|
getPosts({
|
|
resolve: ["postImage", "postTag", "calendarItem"],
|
|
depth: 2,
|
|
fields: [
|
|
"_slug",
|
|
"slug",
|
|
"headline",
|
|
"linkName",
|
|
"subheadline",
|
|
"excerpt",
|
|
"created",
|
|
"calendarItem",
|
|
"hideFromListing",
|
|
"postImage.*",
|
|
"postTag.name",
|
|
"postTag.icon",
|
|
"postTag.color",
|
|
"postTag._slug",
|
|
],
|
|
}),
|
|
getTags(),
|
|
]);
|
|
posts = filterHiddenPosts(sortPostsByDate(posts));
|
|
const tagsMap = await getTagsMap();
|
|
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
|
|
for (const post of posts) {
|
|
const field = getPostImageField(post.postImage);
|
|
if (field) {
|
|
const url = ensureTransformedImage(field.url, {
|
|
width: 400,
|
|
height: 267,
|
|
fit: "cover",
|
|
format: "webp",
|
|
focalX: field.focal?.x,
|
|
focalY: field.focal?.y,
|
|
});
|
|
const p = post as PostEntry & {
|
|
_resolvedImageUrl?: string;
|
|
_rawImageUrl?: string;
|
|
_imageFocal?: { x: number; y: number } | null;
|
|
};
|
|
p._resolvedImageUrl = url;
|
|
p._rawImageUrl = field.url;
|
|
p._imageFocal = field.focal ?? null;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
cmsError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
const filteredByTag = filterPostsByTag(posts, tagSlug);
|
|
// posts sind bereits neueste-zuerst sortiert; „oldest" = umgekehrte Reihenfolge.
|
|
const filtered =
|
|
sort === "oldest" ? [...filteredByTag].reverse() : filteredByTag;
|
|
const totalPages = getTotalPages(filtered.length, perPage);
|
|
const pageNum = Math.min(requestedPage, totalPages);
|
|
const pagePosts = paginate(filtered, pageNum, perPage);
|
|
|
|
const tagName = tagSlug
|
|
? (tags.find((t) => ((t as { _slug?: string })._slug ?? "") === tagSlug)?.name ?? tagSlug)
|
|
: null;
|
|
|
|
const upcomingEvents = selectUpcomingEvents(posts);
|
|
const searchIndex = buildPostSearchIndex(posts);
|
|
|
|
return {
|
|
posts: pagePosts,
|
|
tags,
|
|
activeTag: tagSlug,
|
|
currentPage: pageNum,
|
|
totalPages,
|
|
totalPosts: filtered.length,
|
|
sort,
|
|
upcomingEvents,
|
|
searchIndex,
|
|
tagName,
|
|
cmsError,
|
|
};
|
|
}
|
|
|
|
export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] {
|
|
const now = Date.now();
|
|
type Scored = { post: PostEntry; ts: number };
|
|
const scored: Scored[] = [];
|
|
for (const p of posts) {
|
|
const ev = getPostEventInfo(p);
|
|
if (!ev.isEvent || !ev.eventDate) continue;
|
|
const ts = new Date(ev.eventDate).getTime();
|
|
if (ts >= now) scored.push({ post: p, ts });
|
|
}
|
|
return scored
|
|
.sort((a, b) => a.ts - b.ts)
|
|
.slice(0, max)
|
|
.map((s) => s.post);
|
|
}
|
|
|
|
const POST_BASE = "/post";
|
|
|
|
/** Post-URL: slug ist die URL; führender Slash entfernt. */
|
|
export function postHref(post: PostEntry): string {
|
|
const slug = post.slug ?? post._slug ?? "";
|
|
const norm = (slug ?? "").replace(/^\//, "").trim();
|
|
return norm ? `${POST_BASE}/${encodeURIComponent(norm)}/` : `${POST_BASE}/`;
|
|
}
|
|
|
|
export function formatPostDate(value: string | undefined): string {
|
|
if (!value) return "";
|
|
try {
|
|
const d = new Date(value);
|
|
return Number.isNaN(d.getTime())
|
|
? ""
|
|
: d.toLocaleDateString("de-DE", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Prüft, ob ein Block ein Post-Overview-Block ist (mit _type "post_overview"). */
|
|
function isPostOverviewBlock(item: unknown): item is {
|
|
_type?: string;
|
|
allPosts?: boolean;
|
|
posts?: unknown[];
|
|
filterByTag?: unknown[];
|
|
excludeTag?: unknown[];
|
|
numberItems?: number;
|
|
postsResolved?: unknown[];
|
|
} {
|
|
return (
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
(item as { _type?: string })._type === "post_overview"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved.
|
|
* Nutzt die globale Tag-API (getTagsMap()) für Slug → TagMeta.
|
|
* Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug]+page.server.ts).
|
|
*/
|
|
export async function resolvePostOverviewBlocks(
|
|
layout: RowContentLayout | null | undefined,
|
|
): Promise<Map<string, TagMeta>> {
|
|
const tagsMap = await getTagsMap();
|
|
|
|
if (!layout) return tagsMap;
|
|
const rows = [
|
|
layout.row1Content ?? [],
|
|
layout.row2Content ?? [],
|
|
layout.row3Content ?? [],
|
|
];
|
|
let allPosts: PostEntry[] | null = null;
|
|
|
|
for (const content of rows) {
|
|
if (!Array.isArray(content)) continue;
|
|
for (const item of content) {
|
|
if (!isPostOverviewBlock(item)) continue;
|
|
const rawFilter = item.filterByTag ?? [];
|
|
const tagSlugs = Array.isArray(rawFilter)
|
|
? rawFilter
|
|
.map((t) =>
|
|
typeof t === "string"
|
|
? t
|
|
: ((t as { _slug?: string })?._slug ?? ""),
|
|
)
|
|
.filter(Boolean)
|
|
: [];
|
|
const rawExclude = item.excludeTag ?? [];
|
|
const excludeSlugs = Array.isArray(rawExclude)
|
|
? rawExclude
|
|
.map((t) =>
|
|
typeof t === "string"
|
|
? t
|
|
: ((t as { _slug?: string })?._slug ?? ""),
|
|
)
|
|
.filter(Boolean)
|
|
: [];
|
|
const hasExplicitPosts = Array.isArray(item.posts) && item.posts.length > 0;
|
|
const useAllPosts = item.allPosts || (tagSlugs.length > 0 && !hasExplicitPosts);
|
|
if (useAllPosts) {
|
|
if (allPosts === null)
|
|
allPosts = await getPosts({
|
|
_sort: "created",
|
|
_order: "desc",
|
|
resolve: "all",
|
|
});
|
|
let list = filterHiddenPosts(sortPostsByDate(allPosts));
|
|
if (tagSlugs.length) list = filterPostsByTagSlugs(list, tagSlugs);
|
|
if (excludeSlugs.length) list = excludePostsByTagSlugs(list, excludeSlugs);
|
|
const limit =
|
|
typeof item.numberItems === "number" ? item.numberItems : 9999;
|
|
item.postsResolved = list.slice(0, limit);
|
|
} else if (hasExplicitPosts && Array.isArray(item.posts)) {
|
|
const first = item.posts[0];
|
|
const isResolved =
|
|
typeof first === "object" &&
|
|
first !== null &&
|
|
"_slug" in first &&
|
|
("headline" in first || "linkName" in first);
|
|
if (isResolved) {
|
|
item.postsResolved = item.posts as PostEntry[];
|
|
} else {
|
|
const slugs = item.posts
|
|
.map((p) =>
|
|
typeof p === "string"
|
|
? p
|
|
: ((p as { _slug?: string })?._slug ?? ""),
|
|
)
|
|
.filter(Boolean);
|
|
const limit =
|
|
typeof item.numberItems === "number" ? item.numberItems : 9999;
|
|
const settled = await Promise.all(
|
|
slugs.slice(0, limit).map((slug) =>
|
|
getPostBySlug(slug, { locale: "de", resolve: ["all"] }).catch(
|
|
() => null,
|
|
),
|
|
),
|
|
);
|
|
item.postsResolved = settled.filter((p): p is PostEntry => !!p);
|
|
}
|
|
}
|
|
if (Array.isArray(item.postsResolved)) {
|
|
const posts = item.postsResolved as PostEntry[];
|
|
if (posts.length > 0) {
|
|
for (const post of posts) resolvePostTagsInPost(post, tagsMap);
|
|
}
|
|
for (const post of item.postsResolved as PostEntry[]) {
|
|
const field = getPostImageField(post.postImage);
|
|
if (field) {
|
|
const url = ensureTransformedImage(field.url, {
|
|
width: 400,
|
|
height: 267,
|
|
fit: "cover",
|
|
format: "webp",
|
|
focalX: field.focal?.x,
|
|
focalY: field.focal?.y,
|
|
});
|
|
const p = post as PostEntry & {
|
|
_resolvedImageUrl?: string;
|
|
_rawImageUrl?: string;
|
|
_imageFocal?: { x: number; y: number } | null;
|
|
};
|
|
p._resolvedImageUrl = url;
|
|
p._rawImageUrl = field.url;
|
|
p._imageFocal = field.focal ?? null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return tagsMap;
|
|
}
|
|
|
|
function isCalendarBlock(
|
|
item: unknown,
|
|
): item is { _type?: string; _slug?: string; items?: unknown[] } {
|
|
return (
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
(item as { _type?: string })._type === "calendar"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items
|
|
* mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link).
|
|
*/
|
|
export async function resolveCalendarBlocks(
|
|
layout: RowContentLayout,
|
|
): Promise<void> {
|
|
const rows = [
|
|
layout.row1Content ?? [],
|
|
layout.row2Content ?? [],
|
|
layout.row3Content ?? [],
|
|
];
|
|
|
|
for (const content of rows) {
|
|
if (!Array.isArray(content)) continue;
|
|
for (const item of content) {
|
|
if (!isCalendarBlock(item)) continue;
|
|
const slug = item._slug;
|
|
if (!slug) continue;
|
|
const rawItems = item.items ?? [];
|
|
const first = rawItems[0];
|
|
const alreadyResolved =
|
|
typeof first === "object" &&
|
|
first !== null &&
|
|
"terminZeit" in first &&
|
|
"title" in first;
|
|
if (alreadyResolved) continue;
|
|
|
|
const calendar = await getCalendarBySlug(slug, {
|
|
locale: "de",
|
|
resolve: "items",
|
|
});
|
|
const raw = calendar?.items ?? rawItems;
|
|
// Leere items-Liste → alle calendar_items aus der Collection (Pool).
|
|
// Greift sowohl für default-Variante als auch compact-Anteaser.
|
|
if (raw.length === 0) {
|
|
const pool = (await getCalendarItems({ locale: "de" })) as unknown as CalendarItemData[];
|
|
(item as { items?: CalendarItemData[] }).items = pool;
|
|
continue;
|
|
}
|
|
const settled = await Promise.all(
|
|
raw.map(async (x): Promise<CalendarItemData | null> => {
|
|
if (
|
|
typeof x === "object" &&
|
|
x !== null &&
|
|
"terminZeit" in x &&
|
|
"title" in x
|
|
) {
|
|
return x as CalendarItemData;
|
|
}
|
|
const islug =
|
|
typeof x === "string" ? x : (x as { _slug?: string })?._slug;
|
|
if (!islug) return null;
|
|
const entry = await getCalendarItemBySlug(islug, { locale: "de" }).catch(
|
|
() => null,
|
|
);
|
|
return entry ?? null;
|
|
}),
|
|
);
|
|
(item as { items?: CalendarItemData[] }).items = settled.filter(
|
|
(e): e is CalendarItemData => !!e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isDeadlineBannerBlock(
|
|
item: unknown,
|
|
): item is {
|
|
_type?: string;
|
|
mode?: string;
|
|
items?: unknown[];
|
|
} {
|
|
return (
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
(item as { _type?: string })._type === "deadline_banner"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Löst deadline_banner-Blöcke im auto-Modus auf: Lädt alle calendar_items
|
|
* global und setzt sie als Pool in block.items, sofern dort noch keine
|
|
* aufgelösten Einträge liegen. Die Komponente wählt daraus den nächsten
|
|
* zukünftigen Termin.
|
|
*/
|
|
export async function resolveDeadlineBannerBlocks(
|
|
layout: RowContentLayout,
|
|
): Promise<void> {
|
|
const rows = [
|
|
layout.row1Content ?? [],
|
|
layout.row2Content ?? [],
|
|
layout.row3Content ?? [],
|
|
];
|
|
let pool: CalendarItemData[] | null = null;
|
|
const loadPool = async (): Promise<CalendarItemData[]> => {
|
|
if (pool === null) {
|
|
const items = await getCalendarItems({ locale: "de" });
|
|
pool = items as unknown as CalendarItemData[];
|
|
}
|
|
return pool;
|
|
};
|
|
for (const content of rows) {
|
|
if (!Array.isArray(content)) continue;
|
|
for (const item of content) {
|
|
if (!isDeadlineBannerBlock(item)) continue;
|
|
if (item.mode !== "auto") continue;
|
|
const existing = item.items ?? [];
|
|
const first = existing[0];
|
|
const alreadyResolved =
|
|
typeof first === "object" &&
|
|
first !== null &&
|
|
"terminZeit" in first &&
|
|
"title" in first;
|
|
if (alreadyResolved) continue;
|
|
(item as { items?: CalendarItemData[] }).items = await loadPool();
|
|
}
|
|
}
|
|
}
|
|
|
|
function isSearchableTextBlock(
|
|
item: unknown,
|
|
): item is { _type?: string; textFragments?: unknown[] } {
|
|
return (
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
(item as { _type?: string })._type === "searchable_text"
|
|
);
|
|
}
|
|
|
|
/** Prüft, ob ein Fragment bereits aufgelöst ist (hat title oder text). */
|
|
function isResolvedFragment(f: unknown): boolean {
|
|
if (typeof f !== "object" || f === null) return false;
|
|
const o = f as { title?: string; text?: string };
|
|
return "title" in o || "text" in o;
|
|
}
|
|
|
|
/**
|
|
* Normalisiert tags eines Fragments zu Anzeigenamen (string[]).
|
|
* Nutzt slugToMeta für Slug → Anzeigename; Objekte mit name/_slug werden unterstützt.
|
|
*/
|
|
function resolveFragmentTags(
|
|
tagsRaw: unknown[] | undefined,
|
|
slugToMeta: Map<string, TagMeta>,
|
|
): string[] {
|
|
if (!Array.isArray(tagsRaw)) return [];
|
|
return tagsRaw
|
|
.map((t) => {
|
|
if (typeof t === "string") return slugToMeta.get(t)?.name ?? t;
|
|
const o = t as { _slug?: string; name?: string };
|
|
const slug = o._slug ?? "";
|
|
return (o.name ?? slugToMeta.get(slug)?.name ?? slug).trim();
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Lädt für alle searchable_text-Blöcke im Layout die textFragments nach
|
|
* (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und
|
|
* löst Tag-Slugs/Refs in Anzeigenamen auf.
|
|
* @param slugToMeta Optionale Map Slug → TagMeta (z. B. von resolvePostOverviewBlocks); sonst getTagsMap().
|
|
*/
|
|
export async function resolveSearchableTextBlocks(
|
|
layout: RowContentLayout | null | undefined,
|
|
slugToMeta?: Map<string, TagMeta>,
|
|
): Promise<void> {
|
|
if (!layout) return;
|
|
const tagsMap = slugToMeta ?? (await getTagsMap());
|
|
const rows = [
|
|
layout.row1Content ?? [],
|
|
layout.row2Content ?? [],
|
|
layout.row3Content ?? [],
|
|
];
|
|
for (const content of rows) {
|
|
if (!Array.isArray(content)) continue;
|
|
for (const item of content) {
|
|
if (!isSearchableTextBlock(item)) continue;
|
|
const raw = item.textFragments ?? [];
|
|
if (!Array.isArray(raw) || raw.length === 0) continue;
|
|
const needsResolve = raw.some((f) => !isResolvedFragment(f));
|
|
if (!needsResolve) continue;
|
|
const resolved: unknown[] = [];
|
|
for (const f of raw) {
|
|
const slug =
|
|
typeof f === "string" ? f : ((f as { _slug?: string })?._slug ?? "");
|
|
if (!slug) {
|
|
if (isResolvedFragment(f)) resolved.push(f);
|
|
continue;
|
|
}
|
|
const frag = await getTextFragmentBySlug(slug, { locale: "de" });
|
|
if (frag) {
|
|
const fragObj = frag as { tags?: unknown[] };
|
|
const tagNames = resolveFragmentTags(fragObj.tags, tagsMap);
|
|
resolved.push({ ...fragObj, tags: tagNames });
|
|
}
|
|
}
|
|
(item as { textFragments?: unknown[] }).textFragments = resolved;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isStellingnahmeGeneratorBlock(item: unknown): boolean {
|
|
return (item as { _type?: string })?._type === "stellungnahme_generator";
|
|
}
|
|
|
|
/** Löst stellungnahme_generator-Blöcke auf:
|
|
* - windArea → vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien
|
|
* - allgemeineArgumenteTag → allgemeineArgumente[] via Tag-Filter
|
|
* - aiRules ← zentrale stellungnahme_config (Singleton), einmal geladen, an alle Blöcke gehängt
|
|
*/
|
|
export async function resolveStellingnahmeGeneratorBlocks(
|
|
layout: RowContentLayout | null | undefined,
|
|
): Promise<void> {
|
|
if (!layout) return;
|
|
const rows = [
|
|
layout.row1Content ?? [],
|
|
layout.row2Content ?? [],
|
|
layout.row3Content ?? [],
|
|
];
|
|
|
|
// Zentrales Regelwerk nur laden, wenn überhaupt ein Generator-Block vorhanden ist.
|
|
const hasGenerator = rows.some(
|
|
(content) => Array.isArray(content) && content.some(isStellingnahmeGeneratorBlock),
|
|
);
|
|
const aiRules = hasGenerator
|
|
? (await getStellungnahmeConfig(STELLUNGNAHME_CONFIG_SLUG, { locale: "de" }))?.aiRules
|
|
: undefined;
|
|
|
|
for (const content of rows) {
|
|
if (!Array.isArray(content)) continue;
|
|
for (const item of content) {
|
|
if (!isStellingnahmeGeneratorBlock(item)) continue;
|
|
const block = item as Record<string, unknown>;
|
|
|
|
// Zentrales Regelwerk anhängen (Fallback auf Hardcode-Default in der Komponente)
|
|
if (aiRules) block.aiRules = aiRules;
|
|
|
|
// Resolve windArea
|
|
const windAreaRef = block.windArea;
|
|
const windAreaSlug =
|
|
typeof windAreaRef === "string"
|
|
? windAreaRef
|
|
: (windAreaRef as { _slug?: string })?._slug ?? "";
|
|
if (windAreaSlug) {
|
|
const windArea = await getEntryBySlug("wind_area", windAreaSlug, {
|
|
locale: "de",
|
|
resolve: ["stellungnahme_kriterien"],
|
|
depth: 2,
|
|
});
|
|
if (windArea) {
|
|
const kriterienRaw =
|
|
(windArea as { stellungnahme_kriterien?: unknown[] })
|
|
.stellungnahme_kriterien ?? [];
|
|
// Fragmente einzeln laden um bulletPoints zu bekommen (nicht im Basis-Resolve)
|
|
const kriterien = await Promise.all(
|
|
kriterienRaw.map(async (k) => {
|
|
const slug =
|
|
typeof k === "string"
|
|
? k
|
|
: (k as { _slug?: string })?._slug ?? "";
|
|
if (!slug) return null;
|
|
return getTextFragmentBySlug(slug, { locale: "de" });
|
|
}),
|
|
);
|
|
(windArea as Record<string, unknown>).stellungnahme_kriterien =
|
|
kriterien.filter(Boolean);
|
|
block.windArea = windArea;
|
|
}
|
|
}
|
|
|
|
// Resolve allgemeineArgumente via Tag
|
|
const tagRef = block.allgemeineArgumenteTag;
|
|
const tagSlug =
|
|
typeof tagRef === "string"
|
|
? tagRef
|
|
: (tagRef as { _slug?: string })?._slug ?? "";
|
|
if (tagSlug) {
|
|
block.allgemeineArgumente = await getTextFragmentsByTag(tagSlug, {
|
|
locale: "de",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function resolveAdressbuchBlocks(
|
|
layout: RowContentLayout | null | undefined,
|
|
): Promise<void> {
|
|
if (!layout) return;
|
|
const rows = [layout.row1Content ?? [], layout.row2Content ?? [], layout.row3Content ?? []];
|
|
let contacts: unknown[] | null = null;
|
|
for (const content of rows) {
|
|
if (!Array.isArray(content)) continue;
|
|
for (const item of content) {
|
|
if (typeof item !== "object" || item === null) continue;
|
|
if ((item as { _type?: string })._type !== "adressbuch") continue;
|
|
if (contacts === null) contacts = await getContacts();
|
|
const block = item as Record<string, unknown>;
|
|
const exclude = (block.excludeContacts as unknown[]) ?? [];
|
|
const excludeSlugs = new Set(
|
|
exclude.map((e) =>
|
|
typeof e === "string" ? e : (e as { _slug?: string })?._slug ?? ""
|
|
).filter(Boolean)
|
|
);
|
|
block.resolvedContacts = excludeSlugs.size
|
|
? contacts.filter((c) => !excludeSlugs.has((c as { _slug?: string })._slug ?? ""))
|
|
: contacts;
|
|
}
|
|
}
|
|
}
|