Last active
March 7, 2026 21:02
-
-
Save patforna/94f03ed05c2d570a0eb2bcaf4d06598e to your computer and use it in GitHub Desktop.
Google Calendar Auto-Colorizer (Jane's version)
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
| // === Google Calendar Auto-Colorizer (Jane's version) === | |
| // | |
| // Automatically colors calendar events based on organizer and attendee roles. | |
| // | |
| // Color rules: | |
| // Organizer | Condition | Color | |
| // ------------|------------------------------------|----------------- | |
| // Patric | Jane not accepted OR optional | Blue (Peacock) | |
| // Patric | Jane accepted AND not optional | Yellow (Banana) | |
| // Jane | Patric included + required | Yellow (Banana) | |
| // Jane | Patric not included, or optional | Do nothing (Tomato default) | |
| // Someone else| — | Do nothing | |
| // | |
| // Manual override: | |
| // To override the script's color for any event, simply set the color manually | |
| // in Google Calendar. The script tracks which colors it set via a hidden | |
| // extended property (autoColored). If an event has a color but no autoColored | |
| // property, the script assumes it was set manually and will never touch it. | |
| // | |
| // How it works: | |
| // The onEventUpdated trigger fires whenever any event on the calendar changes, | |
| // but it only provides the calendar ID — not the changed event. To find what | |
| // changed, we use a sync token with Calendar.Events.list(), which returns only | |
| // events modified since the last call. The 90-day scan only happens on the very | |
| // first run to establish the initial sync token. After that, each trigger | |
| // typically processes just 1-2 changed events. | |
| // | |
| // Setup: | |
| // 1. Go to https://script.google.com → New project | |
| // 2. Name it "Calendar Colorizer" | |
| // 3. Paste this entire file into the editor (replace any existing code) | |
| // 4. In the left sidebar, click Services (+) → select "Google Calendar API" → Add | |
| // 5. In the function dropdown (top bar), select "setup" → click Run | |
| // 6. When prompted, click Advanced → "Go to Calendar Colorizer (unsafe)" → Allow | |
| // 7. Done! The script is now live and will auto-color new/updated events | |
| // | |
| // Utilities: | |
| // colorizeDryRun() — logs what would change without modifying anything (next 365 days) | |
| // colorizeAll() — bulk colorize existing events (next 365 days) | |
| // resetSyncToken() — clears the sync token to force a full re-scan on next run | |
| // | |
| // === Configuration === | |
| var PATRIC_EMAIL = 'patric.fornasier@gmail.com'; | |
| var CALENDAR_ID = 'primary'; | |
| var COLOR_PEACOCK = '7'; // Blue | |
| var COLOR_BANANA = '5'; // Yellow | |
| var AUTO_COLOR_KEY = 'autoColored'; // extended property key to track script-set colors | |
| // === Main handler — called by onEventUpdated trigger === | |
| function onCalendarChange(e) { | |
| try { | |
| processCalendarChanges(e); | |
| } catch (err) { | |
| MailApp.sendEmail( | |
| Session.getActiveUser().getEmail(), | |
| 'Calendar Colorizer failed', | |
| 'Error: ' + err.message + '\n\nStack: ' + err.stack | |
| ); | |
| throw err; // re-throw so it also shows in Apps Script logs | |
| } | |
| } | |
| function processCalendarChanges(e) { | |
| var calendarId = (e && e.calendarId) || CALENDAR_ID; | |
| var props = PropertiesService.getUserProperties(); | |
| var syncToken = props.getProperty('syncToken'); | |
| var options = { maxResults: 250 }; | |
| if (syncToken) { | |
| options.syncToken = syncToken; | |
| } else { | |
| // First run: scan upcoming 90 days | |
| options.timeMin = new Date().toISOString(); | |
| options.timeMax = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); | |
| } | |
| var pageToken; | |
| do { | |
| if (pageToken) options.pageToken = pageToken; | |
| var events; | |
| try { | |
| events = Calendar.Events.list(calendarId, options); | |
| } catch (err) { | |
| if (err.message && err.message.indexOf('Sync token') !== -1) { | |
| // Token invalidated — full re-sync | |
| props.deleteProperty('syncToken'); | |
| delete options.syncToken; | |
| options.timeMin = new Date().toISOString(); | |
| options.timeMax = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); | |
| events = Calendar.Events.list(calendarId, options); | |
| } else { | |
| throw err; | |
| } | |
| } | |
| if (events.items) { | |
| events.items.forEach(function(event) { | |
| colorizeEvent(calendarId, event); | |
| }); | |
| } | |
| pageToken = events.nextPageToken; | |
| if (!pageToken && events.nextSyncToken) { | |
| props.setProperty('syncToken', events.nextSyncToken); | |
| } | |
| } while (pageToken); | |
| } | |
| // === Color logic === | |
| var COLOR_NAMES = { '7': 'BLUE', '5': 'YELLOW' }; | |
| function colorizeEvent(calendarId, event, dryRun) { | |
| if (event.status === 'cancelled') return null; | |
| var organizer = event.organizer; | |
| if (!organizer) return null; | |
| var attendees = event.attendees || []; | |
| var targetColor = null; | |
| if (isPatric(organizer.email)) { | |
| // Organizer is Patric | |
| var me = findSelf(attendees); | |
| if (!me) return null; // not an attendee (e.g. shared calendar artifact) → ignore | |
| if (me.responseStatus !== 'accepted' || me.optional) { | |
| targetColor = COLOR_PEACOCK; // Blue — not accepted or FYI from Patric | |
| } else { | |
| targetColor = COLOR_BANANA; // Yellow — accepted family event | |
| } | |
| } else if (organizer.self) { | |
| // Organizer is me (Jane) | |
| var patric = findPatric(attendees); | |
| if (patric && !patric.optional) { | |
| targetColor = COLOR_BANANA; // Yellow — family event I organised | |
| } | |
| // else: Patric not included or optional → do nothing | |
| } | |
| // else: someone else is organizer → ignore | |
| // Skip if no action needed | |
| if (!targetColor || event.colorId === targetColor) return null; | |
| // Skip if the event has a color that was manually set (not by this script) | |
| var autoColored = event.extendedProperties | |
| && event.extendedProperties.private | |
| && event.extendedProperties.private[AUTO_COLOR_KEY]; | |
| if (event.colorId && !autoColored) { | |
| if (dryRun) { | |
| var date = event.start && (event.start.date || event.start.dateTime || ''); | |
| Logger.log('[DRY RUN] Skipped "%s" (%s) — has manually set color', event.summary, date); | |
| } | |
| return null; | |
| } | |
| var colorName = COLOR_NAMES[targetColor]; | |
| var date = event.start && (event.start.date || event.start.dateTime || ''); | |
| if (dryRun) { | |
| Logger.log('[DRY RUN] Would set %s on "%s" (%s)', colorName, event.summary, date); | |
| } else { | |
| patchWithRetry(calendarId, event.id, { | |
| colorId: targetColor, | |
| extendedProperties: { private: { autoColored: 'true' } } | |
| }); | |
| Logger.log('Set %s on "%s" (%s)', colorName, event.summary, date); | |
| } | |
| return { summary: event.summary, date: date, color: colorName }; | |
| } | |
| // === Patch with rate-limit retry === | |
| function patchWithRetry(calendarId, eventId, payload) { | |
| for (var attempt = 0; attempt < 3; attempt++) { | |
| try { | |
| Calendar.Events.patch(payload, calendarId, eventId); | |
| Utilities.sleep(200); // throttle to stay under rate limit | |
| return; | |
| } catch (err) { | |
| if (err.message && (err.message.indexOf('Rate Limit') !== -1 || err.message.indexOf('Quota exceeded') !== -1) && attempt < 2) { | |
| Utilities.sleep(2000 * (attempt + 1)); // back off: 2s, 4s | |
| } else { | |
| throw err; | |
| } | |
| } | |
| } | |
| } | |
| // === Helpers === | |
| function isPatric(email) { | |
| return email && email.toLowerCase() === PATRIC_EMAIL.toLowerCase(); | |
| } | |
| function findPatric(attendees) { | |
| for (var i = 0; i < attendees.length; i++) { | |
| if (isPatric(attendees[i].email)) return attendees[i]; | |
| } | |
| return null; | |
| } | |
| function findSelf(attendees) { | |
| for (var i = 0; i < attendees.length; i++) { | |
| if (attendees[i].self) return attendees[i]; | |
| } | |
| return null; | |
| } | |
| // === Setup — run this once manually === | |
| function setup() { | |
| // Remove any existing triggers for this function | |
| ScriptApp.getProjectTriggers().forEach(function(t) { | |
| if (t.getHandlerFunction() === 'onCalendarChange') { | |
| ScriptApp.deleteTrigger(t); | |
| } | |
| }); | |
| // Install the onEventUpdated trigger for your primary calendar | |
| ScriptApp.newTrigger('onCalendarChange') | |
| .forUserCalendar(Session.getActiveUser().getEmail()) | |
| .onEventUpdated() | |
| .create(); | |
| // Establish sync token without colorizing existing events. | |
| // This does a full list to get the token, then discards the results. | |
| // Only events created/updated after this point will be colorized. | |
| initSyncToken(CALENDAR_ID); | |
| Logger.log('Setup complete. Trigger installed. Only new/updated events will be colorized.'); | |
| } | |
| // === Utility: establish sync token without processing events === | |
| function initSyncToken(calendarId) { | |
| var props = PropertiesService.getUserProperties(); | |
| var options = { | |
| maxResults: 250, | |
| timeMin: new Date().toISOString(), | |
| timeMax: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() | |
| }; | |
| var pageToken; | |
| do { | |
| if (pageToken) options.pageToken = pageToken; | |
| var events = Calendar.Events.list(calendarId, options); | |
| pageToken = events.nextPageToken; | |
| if (!pageToken && events.nextSyncToken) { | |
| props.setProperty('syncToken', events.nextSyncToken); | |
| } | |
| } while (pageToken); | |
| } | |
| // === Utility: bulk colorize all events in the next 365 days === | |
| function colorizeAll() { | |
| var count = processAllEvents(false); | |
| Logger.log('Colorize complete. %s events updated.', count); | |
| } | |
| // === Utility: dry run — logs what would change without modifying anything === | |
| function colorizeDryRun() { | |
| var count = processAllEvents(true); | |
| Logger.log('Dry run complete. %s events would be updated.', count); | |
| } | |
| function processAllEvents(dryRun) { | |
| var options = { | |
| maxResults: 250, | |
| timeMin: new Date().toISOString(), | |
| timeMax: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() | |
| }; | |
| var count = 0; | |
| var pageToken; | |
| do { | |
| if (pageToken) options.pageToken = pageToken; | |
| var events = Calendar.Events.list(CALENDAR_ID, options); | |
| if (events.items) { | |
| events.items.forEach(function(event) { | |
| if (colorizeEvent(CALENDAR_ID, event, dryRun)) count++; | |
| }); | |
| } | |
| pageToken = events.nextPageToken; | |
| } while (pageToken); | |
| return count; | |
| } | |
| // === Utility: reset sync token (if you need a fresh full scan) === | |
| function resetSyncToken() { | |
| PropertiesService.getUserProperties().deleteProperty('syncToken'); | |
| Logger.log('Sync token cleared. Next run will do a full scan.'); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment