/// import { type Cell, cell, Default, handler, lift, recipe } from "commontools"; type PublishInput = string | number | undefined; interface EditorialEntrySeed { id?: string; title?: string; summary?: string; channel?: string; publishDate?: PublishInput; } interface EditorialEntry { id: string; title: string; summary: string; channel: string; publishDate: string; } interface EditorialCalendarArgs { entries: Default; channels: Default; } interface PlanPublicationEvent { id?: string; title?: string; summary?: string; channel?: string; publishDate?: PublishInput; } interface ChannelScheduleEntry { id: string; title: string; publishDate: string; } interface ChannelSchedule { channel: string; entries: ChannelScheduleEntry[]; upcomingLabel: string; } interface NextPublish { id: string; title: string; channel: string; publishDate: string; } const defaultChannels: string[] = ["Blog", "Newsletter", "Podcast"]; const defaultEntries: EditorialEntry[] = [ { id: "blog-weekly-roundup-20240708", title: "Weekly Roundup", summary: "Highlights from the blog pipeline.", channel: "Blog", publishDate: "2024-07-08", }, { id: "newsletter-product-update-20240710", title: "Product Update", summary: "Feature update for newsletter readers.", channel: "Newsletter", publishDate: "2024-07-10", }, { id: "podcast-founder-interview-20240712", title: "Founder Interview", summary: "Conversation with the founders.", channel: "Podcast", publishDate: "2024-07-12", }, ]; const safeText = (value: unknown): string => { if (typeof value !== "string") return ""; const trimmed = value.trim().replace(/\s+/g, " "); return trimmed; }; const capitalizeWord = (word: string): string => { if (word.length === 0) return word; return word[0].toUpperCase() + word.slice(1).toLowerCase(); }; const sanitizeChannelName = (value: unknown, fallback: string): string => { const base = safeText(value); if (base.length === 0) return fallback; const normalized = base .split(" ") .filter((part) => part.length > 0) .map(capitalizeWord) .join(" "); return normalized.length > 0 ? normalized : fallback; }; const sanitizeChannelList = (value: unknown): string[] => { const list = Array.isArray(value) ? value : defaultChannels; const sanitized: string[] = []; const seen = new Set(); for (let index = 0; index < list.length; index += 1) { const fallback = defaultChannels[index % defaultChannels.length]; const name = sanitizeChannelName(list[index], fallback); const key = name.toLowerCase(); if (key.length === 0 || seen.has(key)) continue; seen.add(key); sanitized.push(name); } if (sanitized.length === 0) { return [...defaultChannels]; } return sanitized; }; const sanitizeTitle = (value: unknown, fallback: string): string => { const base = safeText(value); if (base.length === 0) return fallback; return base[0].toUpperCase() + base.slice(1); }; const sanitizeSummary = (value: unknown, fallback: string): string => { const base = safeText(value); return base.length > 0 ? base : fallback; }; const suggestPublishDate = (sequence: number): string => { const baseDay = 8 + (sequence % 10) * 2; const day = ((baseDay - 1) % 28) + 1; return `2024-07-${day.toString().padStart(2, "0")}`; }; const normalizeDateDigits = (value: string): string | null => { if (/^\d{8}$/.test(value)) { const year = value.slice(0, 4); const month = value.slice(4, 6); const day = value.slice(6, 8); return `${year}-${month}-${day}`; } return null; }; const datePattern = /^\d{4}-\d{2}-\d{2}$/; const sanitizePublishDate = ( value: PublishInput, fallback: string, sequence: number, ): string => { if (typeof value === "string") { const normalized = value.trim().replace(/\//g, "-"); const digits = normalizeDateDigits(normalized); if (digits && datePattern.test(digits)) return digits; if (datePattern.test(normalized)) return normalized; } if (typeof value === "number" && Number.isFinite(value)) { const day = Math.min(28, Math.max(1, Math.trunc(value))); return `2024-07-${day.toString().padStart(2, "0")}`; } if (datePattern.test(fallback)) return fallback; return suggestPublishDate(sequence); }; const safeIdentifier = (value: string): string => { return value .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); }; const sanitizeIdentifier = ( value: unknown, title: string, channel: string, publishDate: string, fallback: string, ): string => { if (typeof value === "string") { const fromValue = safeIdentifier(value); if (fromValue.length > 0) return fromValue; } const channelSlug = safeIdentifier(channel); const titleSlug = safeIdentifier(title); const dateDigits = publishDate.replace(/[^0-9]/g, ""); const candidate = [channelSlug, titleSlug, dateDigits] .filter((part) => part.length > 0) .join("-"); if (candidate.length > 0) return candidate; const fallbackSlug = safeIdentifier(fallback); if (fallbackSlug.length > 0) return fallbackSlug; return "schedule-entry"; }; const ensureUniqueId = ( candidate: string, used: Set, fallback: string, ): string => { const base = candidate.length > 0 ? candidate : fallback; if (base.length === 0) { used.add("schedule-entry"); return "schedule-entry"; } let current = base; let suffix = 2; while (used.has(current)) { current = `${base}-${suffix}`; suffix += 1; } used.add(current); return current; }; const normalizeChannel = ( value: unknown, fallback: string, channels: readonly string[], ): string => { const candidate = sanitizeChannelName(value, fallback); const match = channels.find((item) => item.toLowerCase() === candidate.toLowerCase() ); if (match) return match; if (channels.length > 0) return channels[0]; return fallback; }; const sanitizeEntry = ( seed: EditorialEntrySeed | undefined, fallback: EditorialEntry, sequence: number, channels: readonly string[], used: Set, ): EditorialEntry => { const title = sanitizeTitle(seed?.title, fallback.title); const summary = sanitizeSummary(seed?.summary, fallback.summary); const channel = normalizeChannel(seed?.channel, fallback.channel, channels); const publishDate = sanitizePublishDate( seed?.publishDate, fallback.publishDate, sequence, ); const candidateId = sanitizeIdentifier( seed?.id, title, channel, publishDate, fallback.id, ); const id = ensureUniqueId(candidateId, used, fallback.id); return { id, title, summary, channel, publishDate, }; }; const buildFallbackEntry = ( index: number, channels: readonly string[], ): EditorialEntry => { const reference = defaultEntries[index % defaultEntries.length]; const fallbackChannel = channels[0] ?? reference.channel ?? "Blog"; const channel = normalizeChannel( reference.channel, fallbackChannel, channels, ); const title = sanitizeTitle(reference.title, `Untitled ${index + 1}`); const summary = sanitizeSummary( reference.summary, "Pending content summary.", ); const publishDate = sanitizePublishDate( reference.publishDate, suggestPublishDate(index), index, ); const id = sanitizeIdentifier( reference.id, title, channel, publishDate, reference.id, ); return { id, title, summary, channel, publishDate, }; }; const compareDate = (a: string, b: string): number => { if (a === b) return 0; const [aYear, aMonth, aDay] = a.split("-").map((part) => Number(part)); const [bYear, bMonth, bDay] = b.split("-").map((part) => Number(part)); if (aYear !== bYear) return aYear - bYear; if (aMonth !== bMonth) return aMonth - bMonth; return aDay - bDay; }; const sortEntries = (entries: readonly EditorialEntry[]): EditorialEntry[] => { return entries.slice().sort((left, right) => { const dateDiff = compareDate(left.publishDate, right.publishDate); if (dateDiff !== 0) return dateDiff; const channelDiff = left.channel.localeCompare(right.channel); if (channelDiff !== 0) return channelDiff; return left.title.localeCompare(right.title); }); }; const sanitizeEntryList = ( value: readonly EditorialEntrySeed[] | undefined, channels: readonly string[], ): EditorialEntry[] => { const seeds = Array.isArray(value) && value.length > 0 ? value : defaultEntries; const sanitized: EditorialEntry[] = []; const used = new Set(); for (let index = 0; index < seeds.length; index += 1) { const fallback = buildFallbackEntry(index, channels); const entry = sanitizeEntry(seeds[index], fallback, index, channels, used); sanitized.push(entry); } return sortEntries(sanitized); }; const buildChannelSchedule = ( entries: readonly EditorialEntry[], channels: readonly string[], ): ChannelSchedule[] => { const fallback = channels[0] ?? defaultChannels[0]; const bucket = new Map(); for (const channel of channels) { bucket.set(channel, { channel, entries: [], upcomingLabel: "No scheduled posts", }); } if (!bucket.has(fallback)) { bucket.set(fallback, { channel: fallback, entries: [], upcomingLabel: "No scheduled posts", }); } for (const entry of entries) { const target = bucket.get(entry.channel) ?? bucket.get(fallback)!; target.entries.push({ id: entry.id, title: entry.title, publishDate: entry.publishDate, }); } for (const schedule of bucket.values()) { schedule.entries.sort((left, right) => compareDate(left.publishDate, right.publishDate) ); if (schedule.entries.length > 0) { const head = schedule.entries[0]; schedule.upcomingLabel = `${head.title} on ${head.publishDate}`; } } return channels.map((channel) => bucket.get(channel)!).map((item) => ({ channel: item.channel, entries: item.entries.slice(), upcomingLabel: item.upcomingLabel, })); }; const selectNextPublish = ( entries: readonly EditorialEntry[], ): NextPublish | null => { if (entries.length === 0) return null; const [next] = sortEntries(entries); return { id: next.id, title: next.title, channel: next.channel, publishDate: next.publishDate, }; }; const buildSummary = ( channels: readonly string[], entries: readonly EditorialEntry[], next: NextPublish | null, ): string => { const base = `${channels.length} channels, ${entries.length} scheduled`; if (!next) { return `${base}, no upcoming release`; } return `${base}, next ${next.title} (${next.channel}) on ${next.publishDate}`; }; const trimHistory = ( history: readonly string[] | undefined, entry: string, ): string[] => { const log = Array.isArray(history) ? history : []; const next = [...log, entry]; return next.length > 8 ? next.slice(next.length - 8) : next; }; const planPublication = handler( ( event: PlanPublicationEvent | undefined, context: { entries: Cell; channels: Cell; history: Cell; }, ) => { const normalizedChannels = sanitizeChannelList(context.channels.get()); context.channels.set(normalizedChannels); const currentEntries = sanitizeEntryList( context.entries.get(), normalizedChannels, ); const used = new Set(currentEntries.map((item) => item.id)); const history = trimHistory(context.history.get(), "Calendar sanitized"); context.history.set(history); if (!event) { context.entries.set(currentEntries); return; } const eventId = typeof event.id === "string" ? safeIdentifier(event.id) : ""; const existingIndex = eventId.length > 0 ? currentEntries.findIndex((item) => item.id === eventId) : -1; if (existingIndex >= 0) { used.delete(currentEntries[existingIndex].id); const fallback = currentEntries[existingIndex]; const updated = sanitizeEntry( { id: fallback.id, title: event.title ?? fallback.title, summary: event.summary ?? fallback.summary, channel: event.channel ?? fallback.channel, publishDate: event.publishDate ?? fallback.publishDate, }, fallback, existingIndex, normalizedChannels, used, ); const nextEntries = [...currentEntries]; nextEntries[existingIndex] = updated; const sorted = sortEntries(nextEntries); context.entries.set(sorted); const message = `Updated ${updated.title} to ${updated.channel} on ${updated.publishDate}`; context.history.set(trimHistory(context.history.get(), message)); return; } const fallback = buildFallbackEntry( currentEntries.length, normalizedChannels, ); const created = sanitizeEntry( { id: event.id, title: event.title, summary: event.summary, channel: event.channel, publishDate: event.publishDate, }, fallback, currentEntries.length, normalizedChannels, used, ); const nextEntries = sortEntries([...currentEntries, created]); context.entries.set(nextEntries); const message = `Scheduled ${created.title} in ${created.channel} for ${created.publishDate}`; context.history.set(trimHistory(context.history.get(), message)); }, ); const defineChannel = handler( ( event: { channel?: string } | undefined, context: { channels: Cell; history: Cell }, ) => { const current = sanitizeChannelList(context.channels.get()); const candidate = sanitizeChannelName(event?.channel, ""); if (candidate.length === 0) { context.channels.set(current); return; } const exists = current.some((name) => name.toLowerCase() === candidate.toLowerCase() ); if (exists) { context.channels.set(current); return; } const next = [...current, candidate]; context.channels.set(next); const message = `Added channel ${candidate}`; context.history.set(trimHistory(context.history.get(), message)); }, ); export const editorialCalendar = recipe( "Editorial Calendar Pattern", ({ entries, channels }) => { const history = cell(["Calendar initialized"]); const channelList = lift(sanitizeChannelList)(channels); const entriesView = lift( ( state: { entries: readonly EditorialEntrySeed[] | undefined; channels: readonly string[]; }, ) => sanitizeEntryList(state.entries, state.channels), )({ entries, channels: channelList, }); const channelSchedule = lift( ( state: { entries: readonly EditorialEntry[]; channels: readonly string[]; }, ) => buildChannelSchedule(state.entries, state.channels), )({ entries: entriesView, channels: channelList, }); const nextPublish = lift(selectNextPublish)(entriesView); const summaryLabel = lift( ( state: { channels: readonly string[]; entries: readonly EditorialEntry[]; next: NextPublish | null; }, ) => buildSummary(state.channels, state.entries, state.next), )({ channels: channelList, entries: entriesView, next: nextPublish, }); const channelCounts = lift((schedule: readonly ChannelSchedule[]) => schedule.map((item) => ({ channel: item.channel, count: item.entries.length, })) )(channelSchedule); const historyView = lift((value: readonly string[] | undefined) => Array.isArray(value) && value.length > 0 ? value : ["Calendar initialized"] )(history); const latestActivity = lift((log: readonly string[]) => log.length > 0 ? log[log.length - 1] : "Calendar initialized" )(historyView); return { channels, entries, channelList, entriesView, channelSchedule, channelCounts, nextPublish, summaryLabel, history: historyView, latestActivity, planPublication: planPublication({ entries, channels, history }), defineChannel: defineChannel({ channels, history }), }; }, );