Skip to content

Instantly share code, notes, and snippets.

@hmarr
Last active March 21, 2024 17:11
Show Gist options
  • Save hmarr/04fe143f34053768254ac150ff198e0d to your computer and use it in GitHub Desktop.
Save hmarr/04fe143f34053768254ac150ff198e0d to your computer and use it in GitHub Desktop.
// Setup instructions
//
// Copy and paste into a Google Apps Script project. Make sure you set the source calendar id constant
// below. It should be set to the email address of the source calendar.
//
// Add a trigger to run the syncUpdatedEventsIncremental function the on the "Calendar - Changed" event
// of the source calendar.
//
// The first time you run this, you might want to select the "resyncEvents" function, and manually run
// it in the Apps Script editor.
// Set this to the calendar to pull events from
const SOURCE_CALENDAR_ID = "CHANGE-ME";
// This is the calendar to sync events to - change if not your primary calendar
const DEST_CALENDAR_ID = "primary";
function syncUpdatedEventsIncremental(e) {
const sourceCalendarId = e?.calendarId ?? SOURCE_CALENDAR_ID;
syncEvents(SOURCE_CALENDAR_ID, DEST_CALENDAR_ID, getUpdatedCalendarEvents(sourceCalendarId, false));
}
function resyncEvents(e) {
syncEvents(SOURCE_CALENDAR_ID, DEST_CALENDAR_ID, getUpdatedCalendarEvents(SOURCE_CALENDAR_ID, true));
}
function syncEvents(sourceCalendarId, destCalendarId, events) {
for (const event of events) {
if (event.status === "cancelled") {
deleteSyncedEvent(destCalendarId, event);
continue;
}
if (event.eventType !== "default") {
continue;
}
syncEvent(sourceCalendarId, destCalendarId, event);
}
}
function deleteAllSyncedEvents() {
const events = Calendar.Events.list(DEST_CALENDAR_ID, {
privateExtendedProperty: "syncedCopy=true"
}).items;
for (const event of events) {
console.log("Deleting", event.summary);
try {
Calendar.Events.remove(DEST_CALENDAR_ID, event.id);
} catch (err) {
console.error("Error deleting event %s: %s", event.id, err.message);
}
}
}
function syncEvent(sourceCalendarId, destCalendarId, event, retry=true) {
let destEvent;
try {
destEvent = Calendar.Events.get(destCalendarId, destEventId(destCalendarId, event));
} catch (err) {
if (err && err.details && err.details.code === 404) {
console.log("Event %s doesn't exist in destination calendar, creating", event.summary);
const copiedEvent = copySourceEventDetails(sourceCalendarId, destCalendarId, event, {});
try {
Calendar.Events.insert(copiedEvent, destCalendarId);
} catch (err) {
if (retry) {
console.log("Event %s already exists (race condition), retrying", event.summary);
return syncEvent(sourceCalendarId, destCalendarId, event, false);
}
throw err;
}
return;
} else {
throw err;
}
}
console.log("Updating event %s (%s)", event.summary, event.id);
const copiedEvent = copySourceEventDetails(sourceCalendarId, destCalendarId, event, destEvent);
Calendar.Events.update(copiedEvent, destCalendarId, destEvent.id);
}
function deleteSyncedEvent(destCalendarId, event) {
const id = destEventId(destCalendarId, event);
let destEvent;
try {
destEvent = Calendar.Events.get(destCalendarId, id);
} catch (err) {
if (err && err.details && err.details.code === 404) {
// Event doesn't exist, no need to do anything
console.log("Event %s not found, ignoring", event.id);
return;
} else {
throw err;
}
}
if (destEvent.status === "cancelled") {
console.log("Event %s already cancelled, ignoring", destEvent.summary);
return;
}
console.log("Removing %s from calendar", destEvent.summary);
Calendar.Events.remove(destCalendarId, id);
}
function copySourceEventDetails(sourceCalendarId, destCalendarId, sourceEvent, destEvent) {
destEvent.id = destEventId(destCalendarId, sourceEvent),
destEvent.status = sourceEvent.status ?? "confirmed";
destEvent.start = sourceEvent.start;
destEvent.end = sourceEvent.end;
destEvent.summary = sourceEvent.summary;
if (sourceEvent.visibility === "private") {
destEvent.summary = "busy";
}
destEvent.visibility = "private";
destEvent.colorId = sourceEvent.colorId ?? CalendarApp.EventColor.GRAY;
destEvent.recurrence = sourceEvent.recurrence;
destEvent.extendedProperties = {
private: {
sourceCalendarId,
sourceEventId: sourceEvent.id,
syncedCopy: true
}
};
if (sourceEvent.recurringEventId) {
destEvent.recurringEventId = namespacedEventId(destCalendarId, sourceEvent.recurringEventId);
destEvent.originalStartTime = sourceEvent.originalStartTime;
destEvent.extendedProperties.sourceRecurringEventId = sourceEvent.recurringEventId;
}
return destEvent;
}
function destEventId(destCalendarId, event) {
let id = namespacedEventId(destCalendarId, event.id);
if (event.recurringEventId) {
const parentId = namespacedEventId(destCalendarId, event.recurringEventId);
const instanceSuffix = event.id.split("_", 2)[1];
id = parentId + "_" + instanceSuffix;
}
return id;
}
function namespacedEventId(namespace, eventId) {
return strToHex(namespace) + "00" + eventId.toLowerCase().replace(/[^a-v0-9]+/g, "");
}
function strToHex(str) {
return Utilities.newBlob(str)
.getBytes()
.map((byte) => ("0" + (byte & 0xff).toString(16)).slice(-2))
.join("");
}
/**
* Find and return events in the given calendar that have been modified
* since the last sync. If the sync token is missing or invalid, return all
* events from up to a week ago (a full sync).
*
* @param {string} calendarId The ID of the calender to retrieve events from.
* @param {boolean} fullSync If true, throw out any existing sync token and
* perform a full sync; if false, use the existing sync token if possible.
*/
function getUpdatedCalendarEvents(calendarId, fullSync) {
console.log("Fetching updated calendar events, full sync =", fullSync);
const properties = PropertiesService.getUserProperties();
const options = { maxResults: 100 };
const syncToken = properties.getProperty('syncToken');
if (syncToken && !fullSync) {
options.syncToken = syncToken;
} else {
// Sync events up to 7 days in the past.
options.timeMin = getRelativeDate(-7, 0).toISOString();
// And up to 14 days in the future.
options.timeMax = getRelativeDate(30, 0).toISOString();
options.showDeleted = true;
}
// Retrieve events one page at a time.
let events = [];
let pageToken;
let response;
do {
try {
options.pageToken = pageToken;
response = Calendar.Events.list(calendarId, options);
events = events.concat(response.items);
} catch (e) {
// Check to see if the sync token was invalidated by the server;
// if so, perform a full sync instead.
if (e.message === 'Sync token is no longer valid, a full sync is required.') {
properties.deleteProperty('syncToken');
return getUpdatedCalendarEvents(calendarId, true);
}
throw new Error(e.message);
}
pageToken = response.nextPageToken;
} while (pageToken);
properties.setProperty('syncToken', response.nextSyncToken);
return events;
}
/**
* Helper function to get a new Date object relative to the current date.
* @param {number} daysOffset The number of days in the future for the new date.
* @param {number} hour The hour of the day for the new date, in the time zone
* of the script.
* @return {Date} The new date.
*/
function getRelativeDate(daysOffset, hour) {
const date = new Date();
date.setDate(date.getDate() + daysOffset);
date.setHours(hour);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment