5e43c41b2b
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
7.9 KiB
JavaScript
193 lines
7.9 KiB
JavaScript
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 })
|
|
})
|
|
}
|