Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
175 lines
9.6 KiB
JavaScript
175 lines
9.6 KiB
JavaScript
import { state } from './state.js'
|
|
import { requireAdminAuth } from './auth.js'
|
|
import { scriptKey } from './episode-script-keys.js'
|
|
import { getFeedIndex } from './feed-release.js'
|
|
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
|
|
|
// One report per lane (A = primary series, B = second series) listing what is set up, what is
|
|
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
|
|
// publishing. Read-only: it never changes anything.
|
|
|
|
const err = (message, view) => ({ severity: 'error', message, view })
|
|
const warn = (message, view) => ({ severity: 'warn', message, view })
|
|
|
|
function laneConfig(content, lane) {
|
|
if (lane === 'A') {
|
|
return {
|
|
title: String(content.seriesTitle ?? '').trim(),
|
|
studySlug: String(content.seriesStudySlug ?? '').trim(),
|
|
highlightLane: 'primary',
|
|
description: String(content.seriesDescription ?? '').trim(),
|
|
imageUrl: String(content.seriesImageUrl ?? '').trim(),
|
|
listenUrl: String(content.seriesListenUrl ?? '').trim(),
|
|
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
|
|
transition: content.scheduledSeriesTransition,
|
|
}
|
|
}
|
|
const second = content.secondSeries ?? {}
|
|
return {
|
|
title: String(second.title ?? '').trim(),
|
|
studySlug: String(second.studySlug ?? '').trim(),
|
|
highlightLane: 'second',
|
|
description: String(second.description ?? '').trim(),
|
|
imageUrl: String(second.imageUrl ?? '').trim(),
|
|
listenUrl: String(second.listenUrl ?? '').trim(),
|
|
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
|
|
transition: content.scheduledSecondSeriesTransition,
|
|
}
|
|
}
|
|
|
|
export async function buildSeasonCheck() {
|
|
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
|
|
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
|
|
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
|
|
|
|
const studies = Array.isArray(content.studies) ? content.studies : []
|
|
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
|
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
|
|
|
|
const lanes = []
|
|
for (const lane of ['A', 'B']) {
|
|
const cfg = laneConfig(content, lane)
|
|
// Lane B only exists once a second series has been set up
|
|
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
|
|
|
|
const season = seasons[lane]
|
|
const problems = []
|
|
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
|
|
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
|
|
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
|
|
|
|
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
|
|
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
|
|
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
|
|
|
|
const sections = Array.isArray(study?.sections) ? study.sections : []
|
|
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
|
|
|
|
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
|
|
if (study && noNumber.length > 0) {
|
|
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
|
|
}
|
|
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
|
|
if (study && noDate.length > 0) {
|
|
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
|
|
}
|
|
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
|
|
if (noHighlightNumber.length > 0) {
|
|
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
|
|
}
|
|
|
|
// Series card
|
|
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
|
|
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
|
|
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
|
|
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
|
|
|
|
// Handover to the next season
|
|
const t = cfg.transition
|
|
if (t) {
|
|
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
|
|
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
|
|
} else if (season && String(t.newSeason) === season) {
|
|
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
|
|
}
|
|
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
|
|
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
|
|
}
|
|
|
|
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
|
|
const numbers = new Set()
|
|
for (let n = 1; n <= sections.length; n++) numbers.add(n)
|
|
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
|
|
sections.forEach(s => addNumber(s.episodeNumber))
|
|
laneHighlights.forEach(h => addNumber(h.episodeNumber))
|
|
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
|
|
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
|
|
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
|
|
|
|
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
|
|
const num = String(n)
|
|
const feedItem = season ? index.get(`${season}-${num}`) : undefined
|
|
const released = Boolean(feedItem)
|
|
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
|
|
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
|
|
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
|
|
|
|
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
|
|
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
|
|
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
|
|
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
|
|
}
|
|
|
|
return {
|
|
number: n,
|
|
released,
|
|
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
|
|
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
|
|
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
|
|
lesson: lessons.length > 0,
|
|
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
|
|
highlight: hl.length > 0,
|
|
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
|
|
}
|
|
})
|
|
|
|
lanes.push({
|
|
lane,
|
|
title: cfg.title,
|
|
season,
|
|
studyTitle: study?.title ?? '',
|
|
problems,
|
|
errors: problems.filter(p => p.severity === 'error').length,
|
|
warnings: problems.filter(p => p.severity === 'warn').length,
|
|
episodes,
|
|
})
|
|
}
|
|
|
|
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
|
|
const general = []
|
|
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
|
|
if (unseasoned.length > 0) {
|
|
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
|
|
}
|
|
|
|
const all = [...lanes.flatMap(l => l.problems), ...general]
|
|
return {
|
|
lanes,
|
|
general,
|
|
total: all.length,
|
|
errors: all.filter(p => p.severity === 'error').length,
|
|
warnings: all.filter(p => p.severity === 'warn').length,
|
|
}
|
|
}
|
|
|
|
export function register(app) {
|
|
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
|
|
try {
|
|
res.json(await buildSeasonCheck())
|
|
} catch (err) {
|
|
console.error('[season-check] failed:', err)
|
|
res.status(500).json({ message: 'Could not build the season check.' })
|
|
}
|
|
})
|
|
}
|