|
/** |
|
* 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 ---"); |
|
} |
|
} |
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.