Files
study-bible-project/src/utils/icsFile.js
T
nmemmertandClaude Sonnet 5.5 54cb1c217d
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:26:43 -04:00

53 lines
2.8 KiB
JavaScript

// 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);
}