Last active
May 2, 2025 16:06
-
-
Save spencerthayer/1a7c0e3f1e7487a2f051dd038c535d7e to your computer and use it in GitHub Desktop.
Google Calendar Auto‑Decline Script (v1.9) — Auto‑decline conflicting Google Calendar invites with configurable look‑aheads, overlap thresholds, ignore‑list, batch‑modes, and notifications. It even has a dry‑run test mode.
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-Decline Conflicting Invites Script | |
* VERSION 1.9 - Added organizer notification when invites are auto-declined. | |
* | |
* Automatically declines incoming Google Calendar invitations that conflict | |
* with existing events where the user has not declined ("No"). | |
* Allows ignoring events based on their title. | |
* Can be run directly or via a time-driven trigger (e.g., every 15/30 mins). | |
*/ | |
// --- Configuration --- | |
// Modify these settings to customize the script's behavior. | |
const CONFIG = { | |
// How many days into the future to check for new invites. | |
lookAheadDays: 7, | |
// Minimum overlap time (in minutes) required to trigger an auto-decline. | |
// Set to 0 or 1 to decline any overlap. | |
minOverlapMinutes: 1, | |
// Send an email to yourself when an event is auto-declined? (true/false) | |
// Recommended since comments cannot be added directly to the event. | |
sendEmailNotifications: true, | |
// Send an email notification if the script encounters a major error? (true/false) | |
sendErrorNotifications: true, | |
// Send an email to the event organizer when auto-declining? (true/false) | |
notifyOrganizer: true, | |
// Process the calendar in smaller chunks (batches)? Recommended for very busy calendars | |
// or long lookAheadDays to avoid exceeding script runtime limits. (true/false) | |
processInBatches: false, | |
// If processInBatches is true, how many days to process in each batch. | |
batchSizeInDays: 3, | |
// Titles of events that shouldn't block invites (case-insensitive). | |
// Example: ["Home", "Lunch", "Commute", "Focus Time"] | |
// Events with these exact titles will be ignored when checking for conflicts. | |
ignoreEvents: ["Home", "AM All Hands", "PM All Hands", "Afternoon Check-in", "Fireside Chat", "Studio Monthly"], | |
// **SET TO true FOR TESTING ONLY**. If true, the script will log what it *would* decline | |
// but will NOT actually decline any events or send decline notifications. (true/false) | |
dryRun: false | |
}; | |
// --- End Configuration --- | |
/** | |
* Main function to initiate the auto-decline process. | |
* This is the function you should select to run manually or set up in a trigger. | |
*/ | |
function autoDeclineConflictingInvites() { | |
// Use a copy of the config for this specific run | |
const scriptConfig = JSON.parse(JSON.stringify(CONFIG)); | |
if (scriptConfig.dryRun) { | |
Logger.log('🧪 DRY RUN MODE ENABLED: No events will be modified or declined.'); | |
} | |
try { | |
const calendar = CalendarApp.getDefaultCalendar(); | |
const now = new Date(); | |
const endDate = new Date(now.getTime()); | |
endDate.setDate(now.getDate() + scriptConfig.lookAheadDays); | |
Logger.log(`Checking for conflicting invites from ${now.toLocaleString()} to ${endDate.toLocaleString()}`); | |
let totalDeclined = 0; | |
if (scriptConfig.processInBatches) { | |
totalDeclined = processBatches(calendar, now, endDate, scriptConfig); | |
} else { | |
totalDeclined = processDateRange(calendar, now, endDate, scriptConfig); | |
} | |
Logger.log(`✅ Script finished. Processed invites up to ${endDate.toLocaleString()}. Total auto-declined: ${totalDeclined} (Dry Run: ${scriptConfig.dryRun})`); | |
} catch (err) { | |
Logger.log(`SCRIPT FAILED: An error occurred during execution: ${err.message}\n${err.stack}`); | |
if (scriptConfig.sendErrorNotifications) { | |
try { | |
MailApp.sendEmail( | |
Session.getActiveUser().getEmail(), | |
'⚠️ Calendar Auto-Decline Script Error', | |
`The Google Calendar auto-decline script encountered an error:\n\n${err.message}\n\nStack Trace:\n${err.stack}\n\nPlease check the script logs for more details.` | |
); | |
} catch (mailErr) { | |
Logger.log(`🚨 Failed to send error notification email: ${mailErr.message}`); | |
} | |
} | |
} | |
} | |
/** | |
* Processes the calendar check in batches (e.g., week by week). | |
* Useful for avoiding script execution time limits on large calendars. | |
* @param {CalendarApp.Calendar} calendar The CalendarApp object for the default calendar. | |
* @param {Date} startDate The overall start date for checking. | |
* @param {Date} endDate The overall end date for checking. | |
* @param {Object} config The configuration settings for this run. | |
* @return {number} The total number of events declined across all batches. | |
*/ | |
function processBatches(calendar, startDate, endDate, config) { | |
let batchStart = new Date(startDate.getTime()); | |
let totalDeclinedCount = 0; | |
// Ensure batchSizeInDays is positive to prevent infinite loops | |
const batchDays = Math.max(1, config.batchSizeInDays); | |
const batchSizeMillis = batchDays * 24 * 60 * 60 * 1000; | |
Logger.log(`Batch processing enabled (Batch size: ${batchDays} days).`); | |
while (batchStart < endDate) { | |
let batchEnd = new Date(batchStart.getTime() + batchSizeMillis); | |
if (batchEnd > endDate) { | |
batchEnd = new Date(endDate.getTime()); // Don't go past the overall end date | |
} | |
// Prevent processing if batchStart somehow becomes >= batchEnd | |
if (batchStart >= batchEnd) { | |
Logger.log(`Skipping zero-duration batch from ${batchStart.toLocaleDateString()}.`); | |
break; | |
} | |
Logger.log(`--- Processing Batch: ${batchStart.toLocaleDateString()} to ${batchEnd.toLocaleDateString()} ---`); | |
totalDeclinedCount += processDateRange(calendar, batchStart, batchEnd, config); | |
// Move to the next batch start date | |
batchStart = new Date(batchEnd.getTime()); | |
} | |
Logger.log(`--- Batch processing complete. Total declined: ${totalDeclinedCount} ---`); | |
return totalDeclinedCount; | |
} | |
/** | |
* Processes a specific date range, finding and potentially declining conflicting invites. | |
* @param {CalendarApp.Calendar} calendar The CalendarApp object for the default calendar. | |
* @param {Date} startDate The start date for this specific range check. | |
* @param {Date} endDate The end date for this specific range check. | |
* @param {Object} config The configuration settings for this run. | |
* @return {number} The number of events declined in this date range. | |
*/ | |
function processDateRange(calendar, startDate, endDate, config) { | |
let declinedCount = 0; | |
try { | |
// 1. Get all events in the specified date range. | |
const eventsInRange = calendar.getEvents(startDate, endDate); | |
// 2. Filter these events to find only those where the user's status is INVITED. | |
const potentialInvites = eventsInRange.filter(event => { | |
try { | |
// Ensure the event object is valid and has getMyStatus method | |
if (event && typeof event.getMyStatus === 'function') { | |
return event.getMyStatus() === CalendarApp.GuestStatus.INVITED; | |
} | |
return false; | |
} catch (e) { | |
// Log error getting status, but don't stop the script | |
Logger.log(`⚠️ Error getting status for an event (ID potentially ${event ? event.getId() : 'unknown'}): ${e.message}. Skipping this event.`); | |
return false; | |
} | |
}); | |
if (potentialInvites.length === 0) { | |
Logger.log(`No pending invites found in range ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}.`); | |
return 0; // No invites to process in this range | |
} | |
Logger.log(`Found ${potentialInvites.length} pending invite(s) in range ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}. Checking for conflicts...`); | |
// 3. Iterate through each potential new invite. | |
potentialInvites.forEach(invite => { | |
let inviteTitle = '(Untitled Event)'; // Default title | |
let inviteId = 'unknown'; | |
try { | |
inviteId = invite.getId(); // Get ID early for logging | |
inviteTitle = invite.getTitle() || inviteTitle; // Use fetched title or default | |
const inviteStart = invite.getStartTime(); | |
const inviteEnd = invite.getEndTime(); | |
// Skip if event data is incomplete (e.g., missing times) | |
if (!inviteStart || !inviteEnd) { | |
Logger.log(`⚠️ Skipping invite "${inviteTitle}" (ID: ${inviteId}) due to missing start/end time.`); | |
return; // continue to next invite in forEach | |
} | |
Logger.log(`🔎 Checking invite: "${inviteTitle}" (${inviteStart.toLocaleString()} - ${inviteEnd.toLocaleString()}) ID: ${inviteId}`); | |
// 4. Find all events that overlap with this invite's time. | |
const overlappingEvents = calendar.getEvents(inviteStart, inviteEnd); | |
let conflictFound = false; | |
let conflictingEventDetails = ""; // Store details of the event causing the conflict | |
// 5. Check each overlapping event for a genuine conflict. | |
for (const existingEvent of overlappingEvents) { | |
const existingEventId = existingEvent.getId(); | |
// Skip checking the invite against itself. | |
if (existingEventId === inviteId) { | |
continue; | |
} | |
let existingTitle = '(Untitled Event)'; // Default title for existing event | |
try { | |
existingTitle = existingEvent.getTitle() || existingTitle; | |
// --- Check: Ignore based on Event Title --- | |
if (config.ignoreEvents && config.ignoreEvents.length > 0 && existingTitle) { | |
// Case-insensitive check against the ignore list | |
if (config.ignoreEvents.some(ignoredEvent => | |
existingTitle.toLowerCase() === ignoredEvent.toLowerCase())) { | |
Logger.log(`--> Ignoring event "${existingTitle}" (ID: ${existingEventId}) because its title is in the ignored events list.`); | |
continue; // Skip this event, don't consider it a conflict | |
} | |
} | |
// --- End event title ignore check --- | |
// --- Proceed with Conflict Check --- | |
const existingStatus = existingEvent.getMyStatus(); | |
const existingStart = existingEvent.getStartTime(); | |
const existingEnd = existingEvent.getEndTime(); | |
// Check for valid time data on the existing event | |
if (!existingStart || !existingEnd) { | |
Logger.log(`⚠️ Skipping comparison with existing event "${existingTitle}" (ID: ${existingEventId}) due to missing time data.`); | |
continue; // Skip to next overlapping event | |
} | |
// A conflict exists if: | |
// a) The user hasn't declined the existing event (YES, MAYBE, or OWNER/null status). | |
// b) The events actually overlap in time (start_existing < end_invite AND end_existing > start_invite). | |
// c) The duration of the overlap meets the minimum threshold. | |
if (existingStatus !== CalendarApp.GuestStatus.NO && existingStart < inviteEnd && existingEnd > inviteStart) { | |
// Calculate overlap duration | |
const overlapStart = new Date(Math.max(inviteStart.getTime(), existingStart.getTime())); | |
const overlapEnd = new Date(Math.min(inviteEnd.getTime(), existingEnd.getTime())); | |
// Ensure overlap is valid (end > start) before calculating duration | |
if (overlapEnd > overlapStart) { | |
const overlapMillis = overlapEnd.getTime() - overlapStart.getTime(); | |
// Use Math.floor for a conservative overlap calculation (must fully complete the minute) | |
const overlapMinutes = Math.floor(overlapMillis / (1000 * 60)); | |
if (overlapMinutes >= config.minOverlapMinutes) { | |
conflictFound = true; | |
// Store details concisely for potential use in email notification | |
conflictingEventDetails = `"${existingTitle}" (${overlapMinutes} min overlap)`; // Removed ID for simplicity in email | |
Logger.log(`--> Conflict detected with: ${conflictingEventDetails}`); | |
break; // Found a qualifying conflict, no need to check other overlapping events for this invite. | |
} else { | |
// Log ignored overlaps only if they are positive | |
if (overlapMinutes >= 0) { | |
Logger.log(`--> Minor overlap (${overlapMinutes} min) with "${existingTitle}" (ID: ${existingEventId}), below threshold (${config.minOverlapMinutes} min). Ignoring.`); | |
} | |
} | |
} | |
} | |
} catch (e) { | |
Logger.log(`⚠️ Error checking existing event "${existingTitle}" (ID: ${existingEventId}) for conflict: ${e.message}. Skipping this comparison.`); | |
// Continue checking other potentially overlapping events | |
} | |
} // End loop through overlapping events | |
// 6. If a qualifying conflict was found, decline the invite (unless in dryRun mode). | |
if (conflictFound) { | |
if (config.dryRun) { | |
Logger.log(`🧪 [Dry Run] Would decline invite: "${inviteTitle}" (ID: ${inviteId}) due to conflict with ${conflictingEventDetails}.`); | |
// Simulate incrementing declined count for accurate dry run reporting | |
declinedCount++; | |
} else { | |
Logger.log(`🚫 Declining invite: "${inviteTitle}" (ID: ${inviteId}) due to conflict with ${conflictingEventDetails}.`); | |
try { | |
// --- Simplified setMyStatus Call (using Enum as documented) --- | |
invite.setMyStatus(CalendarApp.GuestStatus.NO); | |
// --- End Simplified Call --- | |
declinedCount++; | |
// Log success immediately after the call | |
Logger.log(`--> Successfully declined "${inviteTitle}" (ID: ${inviteId}).`); | |
// Send notification email if enabled | |
if (config.sendEmailNotifications) { | |
try { | |
MailApp.sendEmail( | |
Session.getActiveUser().getEmail(), | |
'ℹ️ Calendar Invite Auto-Declined', | |
`The event "${inviteTitle}" scheduled for ${inviteStart.toLocaleString()} ` + | |
`was automatically declined by your script due to a conflict with ${conflictingEventDetails}.` | |
); | |
} catch (mailErr) { | |
Logger.log(`🚨 Failed to send decline notification email for event ID ${inviteId}: ${mailErr.message}`); | |
} | |
} | |
// Send notification to the organizer if enabled | |
if (config.notifyOrganizer) { | |
try { | |
// Get the organizer's email | |
const organizer = invite.getCreators()[0]; // This gets the first creator (usually the organizer) | |
if (organizer) { | |
MailApp.sendEmail( | |
organizer, | |
'Regarding your calendar invitation: Auto-Declined', | |
`I wanted to let you know that I won't be able to attend "${inviteTitle}" scheduled for ${inviteStart.toLocaleString()} ` + | |
`as it conflicts with another meeting in my calendar. Feel free to suggest an alternative time if needed.\n\n` + | |
`This is an automated message sent by a Google Calendar management script.\n\n` + | |
`Thank You` | |
); | |
Logger.log(`--> Sent notification to organizer: ${organizer}`); | |
} | |
} catch (organizerErr) { | |
Logger.log(`🚨 Failed to send notification to organizer for event ID ${inviteId}: ${organizerErr.message}`); | |
} | |
} | |
} catch (e) { | |
// Catch error specifically from the setMyStatus call | |
Logger.log(`🚨 Error attempting to decline event "${inviteTitle}" (ID: ${inviteId}): ${e.message}. Decline failed.`); | |
// If setMyStatus fails, don't increment declinedCount (or decrement if needed, though less clean) | |
// For simplicity, we assume if error caught here, decline didn't happen. | |
} | |
} | |
} else { | |
Logger.log(`👍 No qualifying conflicts found for "${inviteTitle}" (ID: ${inviteId}).`); | |
} | |
} catch (e) { | |
// Catch errors processing a specific invite (e.g., getting ID, title, times), log, and continue with the next invite | |
Logger.log(`🚨 Error processing invite (ID: ${inviteId}): ${e.message}\n${e.stack}. Continuing...`); | |
} | |
}); // End loop through potential invites | |
} catch (e) { | |
// Catch errors related to fetching events for the range (e.g., Calendar Service errors) | |
Logger.log(`🚨 Error processing date range ${startDate.toLocaleString()} - ${endDate.toLocaleString()}: ${e.message}\n${e.stack}`); | |
// Optionally re-throw or notify if this is critical depending on desired script behavior on failure | |
// throw e; // Uncomment to potentially stop the script on range processing errors | |
} | |
return declinedCount; | |
} | |
/** | |
* Helper function to run the script in TEST (Dry Run) mode. | |
* Select this function from the dropdown in the Apps Script editor and click "Run". | |
* It will use the main CONFIG settings but force dryRun = true, | |
* logging actions without modifying your calendar. | |
* IMPORTANT: This function temporarily modifies the global CONFIG object for the duration | |
* of its execution and restores it using a finally block. | |
*/ | |
function testAutoDeclineWithoutDeclining() { | |
Logger.log("--- Starting Test Run (Dry Run Mode) ---"); | |
// Backup the original configuration | |
const originalConfig = JSON.parse(JSON.stringify(CONFIG)); | |
try { | |
// --- Temporarily override global CONFIG for this test run --- | |
CONFIG.dryRun = true; // Force dry run globally for this execution | |
Logger.log(`Forcing dryRun = ${CONFIG.dryRun} for this test execution.`); | |
// --- Run the main logic with the dryRun setting forced --- | |
autoDeclineConflictingInvites(); // Call the main function which now respects the overridden CONFIG.dryRun | |
} catch(e) { | |
// Log any errors that occur during the test run itself | |
Logger.log(`🚨 Error during test run execution: ${e.message}\n${e.stack}`); | |
} finally { | |
// --- Restore the original global CONFIG --- | |
// Use 'finally' to ensure the original configuration is restored | |
// even if the script encounters an error during the test run. | |
for (const key in originalConfig) { | |
if (originalConfig.hasOwnProperty(key)) { | |
CONFIG[key] = originalConfig[key]; | |
} | |
} | |
Logger.log(`Original configuration restored. dryRun is now: ${CONFIG.dryRun}`); | |
Logger.log("--- Test Run Finished ---"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here’s how to install, configure, test and schedule the auto‑decline script in your Google Calendar via Apps Script:
Create a new Apps Script project
Paste in the script
/** Google Calendar Auto‑Decline…
through to the closing}
oftestAutoDeclineWithoutDeclining()
) and paste it into Code.gs.Adjust your configuration
At the top of the file you’ll see the
CONFIG
object. Modify these to suit your preferences:– Tip: start with
dryRun: true
so you can inspect what would happen without affecting your calendar.Authorize & run a dry‑run test
In the Apps Script editor, select the function dropdown (just above the code) and choose testAutoDeclineWithoutDeclining.
Click the▶️ Run button.
The first time, you’ll be prompted to grant:
Approve the scopes.
Open View → Logs to see which events would be declined.
Switch off dry‑run & test live
dryRun: false
.Set up an automatic trigger
In the left sidebar, click the ⏰ Triggers icon.
Click + Add Trigger (bottom right).
Configure:
autoDeclineConflictingInvites
Click Save.
Verify
🎉 That’s it! Your Calendar will now automatically decline any incoming invites that conflict with your existing events—complete with optional email alerts and organizer notifications.