@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
JavaScript
// 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
*/