Last active
May 14, 2026 16:52
-
-
Save cr0ybot/c4d21271b740bc098e79c135ba4ed766 to your computer and use it in GitHub Desktop.
SyncPersonalCalendar
This file contains hidden or 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
| { | |
| "timeZone": "America/Chicago", | |
| "dependencies": { | |
| "enabledAdvancedServices": [ | |
| { | |
| "userSymbol": "Calendar", | |
| "version": "v3", | |
| "serviceId": "calendar" | |
| } | |
| ] | |
| }, | |
| "exceptionLogging": "STACKDRIVER", | |
| "runtimeVersion": "V8", | |
| "oauthScopes": [ | |
| "https://www.googleapis.com/auth/calendar" | |
| ] | |
| } |
This file contains hidden or 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
| /** | |
| * Sync personal events with your work calendar. | |
| * | |
| * Uses sync tokens (via the advanced Calendar API) for efficient incremental updates. | |
| * Tracks a personalEventId → workEventId map in PropertiesService so that work | |
| * calendar events are deleted or updated when the originating personal event changes. | |
| * | |
| * Setup: | |
| * 1. From your *personal* calendar, add your work account in Calendar Settings > Share with specific people, your work cal needs at least "See all event details" access to your personal cal. | |
| * 2. Create an Apps Script project in your *work* Google account: https://script.google.com/u/0/home | |
| * 3. Add this file and the appsscript.json file. | |
| * 4. In the Apps Script project, go to Project Settings > Script Properties and add the following: | |
| * - fromEmail (required) — email address of the personal calendar to sync from | |
| * - daysOut (optional, default: 30) — how many days ahead to monitor on the initial full sync | |
| * - skipWeekends (optional, default: true) — set to "false" to also sync weekend events | |
| * - officeHoursStart (optional, default: 8) — work day start hour (24-hour) | |
| * - officeHoursEnd (optional, default: 16) — work day end hour (24-hour) | |
| * - label (optional, default: "Out of Office") — title prefix for copied events | |
| * - acceptCalendars (optional) — comma-separated list of *additional* calendar IDs (beyond fromEmail) | |
| * whose events should be synced, e.g. "friend@example.com,shared@example.com" | |
| * 5. Add a trigger for the `sync` function: | |
| * - Event source: From calendar | |
| * - Event calendar details: Calendar updated | |
| * - Calendar owner email: [your personal email address] | |
| * | |
| * @version 2.0.0 | |
| * @author Cory Hughart <cory@coryhughart.com> | |
| * @copyright 2026 Cory Hughart | |
| * @license https://www.gnu.org/licenses/gpl-3.0.html GPL-3.0-or-later | |
| * @link https://gist.github.com/cr0ybot/c4d21271b740bc098e79c135ba4ed766 | |
| * @ref https://medium.com/@willroman/auto-block-time-on-your-work-google-calendar-for-your-personal-events-2a752ae91dab | |
| */ | |
| function getConfig() { | |
| const props = PropertiesService.getScriptProperties().getProperties(); | |
| const toEmail = CalendarApp.getDefaultCalendar().getId(); | |
| const fromEmail = props['fromEmail']; | |
| if (!fromEmail) throw new Error('Script property "fromEmail" is required.'); | |
| const daysOut = parseInt(props['daysOut'] || '30', 10); | |
| const skipWeekends = (props['skipWeekends'] ?? 'true') !== 'false'; | |
| const officeHours = { | |
| start: parseInt(props['officeHoursStart'] || '8', 10), | |
| end: parseInt(props['officeHoursEnd'] || '16', 10), | |
| }; | |
| const label = props['label'] || 'Out of Office'; | |
| const extraCalendars = (props['acceptCalendars'] || '') | |
| .split(',') | |
| .map(s => s.trim()) | |
| .filter(Boolean); | |
| const acceptCalendars = [fromEmail, ...extraCalendars]; | |
| return { toEmail, fromEmail, daysOut, skipWeekends, officeHours, label, acceptCalendars }; | |
| } | |
| /** | |
| * Trigger entry point. Uses the stored sync token for an incremental update, or | |
| * falls back to a full sync over the next `daysOut` days when no token exists. | |
| * | |
| * @ref https://developers.google.com/apps-script/advanced/calendar#synchronizing_events | |
| */ | |
| function sync() { | |
| const config = getConfig(); | |
| const properties = PropertiesService.getUserProperties(); | |
| const syncToken = properties.getProperty('syncToken'); | |
| const eventMap = JSON.parse(properties.getProperty('eventMap') || '{}'); | |
| const toCal = CalendarApp.getDefaultCalendar(); | |
| const options = { maxResults: 100, singleEvents: true }; | |
| if (syncToken) { | |
| options.syncToken = syncToken; | |
| } else { | |
| // No stored token: fetch the upcoming window for the initial full sync | |
| const now = new Date(); | |
| const endDate = new Date(); | |
| endDate.setDate(now.getDate() + config.daysOut); | |
| options.timeMin = now.toISOString(); | |
| options.timeMax = endDate.toISOString(); | |
| } | |
| let pageToken; | |
| let nextSyncToken; | |
| do { | |
| options.pageToken = pageToken; | |
| let response; | |
| try { | |
| response = Calendar.Events.list(config.fromEmail, options); | |
| } catch (e) { | |
| if (e.message === 'Sync token is no longer valid, a full sync is required.') { | |
| // Token was invalidated server-side; drop it and redo as a full sync | |
| console.log('Sync token expired, falling back to full sync.'); | |
| properties.deleteProperty('syncToken'); | |
| sync(); | |
| return; | |
| } | |
| throw e; | |
| } | |
| for (const ev of (response.items || [])) { | |
| processEvent(ev, toCal, eventMap, config); | |
| } | |
| pageToken = response.nextPageToken; | |
| nextSyncToken = response.nextSyncToken; | |
| } while (pageToken); | |
| if (nextSyncToken) { | |
| properties.setProperty('syncToken', nextSyncToken); | |
| } | |
| properties.setProperty('eventMap', JSON.stringify(eventMap)); | |
| } | |
| // Evaluate a single personal calendar event and create, update, or remove its | |
| // corresponding work calendar OOO block as needed. | |
| function processEvent(ev, toCal, eventMap, config) { | |
| const { toEmail, fromEmail, skipWeekends, officeHours, label, acceptCalendars } = config; | |
| const workEventId = eventMap[ev.id]; | |
| // Remove the work event if the personal event was deleted or marked "free" | |
| // (transparency === 'transparent'). This replaces the Freebusy API check. | |
| if (ev.status === 'cancelled' || ev.transparency === 'transparent') { | |
| if (workEventId) removeWorkEvent(workEventId, ev.id, eventMap); | |
| return; | |
| } | |
| // Only process events whose organizer is in the accepted list (prevents spam) | |
| const organizer = ev.organizer?.email; | |
| if (!acceptCalendars.includes(organizer)) { | |
| if (workEventId) removeWorkEvent(workEventId, ev.id, eventMap); | |
| return; | |
| } | |
| // Skip events the personal account declined or hasn't responded to | |
| if (ev.attendees?.length) { | |
| const isOrganizer = organizer === fromEmail || organizer === toEmail; | |
| const me = ev.attendees.find(a => a.email === fromEmail || a.email === toEmail); | |
| if (!isOrganizer && (!me || me.responseStatus !== 'accepted')) { | |
| if (workEventId) removeWorkEvent(workEventId, ev.id, eventMap); | |
| return; | |
| } | |
| } | |
| // Ignore all-day events (for now); they use ev.start.date instead of ev.start.dateTime | |
| if (!ev.start?.dateTime) { | |
| if (workEventId) removeWorkEvent(workEventId, ev.id, eventMap); | |
| return; | |
| } | |
| const startTime = new Date(ev.start.dateTime); | |
| const endTime = new Date(ev.end.dateTime); | |
| // Skip weekends | |
| const dow = startTime.getDay(); | |
| if (skipWeekends && (dow < 1 || dow > 5)) { | |
| if (workEventId) removeWorkEvent(workEventId, ev.id, eventMap); | |
| return; | |
| } | |
| // Skip events outside work hours | |
| if (startTime.getHours() >= officeHours.end || endTime.getHours() <= officeHours.start) { | |
| if (workEventId) removeWorkEvent(workEventId, ev.id, eventMap); | |
| return; | |
| } | |
| const isPublic = ev.visibility === 'public'; | |
| const title = isPublic && ev.summary ? `${label} (${ev.summary})` : label; | |
| // If we already have a work event for this personal event, update it in place | |
| if (workEventId) { | |
| const workEvent = CalendarApp.getEventById(workEventId); | |
| if (workEvent) { | |
| const timeChanged = workEvent.getStartTime().getTime() !== startTime.getTime() | |
| || workEvent.getEndTime().getTime() !== endTime.getTime(); | |
| const titleChanged = workEvent.getTitle() !== title; | |
| if (timeChanged || titleChanged) { | |
| console.log(`Updating work event for personal event ${ev.id}: ${title}`); | |
| if (timeChanged) workEvent.setTime(startTime, endTime); | |
| if (titleChanged) workEvent.setTitle(title); | |
| } | |
| return; | |
| } | |
| // Work event was externally deleted; fall through to recreate it | |
| delete eventMap[ev.id]; | |
| } | |
| // Create a new OOO block on the work calendar | |
| console.log(`Creating work event for personal event ${ev.id}: ${title}`); | |
| const newEvent = toCal.createEvent(title, startTime, endTime, { eventType: 'outOfOffice' }); | |
| newEvent.removeAllReminders(); | |
| eventMap[ev.id] = newEvent.getId(); | |
| } | |
| // Delete the work calendar event and remove its entry from the map. | |
| function removeWorkEvent(workEventId, personalEventId, eventMap) { | |
| const workEvent = CalendarApp.getEventById(workEventId); | |
| if (workEvent) { | |
| console.log(`Removing work event ${workEventId} (personal event ${personalEventId} deleted/changed)`); | |
| workEvent.deleteEvent(); | |
| } | |
| delete eventMap[personalEventId]; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment