Last active
March 21, 2024 17:11
-
-
Save hmarr/04fe143f34053768254ac150ff198e0d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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