UNPKG

@mildshield14/ical-booker

Version:

A lightweight, modern CalDAV client for Node.js - discover calendars, check availability, and create bookings

283 lines (278 loc) 9.59 kB
// src/lib/discover.ts import fetch from "node-fetch"; var xmlReq = (auth, depth = "0") => (body = "") => ({ method: "PROPFIND", headers: { Authorization: auth, "Content-Type": "application/xml", Depth: depth }, body }); async function discoverCalendars({ principal, // e.g. https://p55-caldav.icloud.com username, // full address password // app-specific pwd }) { const AUTH = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; const propfind = xmlReq(AUTH); const principalResp = await fetch( principal, propfind('<propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>') ); const principalXML = await principalResp.text(); console.debug("\u2460 status", principalResp.status, "\n", principalXML.slice(0, 400)); const princHref = principalXML.match(/<current-user-principal[^>]*>\s*<href[^>]*>([^<]+)</i)?.[1]; if (!princHref) throw new Error("Could not discover principal URL"); let homeURL; const homeResp = await fetch( new URL(princHref, principal).toString(), propfind('<propfind xmlns="DAV:"><prop><calendar-home-set/></prop></propfind>') ); const homeXML = await homeResp.text(); console.debug("\u2461 status", homeResp.status, "\n", homeXML.slice(0, 400)); const homeHref = homeXML.match(/<calendar-home-set[^>]*>\s*<href[^>]*>([^<]+)</i)?.[1]; if (homeHref) { homeURL = new URL(homeHref, principal).toString(); } if (!homeURL) { const userIdMatch = princHref.match(/\/(\d+)\/principal/); const userId = userIdMatch ? userIdMatch[1] : username.split("@")[0]; const candidates = [ // Standard CalDAV pattern new URL("./calendars/", new URL(princHref, principal)).toString(), // iCloud pattern with user ID from principal new URL(`/${userId}/calendars/`, principal).toString(), // iCloud pattern with email prefix (fallback) new URL(`/${username.split("@")[0]}/calendars/`, principal).toString() ]; console.log("\u{1F50D} Trying calendar-home-set fallback URLs:", candidates); for (const url of candidates) { try { const r = await fetch(url, { method: "PROPFIND", headers: { Authorization: AUTH, Depth: "1", "Content-Type": "application/xml" } }); console.log(`\u{1F4CD} Trying ${url}: ${r.status}`); if (r.status >= 200 && r.status < 300) { homeURL = url.endsWith("/") ? url : `${url}/`; console.log("\u2705 Found working calendar-home-set:", homeURL); break; } } catch (error) { console.log(`\u274C Failed to probe ${url}:`, error instanceof Error ? error.message : error); } } if (!homeURL) { throw new Error(`calendar-home-set not found on server. Tried: ${candidates.join(", ")}`); } console.log("\u26A0\uFE0F calendar-home-set missing; using fallback:", homeURL); } const listResp = await fetch( homeURL, xmlReq( AUTH, "1" )( `<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <prop><displayname/><resourcetype/></prop> </propfind>` ) ); const listXML = await listResp.text(); console.debug("\u2462 status", listResp.status, "\n", listXML.slice(0, 400)); if (listResp.status < 200 || listResp.status >= 300) { throw new Error(`Failed to list calendars: ${listResp.status} ${listResp.statusText}`); } const matches = listXML.matchAll(/<response[^>]*>[\s\S]*?<href[^>]*>([^<]+)<[\s\S]*?<displayname[^>]*>([^<]*)</gi); const calendars = []; for (const [, href, name] of matches) { if (!name?.trim() || !href || !/\/[A-Fa-f0-9-]+\/$/.test(href)) continue; calendars.push({ displayName: name.trim(), url: new URL(href, principal).toString() }); } console.log( `\u{1F4C5} Found ${calendars.length} calendars:`, calendars.map((c) => c.displayName) ); return calendars; } // src/lib/busy.ts import fetch2 from "node-fetch"; import ical from "node-ical"; import pkg from "rrule"; var { RRule } = pkg; var fmt = (d) => `${new Date(d).toISOString().replace(/[-:]/g, "").split(".")[0]}Z`; async function getBusyEvents(creds, calendars, startISO, endISO) { const range = { start: fmt(startISO), end: fmt(endISO) }; const AUTH = `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString("base64")}`; const report = `<?xml version="1.0"?> <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:"> <D:prop><C:calendar-data><C:expand start="${range.start}" end="${range.end}"/></C:calendar-data></D:prop> <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"> <C:time-range start="${range.start}" end="${range.end}"/> </C:comp-filter></C:comp-filter></C:filter> </C:calendar-query>`; const bodies = await Promise.all( calendars.map( ({ url }) => fetch2(url, { method: "REPORT", headers: { Authorization: AUTH, Depth: "1", "Content-Type": "application/xml" }, body: report }).then((r) => r.ok ? r.text() : "") ) ); const events = []; for (const xml of bodies) { for (const [, ics] of xml.matchAll( /<calendar-data[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/calendar-data>/gi )) { const parsed = ical.parseICS(ics); for (const ev of Object.values(parsed)) { if (!(ev && ev.type === "VEVENT" && ev.start && ev.end)) continue; if (!ev.rrule) { events.push({ start: ev.start.toISOString(), end: ev.end.toISOString(), title: ev.summary || "(no title)" }); continue; } const rule = RRule.fromString(ev.rrule.toString()); rule.between(new Date(startISO), new Date(endISO), true).forEach((dt) => { const duration = ev.end.getTime() - ev.start.getTime(); events.push({ start: dt.toISOString(), end: new Date(dt.getTime() + duration).toISOString(), title: ev.summary || "(no title)" }); }); } } } return events.sort((a, b) => a.start.localeCompare(b.start)); } // src/lib/book.ts import fetch3 from "node-fetch"; import { createEvent } from "ics"; async function createBooking(creds, { start, end, title, attendee }) { const icsValue = await new Promise((res, rej) => { createEvent( { start: toArr(start), end: toArr(end), title, attendees: attendee ? [{ email: attendee }] : [] }, (err, val) => err ? rej(err) : res(val) ); }); const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}.ics`; const url = creds.calendarURL.replace(/\/$/, "/") + uid; const ok = await fetch3(url, { method: "PUT", headers: { Authorization: `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString("base64")}`, "Content-Type": "text/calendar; charset=utf-8" }, body: icsValue }).then((r) => r.ok); if (!ok) throw new Error("PUT failed"); return { uid, url }; } function toArr(iso) { const d = new Date(iso); return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()]; } // src/lib/email.ts import fetch4 from "node-fetch"; async function sendBookingEmails(cfg, ev) { const templateParams = { event_title: ev.title, event_start: format(ev.start), event_end: format(ev.end), attendee_name: typeof ev.attendee === "string" ? ev.attendee.split("@")[0] || "Guest" : "Guest", attendee_email: typeof ev.attendee === "string" ? ev.attendee : "N/A", meeting_reason: "description" in ev && typeof ev.description === "string" ? ev.description : "", ics_url: ev.url, uid: ev.uid, from_name: cfg.fromName ?? "", from_email: cfg.fromEmail ?? "" }; if (ev.attendee) { await callEmailJs(cfg, { ...templateParams, to_email: ev.attendee, to_name: templateParams.attendee_name }); await callEmailJs(cfg, { ...templateParams, to_email: cfg.adminEmail ?? "", to_name: cfg.fromName ?? "" }); } await callEmailJs(cfg, { ...templateParams, to_email: cfg.adminEmail ?? "", to_name: cfg.fromName ?? "" }); } function callEmailJs(cfg, template_params) { return fetch4("https://api.emailjs.com/api/v1.0/email/send", { method: "POST", headers: { "Content-Type": "application/json", "X-EmailJS-Key": cfg.privateKey ?? "", // allow caller to decide the CORS origin (defaults to '*') origin: cfg.origin ?? "*" }, body: JSON.stringify({ service_id: cfg.serviceId ?? "", template_id: cfg.templateId ?? "", user_id: cfg.publicKey ?? "", accessToken: cfg.privateKey ?? "", template_params }) }).then((r) => { if (!r.ok) throw new Error(`EmailJS error ${r.status}`); }); } var format = (iso) => new Date(iso).toLocaleString("en-US", { timeZone: "America/Toronto", weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" }); // src/lib/index.ts var VERSION = "1.0.0"; var DEFAULT_CONFIG = { timeout: 3e4, maxRetries: 3, userAgent: "@mildshield14/ical-booker/1.0.0" }; export { DEFAULT_CONFIG, VERSION, createBooking, discoverCalendars, getBusyEvents, sendBookingEmails }; /** * @mildshield14/ical-booker * * A lightweight, modern CalDAV client for Node.js * Discover calendars, check availability, and create bookings * * @author mildshield14 * @version 1.0.0 * @license MIT */