import { randomUUID } from 'node:crypto' import { state } from '../state.js' import { queueCalendarEventsWrite } from '../data.js' import { requireAdminAuth } from '../auth.js' const VALID_TYPES = ['general', 'recording', 'social', 'task'] const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly'] const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ const TIME_RE = /^\d{2}:\d{2}$/ function sanitizeRecurrence(raw) { if (!raw || typeof raw !== 'object') return null const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none' if (freq === 'none') return null const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null return { freq, until } } function sanitize(ev) { const recurrence = sanitizeRecurrence(ev.recurrence) return { id: ev.id, type: VALID_TYPES.includes(ev.type) ? ev.type : 'general', title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '', date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '', startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined, notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '', completed: Boolean(ev.completed), reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0, reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined, recurrence: recurrence ?? undefined, createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(), } } // ── iCal helpers ───────────────────────────────────────────────────────────── function escapeIcs(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n') } function foldIcsLine(line) { const chars = [...line] if (chars.length <= 75) return line const parts = [] let current = '' for (const ch of chars) { if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch } else current += ch } if (current) parts.push(current) return parts.join('\r\n') } function toIcsDatetime(dateStr, timeStr) { const d = dateStr.replace(/-/g, '') if (timeStr && TIME_RE.test(timeStr)) { const t = timeStr.replace(':', '') + '00' return `DTSTART:${d}T${t}` } return `DTSTART;VALUE=DATE:${d}` } function toIcsTimestamp(iso) { try { const d = new Date(iso) return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '') } catch { return '' } } function rruleFor(recurrence) { if (!recurrence || recurrence.freq === 'none') return null const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' } const freq = freqMap[recurrence.freq] if (!freq) return null let rule = `RRULE:FREQ=${freq}` if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2' if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z` return rule } function generateIcal() { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Siteforge//CalendarExport//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Verse by Verse Calendar', ] for (const ep of (state.podcastChecklist?.episodes ?? [])) { if (!ep.datePublished) continue const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ') const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}` lines.push('BEGIN:VEVENT') lines.push(`UID:ep-${ep.id}@siteforge`) lines.push(dtstart) lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`) if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`) lines.push('END:VEVENT') } for (const ev of (state.calendarEvents ?? [])) { if (!ev.date) continue lines.push('BEGIN:VEVENT') lines.push(`UID:ev-${ev.id}@siteforge`) lines.push(toIcsDatetime(ev.date, ev.startTime)) lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`) if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`) const rrule = rruleFor(ev.recurrence) if (rrule) lines.push(rrule) if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`) lines.push('END:VEVENT') } lines.push('END:VCALENDAR') return lines.map(foldIcsLine).join('\r\n') + '\r\n' } // ── Routes ─────────────────────────────────────────────────────────────────── export function register(app) { app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => { res.json({ events: state.calendarEvents }) }) app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => { const ical = generateIcal() res.setHeader('Content-Type', 'text/calendar; charset=utf-8') res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"') res.send(ical) }) app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => { const { type, title, date, startTime, notes, reminderDays, recurrence } = req.body ?? {} if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return } if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return } if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return } const ev = sanitize({ id: randomUUID(), type: type ?? 'general', title: String(title).trim(), date, startTime: startTime ?? undefined, notes: notes ?? '', completed: false, reminderDays: reminderDays ?? 0, recurrence: recurrence ?? null, createdAt: new Date().toISOString(), }) state.calendarEvents.unshift(ev) queueCalendarEventsWrite() res.json({ ok: true, event: ev }) }) app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => { const { id } = req.params let found = false state.calendarEvents = state.calendarEvents.map(ev => { if (ev.id !== id) return ev found = true const patch = {} if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300) if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date if (typeof req.body?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined if (req.body?.startTime === null) patch.startTime = undefined if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000) if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined const dateChanged = patch.date && patch.date !== ev.date const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays if (dateChanged || reminderChanged) patch.reminderSentAt = undefined return { ...ev, ...patch } }) if (!found) { res.status(404).json({ message: 'Event not found.' }); return } queueCalendarEventsWrite() res.json({ ok: true }) }) app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => { const before = state.calendarEvents.length state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id) if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return } queueCalendarEventsWrite() res.json({ ok: true }) }) }