Skip to content

Export and import calendar files

Pure functions turn a list of events into text: eventsToICS for a calendar app and eventsToCSV for a spreadsheet; icsToEvents reads a calendar file back. The export functions return strings, so they work in the browser for a “Download” button and on a server for a feed. They live in their own entry point, resource-scheduler/exchange: no React, no "use client", so a server component or a route handler can import them, and they stay out of the component bundle.

import { eventsToICS, eventsToCSV } from "resource-scheduler/exchange";
const ics = eventsToICS(events, { resources, name: "Meeting rooms" });
const csv = eventsToCSV(events, { resources });
function download(name: string, type: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type }));
const link = Object.assign(document.createElement("a"), { href: url, download: name });
link.click();
URL.revokeObjectURL(url);
}
download("rooms.ics", "text/calendar", ics);

events is a flat list. If yours live inside the resources, flatten them first and keep the resource id on each:

const events = resources.flatMap((r) => r.events.map((e) => ({ ...e, resourceId: r.id })));

resources is optional. With it, an event’s resourceId becomes a name (LOCATION in the calendar file, the resource column in the CSV); without it the id is used in the CSV and the calendar file has no location.

  • Timed events are written in UTC, so every calendar app places them at the right moment.
  • Whole days (an event that starts and ends at local midnight, like a rental) are written as all-day events. The end is exclusive, as it is in iCalendar.
  • Recurring events stay one event: recurrence becomes RRULE (FREQ, INTERVAL, BYDAY, BYMONTHDAY, BYSETPOS, UNTIL, COUNT, written by toRRule; “the last Friday” is BYDAY=-1FR) and exceptions become EXDATE, so the calendar app expands them itself.
  • Room names as locations. Passing resources writes the resource name as LOCATION. That suits rooms and equipment; for people, leave resources out.
  • The UID is <id>@resource-scheduler, the same on every export, which is what calendar apps use to recognise an event.
  • Text is escaped and lines are folded at 75 bytes without splitting a character.

The output is covered by tests, and it was also checked by parsing it back with the independent ical.js library: titles with commas, semicolons and non-Latin characters, a long folded title, an all-day event, two locations, and a weekly series with an exception all read back as written.

eventsToCSV writes id,title,resource,start,end,description, one row per resource of an event, with local dates like 2026-10-07 09:00. Cells with commas, quotes or line breaks are quoted.

A cell that starts with =, +, - or @ gets a leading '. Event titles often come from users, and a spreadsheet would run such a cell as a formula, which is how a hostile title can make a request or leak data from whoever opens the export. The quote is visible in the cell; strip it if you read the file back.

A recurring event is one row, with its first occurrence. For a row per occurrence, expand the series first:

import { expandEvents } from "resource-scheduler";
import { eventsToCSV } from "resource-scheduler/exchange";
const csv = eventsToCSV(expandEvents(events, from, to), { resources });

icsToEvents reads the events of an .ics file, from Google Calendar, Outlook, Apple Calendar or eventsToICS, into the events the scheduler takes. It works on the text of a file the user picks, so a calendar can be imported without a server.

import { icsToEvents } from "resource-scheduler/exchange";
const events = icsToEvents(await file.text(), {
resources, // match each LOCATION to a resource name
resourceId: "ann", // events that match none go to this row
onWarning: (message) => console.warn(message),
});
<ResourceScheduler resources={resources} events={events} />;

An event is drawn on the resource it names, so every imported event needs one. LOCATION is matched with the names in resources (ignoring case; a list such as Room A, Room B gives the event both), and resourceId catches the rest, for example a person’s own calendar imported into their row. Without either, an event has no resource and is not drawn.

What is read:

  • Times. UTC times (Z), times in a TZID zone and floating times become moments; dates become whole local days, with the exclusive end iCalendar uses. DTEND or DURATION gives the end; an event with neither lasts an hour, or a day when it has a date only. Zones are IANA names such as Europe/Paris, read with the browser’s own time zone data.
  • Repeats. RRULE and EXDATE become recurrence: hourly, daily, weekly, monthly and yearly rules, with INTERVAL, COUNT, UNTIL, BYDAY (also “the second Tuesday” as 2TU), BYMONTHDAY and BYSETPOS (read by parseRRule). An occurrence that was changed (RECURRENCE-ID) becomes its own event, with the id <uid>#<yyyy-MM-dd> (<uid>#<yyyy-MM-dd>T<HH:mm> for an hourly series), and its day is skipped in the series.
  • Ids. The UID becomes the id, without the @resource-scheduler that eventsToICS adds, so a round trip keeps your ids. Duplicates get a numeric suffix so ids stay unique.
  • Text. Titles and descriptions are unescaped and unfolded. They come from whoever wrote the file: render them as text, never as HTML.

What is left out (each one except a cancelled event sends a message to onWarning):

  • Cancelled events and cancelled occurrences (STATUS:CANCELLED).
  • A rule the scheduler cannot show: a BYMONTH other than the month the series starts in (or several months), BYYEARDAY, BYWEEKNO, minutely or secondly repeats, different positions on different weekdays (1MO,-1FR), WKST other than Monday on a rule every n weeks. The event is imported once, on its first day, and the message names the part.
  • A zone that is not an IANA name, such as Outlook’s “Eastern Standard Time”. Its times are read as local time. If your files come from Outlook, ask for UTC or convert the zone names first.
  • An event with no readable start, or one that ends before it starts, and the RDATE list.
  • Alarms, tasks, attendees and attachments are ignored without a message.

parseRRule and toRRule are the rule reader and writer that icsToEvents and eventsToICS use, exported for your own storage, an API or a form. They are pure and live in resource-scheduler/exchange.

import { parseRRule, toRRule } from "resource-scheduler/exchange";
const start = new Date(2026, 9, 30, 9); // DTSTART: the first occurrence
parseRRule("FREQ=MONTHLY;BYDAY=-1FR;COUNT=12", { start });
// { freq: "monthly", byWeekday: [5], bySetPos: [-1], count: 12 }
toRRule({ freq: "monthly", byWeekday: [5], bySetPos: [-1] });
// "FREQ=MONTHLY;BYDAY=-1FR" (add "RRULE:" for a line of an .ics file)
try {
parseRRule("FREQ=YEARLY;BYMONTH=3,9", { start });
} catch (error) {
// RangeError: BYMONTH=3,9: a yearly series repeats in the month it starts in (10)
}
  • Read: FREQ (hourly to yearly), INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYSETPOS, BYMONTH for yearly in the start month, and WKST=MO. The prefix RRULE: and the case are optional. BYDAY=2TU or -1FR (one weekday, any positions) become byWeekday and bySetPos; plain weekdays with BYSETPOS stay as they are.
  • Not guessed: anything else makes parseRRule throw a RangeError that names the part (BYYEARDAY is not supported), rather than dropping it and returning a rule that means something else. The same for values out of range and for a part the frequency cannot take (a BYDAY on a daily rule, BYMONTHDAY on a weekly one).
  • start is the first occurrence (DTSTART). A yearly rule repeats in the month it starts in, so parseRRule checks BYMONTH against it and toRRule writes it for a yearly rule with weekdays or month days. DTSTART and EXDATE are not part of the rule: exceptions are written and read as EXDATE.
  • UNTIL is a calendar day, as for recurrence.until. For an hourly rule it is the moment. toRRule writes the end of that day in UTC, or a date with { allDay: true }.
  • The output was checked against ical.js: the same occurrences (hourly included, which steps the clock as iCalendar does) for the last Friday, the second Tuesday, the last weekday, month days, Friday the 13th, yearly rules, hourly, COUNT and UNTIL.
  • Time zones in export. Dates are local Dates, so the UTC times in the calendar file are for the time zone of the machine that runs the function. A server in another zone writes different times for the same events.
  • Print styles. Use the browser’s own print for now.