// A calendar file (.ics) any calendar app can import: one event per entry. Dates are plain calendar days // (all-day events) unless a time is given, in which case the event is at that clock time wherever the file is opened. const pad = (n) => String(n).padStart(2, '0'); const day = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; const stamp = (d) => `${day(d)}T${pad(d.getHours())}${pad(d.getMinutes())}00`; const utcStamp = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; // Escapes text for an ICS field, and folds long lines the way the format requires (75 characters). export const icsText = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); export function fold(line) { const out = []; let rest = line; while (rest.length > 75) { out.push(rest.slice(0, 75)); rest = ` ${rest.slice(75)}`; } out.push(rest); return out.join('\r\n'); } // events: [{ date: Date, title, description?, time?: 'HH:MM', minutes?: number }] -> the file's text export function buildIcs(events, { name = 'Bible study', now = new Date() } = {}) { const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bible Study Project//EN', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${icsText(name)}`]; events.forEach((e, i) => { lines.push('BEGIN:VEVENT', `UID:${utcStamp(now)}-${i}@bible-study-project`, `DTSTAMP:${utcStamp(now)}`); const m = /^(\d{1,2}):(\d{2})$/.exec(e.time ?? ''); if (m) { const start = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate(), Number(m[1]), Number(m[2])); const end = new Date(start.getTime() + (e.minutes ?? 60) * 60_000); lines.push(`DTSTART:${stamp(start)}`, `DTEND:${stamp(end)}`); } else { const next = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate() + 1); lines.push(`DTSTART;VALUE=DATE:${day(e.date)}`, `DTEND;VALUE=DATE:${day(next)}`); } lines.push(`SUMMARY:${icsText(e.title)}`); if (e.description) lines.push(`DESCRIPTION:${icsText(e.description)}`); lines.push('END:VEVENT'); }); lines.push('END:VCALENDAR'); return `${lines.map(fold).join('\r\n')}\r\n`; } // The date of each of `count` sessions: the first on `start`, then every `everyDays` days. export function sessionDates(start, count, everyDays = 7) { return Array.from({ length: count }, (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i * everyDays)); } export function downloadIcs(text, fileName) { const url = URL.createObjectURL(new Blob([text], { type: 'text/calendar;charset=utf-8' })); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }