// Channel grouping primitives. A ChannelGroup is a cosmetic+default-selection // bucket the export UI uses to organize channel checkboxes. Membership lives // on each ChannelConfig (group?: string) — group definitions themselves live // in SiteSettings.groups so they can be edited centrally without touching // every channel config when a description or default-selected value changes. // // Pure, no runtime imports: `"use client"` code imports these. import type { FieldDocs } from "./fieldDocs"; // Each field is documented in CHANNEL_GROUP_FIELD_DOCS below. export type ChannelGroup = { id: string; name: string; description?: string; selectedByDefault: boolean; order?: number; accent?: string; inline?: boolean; }; export const CHANNEL_GROUP_FIELD_DOCS: FieldDocs = { id: "Group id: a lowercase slug (`[a-z0-9][a-z0-9-]*`), unique within the list. An entry with an invalid or repeated id is dropped.", name: 'Header text. May be blank — the UI then shows no header (and falls back to the id in admin contexts) — but must be a string. Trimmed.', description: "Optional description under the header. Trimmed; blank = none.", selectedByDefault: "Whether this group's channels start checked in the export UI's channel filter. Only `true` counts.", order: "Optional explicit ordering hint (lower first); floored. Unordered groups sort after ordered ones, then by name.", accent: "Provenance accent, set only in hub mode where each group is a federated site (id = origin): that site's own accent, drawn as a swatch on the group header. Never read from a site.json — absent in single-site mode.", inline: "Render this group's channels as loose individual chips instead of one collapsible group chip. Stored only when `true`.", }; export const DEFAULT_GROUP_FALLBACK_ID = "default"; // Synthesized fallback used when settings has no groups configured at all, // so the UI always has a non-empty grouping to render. Inline by default: // with no configured grouping the channels render as loose individual chips // rather than one big collapsible "All channels" box. export const FALLBACK_GROUP: ChannelGroup = { id: DEFAULT_GROUP_FALLBACK_ID, name: "All channels", selectedByDefault: true, inline: true, }; const ID_RE = /^[a-z0-9][a-z0-9-]*$/; export function isValidGroupId(id: unknown): id is string { return typeof id === "string" && ID_RE.test(id); } export function parseChannelGroup(raw: unknown): ChannelGroup | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; if (!isValidGroupId(r.id)) return null; // Name may be blank — UI treats empty as "no header" and falls back to id // for admin contexts. Still require it to be a string for shape stability. if (typeof r.name !== "string") return null; const group: ChannelGroup = { id: r.id, name: r.name.trim(), selectedByDefault: r.selectedByDefault === true, }; if (typeof r.description === "string" && r.description.trim()) { group.description = r.description.trim(); } if (typeof r.order === "number" && Number.isFinite(r.order)) { group.order = Math.floor(r.order); } // Absent/false stays absent so serialized JSON stays clean and existing // configured groups default off. if (r.inline === true) group.inline = true; return group; } export function parseChannelGroups(raw: unknown): ChannelGroup[] { if (!Array.isArray(raw)) return []; const out: ChannelGroup[] = []; const seen = new Set(); for (const entry of raw) { const g = parseChannelGroup(entry); if (!g) continue; if (seen.has(g.id)) continue; seen.add(g.id); out.push(g); } return out; } // Picks a usable defaultGroupId given the candidate and the configured groups. // Strategy: honor the candidate if it matches an existing group; otherwise // fall back to the first group's id; otherwise the synthesized fallback id. export function resolveDefaultGroupId( candidate: unknown, groups: ReadonlyArray, ): string { if (typeof candidate === "string" && groups.some((g) => g.id === candidate)) { return candidate; } if (groups.length > 0) return groups[0].id; return DEFAULT_GROUP_FALLBACK_ID; } // Maps a channel's optional group field to a concrete group id, folding // unknown or missing values onto the configured default. export function resolveChannelGroupId( channelGroup: string | undefined, groups: ReadonlyArray, defaultGroupId: string, ): string { if (channelGroup && groups.some((g) => g.id === channelGroup)) { return channelGroup; } return defaultGroupId; } // Stable sort for rendering: explicit order asc, then name asc. export function sortGroups( groups: ReadonlyArray, ): ChannelGroup[] { return groups.slice().sort((a, b) => { const ao = a.order ?? Number.POSITIVE_INFINITY; const bo = b.order ?? Number.POSITIVE_INFINITY; if (ao !== bo) return ao - bo; return a.name.localeCompare(b.name); }); }