Last active
February 20, 2026 09:08
-
-
Save jpoutrin/e028db163b73b8deedd61399d642460d to your computer and use it in GitHub Desktop.
Calendar Sync
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
| /** | |
| * Calendar Sync Script | |
| * Syncs events from multiple source calendars to your main pro calendar as private blockers. | |
| * | |
| * CONFIGURATION: Edit the CONFIG object below with your calendar IDs | |
| */ | |
| const CONFIG = { | |
| // Your main professional calendar (where all events will be synced TO) | |
| // Use 'primary' for your default calendar, or the calendar ID | |
| targetCalendarId: 'CAL_ID_TARGET', | |
| // Source calendars to sync FROM (add as many as needed) | |
| // You can find calendar IDs in Google Calendar > Settings > Calendar settings | |
| sourceCalendars: [ | |
| { | |
| id: 'CAL ID', | |
| name: 'Perso', | |
| color: '2' // Optional: CalendarApp.EventColor (1-11) | |
| }, | |
| { | |
| id: 'CAR ID', | |
| name: 'CAL NAME', | |
| color: '9' | |
| }, | |
| // { | |
| // id: 'client2-calendar-id@group.calendar.google.com', | |
| // name: 'Client 2', | |
| // color: '3' | |
| // } | |
| // Add more calendars as needed | |
| ], | |
| // How many days in the past and future to sync | |
| daysInPast: 7, | |
| daysInFuture: 60, | |
| // Prefix for synced events (helps identify them) | |
| syncPrefix: '🔒 ', | |
| // Unique identifier stored in event description to track synced events | |
| syncTag: '[CALENDAR-SYNC-ID:', | |
| excludeTag: '#nosync' | |
| }; | |
| /** | |
| * Main sync function - run this manually or via trigger | |
| */ | |
| function syncAllCalendars() { | |
| console.log('Starting calendar sync...'); | |
| const targetCalendar = CalendarApp.getCalendarById(CONFIG.targetCalendarId) | |
| || CalendarApp.getDefaultCalendar(); | |
| if (!targetCalendar) { | |
| console.error('Target calendar not found!'); | |
| return; | |
| } | |
| const now = new Date(); | |
| const startDate = new Date(now.getTime() - (CONFIG.daysInPast * 24 * 60 * 60 * 1000)); | |
| const endDate = new Date(now.getTime() + (CONFIG.daysInFuture * 24 * 60 * 60 * 1000)); | |
| // Get all existing synced events in target calendar | |
| const existingSyncedEvents = getExistingSyncedEvents(targetCalendar, startDate, endDate); | |
| const processedEventIds = new Set(); | |
| // Process each source calendar | |
| for (const sourceConfig of CONFIG.sourceCalendars) { | |
| console.log(`Processing: ${sourceConfig.name}`); | |
| try { | |
| const sourceCalendar = CalendarApp.getCalendarById(sourceConfig.id); | |
| if (!sourceCalendar) { | |
| console.warn(`Calendar not found or not accessible: ${sourceConfig.name} (${sourceConfig.id})`); | |
| continue; | |
| } | |
| const sourceEvents = sourceCalendar.getEvents(startDate, endDate); | |
| console.log(`Found ${sourceEvents.length} events in ${sourceConfig.name}`); | |
| for (const sourceEvent of sourceEvents) { | |
| // const sourceTitleDebug = sourceEvent.getTitle(); | |
| // const sourceEventId = sourceEvent.getId(); | |
| const syncId = generateSyncId(sourceConfig.id, sourceEvent); | |
| processedEventIds.add(syncId); | |
| syncEvent(sourceEvent, targetCalendar, sourceConfig, syncId, existingSyncedEvents); | |
| } | |
| } catch (error) { | |
| console.error(`Error processing ${sourceConfig.name}: ${error.message}`); | |
| } | |
| } | |
| // Clean up orphaned events (deleted from source) | |
| cleanupOrphanedEvents(existingSyncedEvents, processedEventIds); | |
| console.log('Calendar sync completed!'); | |
| } | |
| /** | |
| * Get all events in target calendar that were created by this sync | |
| */ | |
| function getExistingSyncedEvents(calendar, startDate, endDate) { | |
| const events = calendar.getEvents(startDate, endDate); | |
| const syncedEvents = new Map(); | |
| for (const event of events) { | |
| // const title = event.getTitle(); | |
| const description = event.getDescription() || ''; | |
| const syncIdMatch = description.match(/\[CALENDAR-SYNC-ID:([^\]]+)\]/); | |
| if (syncIdMatch) { | |
| syncedEvents.set(syncIdMatch[1], event); | |
| } | |
| } | |
| return syncedEvents; | |
| } | |
| /** | |
| * Generate a unique sync ID for tracking events | |
| */ | |
| function generateSyncId(calendarId, event) { | |
| const eventId = event.getId(); | |
| const startTime = event.getStartTime().getTime(); | |
| const combined = `${calendarId}|${eventId}|${startTime}`; | |
| const hash = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, combined); | |
| return hash.map(b => ('0' + (b & 0xFF).toString(16)).slice(-2)).join(''); | |
| } | |
| /** | |
| * Sync a single event to the target calendar | |
| */ | |
| function syncEvent(sourceEvent, targetCalendar, sourceConfig, syncId, existingSyncedEvents) { | |
| // Skip if the source event is already a synced event (avoid loops) | |
| const sourceDescription = sourceEvent.getDescription() || ''; | |
| if (sourceDescription.includes(CONFIG.syncTag)) { | |
| return; | |
| } | |
| const eventDay = sourceEvent.getStartTime().getDay(); | |
| if (eventDay === 0 || eventDay === 6) { | |
| return; | |
| } | |
| const existingEvent = existingSyncedEvents.get(syncId); | |
| // Prepare event details | |
| const originalTitle = sourceEvent.getTitle(); | |
| const originalDescription = sourceEvent.getDescription(); | |
| const title = `${CONFIG.syncPrefix}${sourceConfig.name}: ${originalTitle}`; | |
| const description = `Synced from: ${sourceConfig.name}\n${CONFIG.syncTag}${syncId}]`; | |
| if (originalTitle.includes(CONFIG.excludeTag) || originalDescription.includes(CONFIG.excludeTag)) { | |
| return; | |
| } | |
| const isAllDay = sourceEvent.isAllDayEvent(); | |
| const startTime = sourceEvent.getStartTime(); | |
| const endTime = sourceEvent.getEndTime(); | |
| const status = sourceEvent.getMyStatus(); | |
| // Determine if should show as busy or free | |
| // If declined or the original is set to "free", mark as free | |
| const guestsCanSee = sourceEvent.guestsCanSeeGuests(); | |
| const eventType = sourceEvent.getEventType().toString(); | |
| if (existingEvent) { | |
| // Update existing synced event | |
| if (needsUpdate(existingEvent, sourceEvent, title)) { | |
| try { | |
| if (isAllDay) { | |
| // For all-day events, we need to delete and recreate | |
| // because you can't change an event from timed to all-day | |
| existingEvent.deleteEvent(); | |
| createSyncedEvent(targetCalendar, sourceEvent, title, description, sourceConfig, isAllDay); | |
| } else { | |
| existingEvent.setTime(startTime, endTime); | |
| existingEvent.setTitle(title); | |
| existingEvent.setDescription(description); | |
| // existingEvent.setVisibility(CalendarApp.Visibility.PRIVATE); | |
| } | |
| console.log(`Updated: ${title}`); | |
| } catch (error) { | |
| console.error(`Error updating event: ${error.message}`); | |
| } | |
| } | |
| } else { | |
| // Create new synced event | |
| createSyncedEvent(targetCalendar, sourceEvent, title, description, sourceConfig, isAllDay); | |
| } | |
| } | |
| /** | |
| * Create a new synced event in the target calendar | |
| */ | |
| function createSyncedEvent(targetCalendar, sourceEvent, title, description, sourceConfig, isAllDay) { | |
| try { | |
| let newEvent; | |
| if (isAllDay) { | |
| const startDate = sourceEvent.getAllDayStartDate(); | |
| const endDate = sourceEvent.getAllDayEndDate(); | |
| // Check if multi-day | |
| const daysDiff = Math.round((endDate - startDate) / (24 * 60 * 60 * 1000)); | |
| if (daysDiff > 1) { | |
| newEvent = targetCalendar.createAllDayEvent(title, startDate, endDate); | |
| } else { | |
| newEvent = targetCalendar.createAllDayEvent(title, startDate); | |
| } | |
| } else { | |
| newEvent = targetCalendar.createEvent(title, sourceEvent.getStartTime(), sourceEvent.getEndTime()); | |
| } | |
| newEvent.setDescription(description); | |
| newEvent.setVisibility(CalendarApp.Visibility.PRIVATE); | |
| if (sourceEvent.getEventType() === CalendarApp.EventType.WORKING_LOCATION) { | |
| newEvent.setVisibility(CalendarApp.Visibility.PUBLIC); | |
| } else { | |
| newEvent.setVisibility(CalendarApp.Visibility.PRIVATE); | |
| } | |
| // Set color if specified | |
| if (sourceConfig.color) { | |
| newEvent.setColor(sourceConfig.color); | |
| } | |
| // Remove any default notifications (optional - keeps it clean) | |
| newEvent.removeAllReminders(); | |
| console.log(`Created: ${title}`); | |
| } catch (error) { | |
| console.error(`Error creating event: ${error.message}`); | |
| } | |
| } | |
| /** | |
| * Check if an existing synced event needs to be updated | |
| */ | |
| function needsUpdate(existingEvent, sourceEvent, newTitle) { | |
| const existingTitle = existingEvent.getTitle(); | |
| if (existingTitle !== newTitle) return true; | |
| if (sourceEvent.isAllDayEvent() !== existingEvent.isAllDayEvent()) return true; | |
| if (!sourceEvent.isAllDayEvent()) { | |
| if (existingEvent.getStartTime().getTime() !== sourceEvent.getStartTime().getTime()) return true; | |
| if (existingEvent.getEndTime().getTime() !== sourceEvent.getEndTime().getTime()) return true; | |
| } else { | |
| if (existingEvent.getAllDayStartDate().getTime() !== sourceEvent.getAllDayStartDate().getTime()) return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Remove synced events that no longer exist in source calendars | |
| */ | |
| function cleanupOrphanedEvents(existingSyncedEvents, processedEventIds) { | |
| let cleaned = 0; | |
| for (const [syncId, event] of existingSyncedEvents) { | |
| if (!processedEventIds.has(syncId)) { | |
| try { | |
| console.log(`Removing orphaned event: ${event.getTitle()}`); | |
| event.deleteEvent(); | |
| cleaned++; | |
| } catch (error) { | |
| console.error(`Error deleting orphaned event: ${error.message}`); | |
| } | |
| } | |
| } | |
| if (cleaned > 0) { | |
| console.log(`Cleaned up ${cleaned} orphaned events`); | |
| } | |
| } | |
| /** | |
| * Setup automatic trigger - run this once manually | |
| */ | |
| function setupTrigger() { | |
| // Remove existing triggers for this function | |
| const triggers = ScriptApp.getProjectTriggers(); | |
| for (const trigger of triggers) { | |
| if (trigger.getHandlerFunction() === 'syncAllCalendars') { | |
| ScriptApp.deleteTrigger(trigger); | |
| } | |
| } | |
| // Create new trigger - runs every 15 minutes | |
| ScriptApp.newTrigger('syncAllCalendars') | |
| .timeBased() | |
| .everyMinutes(15) | |
| .create(); | |
| console.log('Trigger created! Sync will run every 15 minutes.'); | |
| } | |
| /** | |
| * Remove all triggers - run if you want to stop syncing | |
| */ | |
| function removeTriggers() { | |
| const triggers = ScriptApp.getProjectTriggers(); | |
| for (const trigger of triggers) { | |
| if (trigger.getHandlerFunction() === 'syncAllCalendars') { | |
| ScriptApp.deleteTrigger(trigger); | |
| } | |
| } | |
| console.log('All sync triggers removed.'); | |
| } | |
| /** | |
| * Manual cleanup - removes ALL synced events from target calendar | |
| * Use with caution! | |
| */ | |
| function removeAllSyncedEvents() { | |
| const targetCalendar = CalendarApp.getCalendarById(CONFIG.targetCalendarId) | |
| || CalendarApp.getDefaultCalendar(); | |
| const now = new Date(); | |
| const startDate = new Date(now.getTime() - (CONFIG.daysInPast * 24 * 60 * 60 * 1000)); | |
| const endDate = new Date(now.getTime() + (CONFIG.daysInFuture * 24 * 60 * 60 * 1000)); | |
| const events = targetCalendar.getEvents(startDate, endDate); | |
| let removed = 0; | |
| for (const event of events) { | |
| const description = event.getDescription() || ''; | |
| if (description.includes(CONFIG.syncTag)) { | |
| event.deleteEvent(); | |
| removed++; | |
| } | |
| } | |
| console.log(`Removed ${removed} synced events.`); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment