/// import { type Cell, Default, handler, lift, recipe, str } from "commontools"; interface EpisodeSegmentInput { id?: unknown; title?: unknown; duration?: unknown; } interface EpisodeSegment { id: string; title: string; duration: number; } interface PodcastEpisodePlannerArgs { segments: Default; } interface OutlineEntry extends EpisodeSegment { startMinute: number; endMinute: number; label: string; } const defaultSegments: EpisodeSegment[] = [ { id: "intro", title: "Intro", duration: 2 }, { id: "interview", title: "Interview", duration: 25 }, { id: "outro", title: "Outro", duration: 3 }, ]; const sanitizeTitle = (value: unknown, fallback: string): string => { if (typeof value !== "string") return fallback; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : fallback; }; const sanitizeId = (value: unknown): string | null => { if (typeof value !== "string") return null; const trimmed = value.trim().toLowerCase(); if (trimmed.length === 0) return null; return trimmed.replace(/[^a-z0-9-]+/g, "-").replace(/(^-|-$)/g, ""); }; const slugFromTitle = (title: string): string => { const normalized = title.toLowerCase().replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); return normalized.length > 0 ? normalized : "segment"; }; const sanitizeDuration = (value: unknown): number => { if (typeof value === "number" && Number.isFinite(value)) { const rounded = Math.round(value); return rounded > 0 ? rounded : 1; } return 5; }; const sanitizeSegments = ( input: readonly EpisodeSegmentInput[] | undefined, ): EpisodeSegment[] => { const base = Array.isArray(input) && input.length > 0 ? input : defaultSegments; const seen = new Set(); const sanitized: EpisodeSegment[] = []; let index = 0; for (const entry of base) { const fallbackTitle = `Segment ${index + 1}`; const rawTitle = (entry as EpisodeSegmentInput)?.title; const title = sanitizeTitle(rawTitle, fallbackTitle); const rawId = (entry as EpisodeSegmentInput)?.id; const candidate = sanitizeId(rawId) ?? slugFromTitle(title); const baseId = candidate.length > 0 ? candidate : `segment-${index + 1}`; let id = baseId; let suffix = 1; while (seen.has(id)) { id = `${baseId}-${suffix}`; suffix += 1; } seen.add(id); const rawDuration = (entry as EpisodeSegmentInput)?.duration; const duration = sanitizeDuration(rawDuration); sanitized.push({ id, title, duration }); index += 1; } if (sanitized.length === 0) { return defaultSegments.map((segment) => ({ ...segment })); } return sanitized; }; const ensureSegments = ( segments: Cell, ): EpisodeSegment[] => { const raw = segments.get(); const sanitized = sanitizeSegments(raw); if ( !Array.isArray(raw) || raw.length !== sanitized.length || sanitized.some((segment, idx) => { const current = raw?.[idx] as EpisodeSegmentInput | undefined; if (!current) return true; const normalizedTitle = sanitizeTitle(current.title, segment.title); const normalizedDuration = sanitizeDuration(current.duration); return current.id !== segment.id || normalizedTitle !== segment.title || normalizedDuration !== segment.duration; }) ) { segments.set(sanitized.map((segment) => ({ ...segment }))); } return sanitized; }; const clampIndex = (value: unknown, length: number): number => { if (length <= 1) return 0; if (typeof value === "number" && Number.isFinite(value)) { const index = Math.trunc(value); if (index < 0) return 0; if (index >= length) return length - 1; return index; } return 0; }; const updateSegmentDetails = handler( ( event: | { id?: unknown; title?: unknown; duration?: unknown } | undefined, context: { segments: Cell }, ) => { if (!event) return; const segments = ensureSegments(context.segments); const identifier = sanitizeId(event.id); if (!identifier) return; const index = segments.findIndex((segment) => segment.id === identifier); if (index < 0) return; const current = segments[index]; const hasTitleUpdate = Object.prototype.hasOwnProperty.call(event, "title"); const hasDurationUpdate = Object.prototype.hasOwnProperty.call( event, "duration", ); const nextTitle = hasTitleUpdate ? sanitizeTitle(event.title, current.title) : current.title; const nextDuration = hasDurationUpdate ? sanitizeDuration(event.duration) : current.duration; if (nextTitle === current.title && nextDuration === current.duration) { return; } const next = segments.slice(); next[index] = { ...current, title: nextTitle, duration: nextDuration }; context.segments.set(next); }, ); const reorderSegments = handler( ( event: { from?: unknown; to?: unknown } | undefined, context: { segments: Cell }, ) => { const segments = ensureSegments(context.segments); if (segments.length < 2) return; const fromIndex = clampIndex(event?.from, segments.length); const toIndex = clampIndex(event?.to, segments.length); if (fromIndex === toIndex) return; const next = segments.slice(); const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); context.segments.set(next); }, ); const buildTimeline = (segments: readonly EpisodeSegment[]): OutlineEntry[] => { const entries: OutlineEntry[] = []; let cursor = 0; for (const segment of segments) { const startMinute = cursor; const endMinute = cursor + segment.duration; entries.push({ ...segment, startMinute, endMinute, label: `${segment.title} (${segment.duration}m) @${startMinute}-${endMinute}`, }); cursor = endMinute; } return entries; }; /** Pattern orchestrating podcast episode segments into a timed outline. */ export const podcastEpisodePlanner = recipe( "Podcast Episode Planner", ({ segments }) => { const segmentsView = lift( (value: EpisodeSegmentInput[] | undefined): EpisodeSegment[] => sanitizeSegments(value), )(segments); const timeline = lift( (entries: EpisodeSegment[] | undefined): OutlineEntry[] => buildTimeline(Array.isArray(entries) ? entries : []), )(segmentsView); const outline = lift( (entries: OutlineEntry[] | undefined): string => { if (!entries || entries.length === 0) return "(empty outline)"; return entries.map((entry) => entry.label).join(" -> "); }, )(timeline); const totalMinutes = lift( (entries: OutlineEntry[] | undefined): number => { if (!entries || entries.length === 0) return 0; return entries[entries.length - 1].endMinute; }, )(timeline); return { segments, segmentsView, timeline, outline, totalMinutes, label: str`Episode Outline: ${outline}`, reorderSegments: reorderSegments({ segments }), updateSegment: updateSegmentDetails({ segments }), }; }, );