Synchronizing Google Calendars

A Google Apps Script to keep calendars in sync

🕒 4 min read

Category: Code

Tags: code, google

At work, we use Google Calendars. I'm scheduling my workout/sports sessions in a separate, private Google Calendar. I'm also regularly on call during office hours - I'm getting assigned some days through another calendar provided by PagerDuty.

Screenshot of Google Calendar

I added these 2 additional calendars to my work calendar as "external calendars", which only I can see.

But I need my colleagues to see when I'm busy because I'm going for a run over the lunch break, or when I'm on call. Hence a need to keep these 3 calendars in sync. One caveat though is that I want to keep my sports sessions "private": my coworkers do not need to know whether I'm going running, or to the gym, or whathever. They just need to know that I'm not available.

So I came up with a Google Apps Script that periodically runs (every hour) and does the following:

Of course if I update or delete a sports session, or if my on-call schedule changes, it gets updated in my work calendar too.

Here is the script:

const CONFIG = {
    TARGET_EMAIL: "romain.pellerin@workemail",
    SYNC_DAYS: 14, // Number of days to look ahead
    TAG_PREFIX: "Source iCalUID:", // Unique identifier tag in description
    CALENDARS: [
        {
            id: "some-id@group.calendar.google.com", // sports
            isPrivate: true, // Will be renamed to 'Busy' and set private
        },
        {
            id: "some-other-id@import.calendar.google.com", // pager duty
            isPrivate: false,
        },
    ],
};

// THE MAIN FUNCTION, to call periodically.
function syncGoogleCalendars() {
    const userEmail = Session.getActiveUser().getEmail();
    if (userEmail !== CONFIG.TARGET_EMAIL) {
        Logger.log(`Skipping: Current user (${userEmail}) is not the target user.`);
        return;
    }

    const now = new Date();
    const futureDate = new Date(
        now.getTime() + CONFIG.SYNC_DAYS * 24 * 60 * 60 * 1000
    );

    const targetCalendar = CalendarApp.getDefaultCalendar();

    // Creates a Map: { 'source_uid_string' : CalendarEventObject }
    const existingEventsMap = getExistingManagedEvents(
        targetCalendar,
        now,
        futureDate
    );

    CONFIG.CALENDARS.forEach((sourceCalendarConfig) => {
        syncSourceCalendar(
        sourceCalendarConfig,
        targetCalendar,
        existingEventsMap,
        now,
        futureDate
        );
    });

    // Cleanup orphans: any event remaining in the map was NOT found in the source calendars, so it should be deleted.
    cleanupOrphanedEvents(existingEventsMap);
}

/**
* Fetches events from the target calendar and maps them by their Source UID.
* Only includes events created by this script (checked via regex).
*/
function getExistingManagedEvents(calendar, start, end) {
    const events = calendar.getEvents(start, end);
    const eventMap = new Map();
    const uidRegex = new RegExp(`${CONFIG.TAG_PREFIX}\\s*([\\w.@-]+)`);

    events.forEach((event) => {
        const match = event.getDescription().match(uidRegex);

        if (match && match[1]) {
        eventMap.set(match[1], event); // Map Key: the source event UID
        }
    });

    return eventMap;
}

function syncSourceCalendar(sourceCalendarConfig, targetCalendar, existingEventsMap, start, end) {
    const sourceCalendar = CalendarApp.getCalendarById(sourceCalendarConfig.id);
    if (!sourceCalendar) {
        Logger.log(`Error: Could not find calendar ${sourceCalendarConfig.id}`);
        return;
    }

    const sourceEvents = sourceCalendar.getEvents(start, end);
    Logger.log(
        `Processing ${sourceCalendarConfig.id}: ${sourceEvents.length} events found.`
    );

    sourceEvents.forEach((sourceEvent) => {
        // Weekday Filter
        const startTime = sourceEvent.getStartTime();
        const dayOfWeek = startTime.getDay(); // 0 = Sunday, 1 = Monday, ... 6 = Saturday

        // If it is Sunday (0) or Saturday (6), skip this event.
        if (dayOfWeek === 0 || dayOfWeek === 6) {
        Logger.log(
            `Skipping event on weekend from ${
            sourceCalendarConfig.id
            }: ${sourceEvent.getTitle()} on ${startTime}`
        );
        return;
        }

        const sourceUid = sourceEvent.getId();
        const existingEvent = existingEventsMap.get(sourceUid);

        // Determine Title and Visibility based on privacy setting
        const targetTitle = sourceCalendarConfig.isPrivate
        ? "Busy"
        : sourceEvent.getTitle();

        if (existingEvent) {
        updateEventIfChanged(existingEvent, sourceEvent, targetTitle);
        existingEventsMap.delete(sourceUid); // Remove from map to indicate this event is still valid (not an orphan)
        } else {
        createNewEvent(
            targetCalendar,
            sourceEvent,
            sourceUid,
            targetTitle,
            sourceCalendarConfig.isPrivate
        );
        }
    });
}

function createNewEvent(targetCalendar, sourceEvent, sourceUid, title, isPrivate) {
    Logger.log(`Creating "${title}" on ${sourceEvent.getStartTime()}`);

    const options = {
        description: `${CONFIG.TAG_PREFIX} ${sourceUid}\nCreated by Google Apps Script`,
        location: sourceEvent.getLocation(),
        sendInvites: false,
    };

    let newEvent;
    if (sourceEvent.isAllDayEvent()) {
        newEvent = targetCalendar.createAllDayEvent(
        title,
        sourceEvent.getStartTime(),
        options
        );
    } else {
        newEvent = targetCalendar.createEvent(
        title,
        sourceEvent.getStartTime(),
        sourceEvent.getEndTime(),
        options
        );
    }

    // Set specific privacy settings
    if (isPrivate) {
        newEvent.setVisibility(CalendarApp.Visibility.PRIVATE);
    }

    newEvent.removeAllReminders();
}

/**
* Note: We blindly update title/time to ensure data consistency.
* To further optimize, you could check if values differ before setting.
*/
function updateEventIfChanged(targetEvent, sourceEvent, targetTitle) {
    // We update the title to ensure "Busy" is maintained if the config changed or privacy was toggled
    targetEvent.setTitle(targetTitle);

    if (sourceEvent.isAllDayEvent()) {
        // For all day events, we generally just check the day, but setAllDayDate is safe
        targetEvent.setAllDayDate(sourceEvent.getStartTime());
    } else {
        targetEvent.setTime(sourceEvent.getStartTime(), sourceEvent.getEndTime());
    }
}

/**
* Deletes any events remaining in the map.
*/
function cleanupOrphanedEvents(orphanMap) {
    if (orphanMap.size === 0) {
        Logger.log("No orphaned events to delete.");
        return;
    }

    Logger.log(`Deleting ${orphanMap.size} orphaned events...`);

    for (const [uid, event] of orphanMap) {
        try {
        Logger.log(
            `Deleting ${event.getTitle()} (Source UID: ${uid}) on ${event.getStartTime()}`
        );
        event.deleteEvent();
        } catch (e) {
        Logger.log(`Failed to delete event ${uid}: ${e.message}`);
        }
    }
}
A screenshot of Google Calendar
And here is the result. In gray and purple are the original events from the external calendars.