From 31aef188e4250e283c77f424e23e7803cff23213 Mon Sep 17 00:00:00 2001 From: Lonestarjeepin <54453237+Lonestarjeepin@users.noreply.github.com> Date: Thu, 28 Sep 2023 21:37:37 -0500 Subject: [PATCH 1/3] SyncDelay SyncDelay processes each source Calendar individually instead of grouping them together by Target Calendar before processing. By handling each calendar individually, I was able to denote a Sync Delay on a per-calendar basis. The default is whatever the script trigger (howFrequent) is set too, but the Sync Delay can be set to skip syncing on a given run if the Sync Delay hasn't "expired". --- Code.gs | 112 +++++++++++++++++++++++++++++++++++++++-------------- Helpers.gs | 38 ++++++++++-------- 2 files changed, 105 insertions(+), 45 deletions(-) diff --git a/Code.gs b/Code.gs index 2ce91c3..d866cc4 100644 --- a/Code.gs +++ b/Code.gs @@ -21,18 +21,38 @@ *========================================= */ -var sourceCalendars = [ // The ics/ical urls that you want to get events from along with their target calendars (list a new row for each mapping of ICS url to Google Calendar) - // For instance: ["https://p24-calendars.icloud.com/holidays/us_en.ics", "US Holidays"] - // Or with colors following mapping https://developers.google.com/apps-script/reference/calendar/event-color, - // for instance: ["https://p24-calendars.icloud.com/holidays/us_en.ics", "US Holidays", "11"] - ["icsUrl1", "targetCalendar1"], - ["icsUrl2", "targetCalendar2"], - ["icsUrl3", "targetCalendar1"] +var sourceCalendars = [ // The ics/ical urls that you want to get events from with a friendly name for the source calendar along with their target calendars (list a new row for each mapping of ICS url to Google Calendar) + /* For instance: ["https://p24-calendars.icloud.com/holidays/us_en.ics", + "Holidays", //Calendar Name *REQUIRED - Must be unique name from other source calendars in this script + "US Holidays", //Target Calendar Name *REQUIRED - This is the name of the Google Calendar the script writes to + "11", //Color *OPTIONAL - following mapping at https://developers.google.com/apps-script/reference/calendar/event-color + "60" //SyncDelay *OPTIONAL - Won't sync within 60 minutes of last run in this example. If SyncDelay is omitted or 0, calendar will sync every time the script runs. + ], + */ + ["icsUrl1",// *REQUIRED - URL + "Calendar1Name", // *REQUIRED - Calendar Name + "targetCalendar1",// *REQUIRED - Target Calendar + "", // *OPTIONAL - Color + "" // *OPTIONAL - Sync Delay + ], + ["icsUrl2",// *REQUIRED - URL + "Calendar2Name", // *REQUIRED - Calendar Name + "targetCalendar1",// *REQUIRED - Target Calendar + "", // *OPTIONAL - Color + "" // *OPTIONAL - Sync Delay + ], + ["icsUrl3",// *REQUIRED - URL + "Calendar3Name", // *REQUIRED - Calendar Name + "targetCalendar2",// *REQUIRED - Target Calendar + "", // *OPTIONAL - Color + "" // *OPTIONAL - Sync Delay + ] ]; -var howFrequent = 15; // What interval (minutes) to run this script on to check for new events -var onlyFutureEvents = false; // If you turn this to "true", past events will not be synced (this will also removed past events from the target calendar if removeEventsFromCalendar is true) +var howFrequent = 5; // What interval (minutes) to run this script on to check for new events. Any integer can be used, but will be rounded up to 5, 10, 15, 30 or to the nearest hour after that.. 60, 120, etc. 1440 (24 hours) is the maximum value. Anything above that will be replaced with 1440. +var onlyFutureEvents = true; // If you turn this to "true", past events will not be synced (this will also removed past events from the target calendar if removeEventsFromCalendar is true) +var getPastDaysIfOnlyFutureEvents = 30; // If onlyFutureEvents is set to "true", you can get still some days in the past var addEventsToCalendar = true; // If you turn this to "false", you can check the log (View > Logs) to make sure your events are being read correctly before turning this on var modifyExistingEvents = true; // If you turn this to "false", any event in the feed that was modified after being added to the calendar will not update var removeEventsFromCalendar = true; // If you turn this to "true", any event created by the script that is not found in the feed will be removed. @@ -96,17 +116,32 @@ var email = ""; // OPTIONAL: If "emailSummary" is set //===================================================================================================== var defaultMaxRetries = 10; // Maximum number of retries for api functions (with exponential backoff) +//var howFrequent = getValidTriggerFrequency(howFrequent); // What interval (minutes) to run this script on to check for new events -function install(){ - //Delete any already existing triggers so we don't create excessive triggers +function install() { + // Delete any already existing triggers so we don't create excessive triggers deleteAllTriggers(); - //Schedule sync routine to explicitly repeat and schedule the initial sync - ScriptApp.newTrigger("startSync").timeBased().everyMinutes(getValidTriggerFrequency(howFrequent)).create(); + // Schedule sync routine to explicitly repeat and schedule the initial sync + var adjustedMinutes = getValidTriggerFrequency(howFrequent); + if (adjustedMinutes >= 60) { + ScriptApp.newTrigger("startSync") + .timeBased() + .everyHours(adjustedMinutes / 60) + .create(); + } else { + ScriptApp.newTrigger("startSync") + .timeBased() + .everyMinutes(adjustedMinutes) + .create(); + } ScriptApp.newTrigger("startSync").timeBased().after(1000).create(); - //Schedule sync routine to look for update once per day - ScriptApp.newTrigger("checkForUpdate").timeBased().everyDays(1).create(); + // Schedule sync routine to look for update once per day using everyDays + ScriptApp.newTrigger("checkForUpdate") + .timeBased() + .everyDays(1) + .create(); } function uninstall(){ @@ -137,45 +172,62 @@ function startSync(){ PropertiesService.getUserProperties().setProperty('LastRun', new Date().getTime()); + var currentDate = new Date(); + if (onlyFutureEvents) - startUpdateTime = new ICAL.Time.fromJSDate(new Date()); + startUpdateTime = new ICAL.Time.fromJSDate(new Date(currentDate.setDate(currentDate.getDate() - getPastDaysIfOnlyFutureEvents))); //Disable email notification if no mail adress is provided emailSummary = emailSummary && email != ""; - sourceCalendars = condenseCalendarMap(sourceCalendars); for (var calendar of sourceCalendars){ //------------------------ Reset globals ------------------------ + var sourceURL = calendar[0]; + var sourceCalendarName = calendar[1]; + var targetCalendarName = calendar[2]; + var color = calendar[3]; calendarEvents = []; calendarEventsIds = []; icsEventsIds = []; calendarEventsMD5s = []; recurringEvents = []; - - targetCalendarName = calendar[0]; - var sourceCalendarURLs = calendar[1]; var vevents; + //------------------------ Determine whether to sync each calendar based on SyncDelay ------------------------ + let sourceSyncDelay = Number(calendar[4])*60*1000; + let currentTime = Number(new Date().getTime()); + let lastSyncTime = Number(PropertiesService.getUserProperties().getProperty(sourceCalendarName)); + var lastSyncDelta = currentTime - lastSyncTime; + + if (isNaN(sourceSyncDelay)) { + Logger.log("Syncing " + sourceCalendarName + " because no SyncDelay defined."); + } else if (lastSyncDelta >= sourceSyncDelay) { + Logger.log("Syncing " + sourceCalendarName + " because lastSyncDelta ("+ (lastSyncDelta/60/1000).toFixed(1) + ") is greater than sourceSyncDelay (" + (sourceSyncDelay/60/1000).toFixed(0) + ")."); + } else if (lastSyncDelta < sourceSyncDelay) { + Logger.log("Skipping " + sourceCalendarName + " because lastSyncDelta ("+ (lastSyncDelta/60/1000).toFixed(1) + ") is less than sourceSyncDelay (" + (sourceSyncDelay/60/1000).toFixed(0) + ")."); + continue; + } + //------------------------ Fetch URL items ------------------------ - var responses = fetchSourceCalendars(sourceCalendarURLs); - Logger.log("Syncing " + responses.length + " calendars to " + targetCalendarName); + var responses = fetchSourceCalendars([[sourceURL, color]]); + Logger.log("Syncing " + sourceCalendarName + " calendar to " + targetCalendarName); //" - " + responses.length + //------------------------ Get target calendar information------------------------ var targetCalendar = setupTargetCalendar(targetCalendarName); targetCalendarId = targetCalendar.id; - Logger.log("Working on calendar: " + targetCalendarId); + Logger.log("Working on target calendar: " + targetCalendarId); //------------------------ Parse existing events -------------------------- if(addEventsToCalendar || modifyExistingEvents || removeEventsFromCalendar){ var eventList = callWithBackoff(function(){ - return Calendar.Events.list(targetCalendarId, {showDeleted: false, privateExtendedProperty: "fromGAS=true", maxResults: 2500}); + return Calendar.Events.list(targetCalendarId, {showDeleted: false, privateExtendedProperty: 'fromGAS=' + sourceCalendarName, maxResults: 2500}); }, defaultMaxRetries); calendarEvents = [].concat(calendarEvents, eventList.items); //loop until we received all events while(typeof eventList.nextPageToken !== 'undefined'){ eventList = callWithBackoff(function(){ - return Calendar.Events.list(targetCalendarId, {showDeleted: false, privateExtendedProperty: "fromGAS=true", maxResults: 2500, pageToken: eventList.nextPageToken}); + return Calendar.Events.list(targetCalendarId, {showDeleted: false, privateExtendedProperty: 'fromGAS=' + sourceCalendarName, maxResults: 2500, pageToken: eventList.nextPageToken}); }, defaultMaxRetries); if (eventList != null) @@ -201,9 +253,8 @@ function startSync(){ callWithBackoff(function(){ return Calendar.Settings.get("timezone").value; }, defaultMaxRetries); - vevents.forEach(function(e){ - processEvent(e, calendarTz); + processEvent(e, calendarTz, targetCalendarId, sourceCalendarName); }); Logger.log("Done processing events"); @@ -212,7 +263,7 @@ function startSync(){ //------------------------ Remove old events from calendar ------------------------ if(removeEventsFromCalendar){ Logger.log("Checking " + calendarEvents.length + " events for removal"); - processEventCleanup(); + processEventCleanup(sourceURL); Logger.log("Done checking events for removal"); } @@ -226,6 +277,9 @@ function startSync(){ for (var recEvent of recurringEvents){ processEventInstance(recEvent); } + + //Set last sync time for given sourceCalendar + PropertiesService.getUserProperties().setProperty(sourceCalendarName, new Date().getTime()); } if ((addedEvents.length + modifiedEvents.length + removedEvents.length) > 0 && emailSummary){ @@ -233,4 +287,4 @@ function startSync(){ } Logger.log("Sync finished!"); PropertiesService.getUserProperties().setProperty('LastRun', 0); -} +} \ No newline at end of file diff --git a/Helpers.gs b/Helpers.gs index 4cfe250..852aab5 100644 --- a/Helpers.gs +++ b/Helpers.gs @@ -12,15 +12,20 @@ function getValidTriggerFrequency(origFrequency) { return 15; } - var adjFrequency = Math.round(origFrequency/5) * 5; // Set the number to be the closest divisible-by-5 - adjFrequency = Math.max(adjFrequency, 1); // Make sure the number is at least 1 (0 is not valid for the trigger) - adjFrequency = Math.min(adjFrequency, 15); // Make sure the number is at most 15 (will check for the 30 value below) + // Limit the original frequency to 1440 + origFrequency = Math.min(origFrequency, 1440); - if((adjFrequency == 15) && (Math.abs(origFrequency-30) < Math.abs(origFrequency-15))) - adjFrequency = 30; // If we adjusted to 15, but the original number is actually closer to 30, set it to 30 instead + var acceptableValues = [5, 10, 15, 30].concat( + Array.from({ length: 24 }, (_, i) => (i + 1) * 60) + ); // [5, 10, 15, 30, 60, 120, ..., 1440] - Logger.log("Intended frequency = "+origFrequency+", Adjusted frequency = "+adjFrequency); - return adjFrequency; + // Find the smallest acceptable value greater than or equal to the original frequency + var roundedUpValue = acceptableValues.find(value => value >= origFrequency); + + Logger.log( + "Intended frequency = " + origFrequency + ", Adjusted frequency = " + roundedUpValue + ); + return roundedUpValue; } String.prototype.includes = function(phrase){ @@ -206,9 +211,9 @@ function parseResponses(responses){ * @param {ICAL.Component} event - The event to process * @param {string} calendarTz - The timezone of the target calendar */ -function processEvent(event, calendarTz){ +function processEvent(event, calendarTz, targetCalendarId, sourceCalendarName){ //------------------------ Create the event object ------------------------ - var newEvent = createEvent(event, calendarTz); + var newEvent = createEvent(event, calendarTz, sourceCalendarName); if (newEvent == null) return; @@ -238,6 +243,7 @@ function processEvent(event, calendarTz){ else{ if (addEventsToCalendar){ Logger.log("Adding new event " + newEvent.extendedProperties.private["id"]); + newEvent = callWithBackoff(function(){ return Calendar.Events.insert(newEvent, targetCalendarId); }, defaultMaxRetries); @@ -260,7 +266,7 @@ function processEvent(event, calendarTz){ * @param {string} calendarTz - The timezone of the target calendar * @return {?Calendar.Event} The Calendar.Event that will be added to the target calendar */ -function createEvent(event, calendarTz){ +function createEvent(event, calendarTz, sourceCalendarName){ event.removeProperty('dtstamp'); var icalEvent = new ICAL.Event(event, {strictExceptions: true}); if (onlyFutureEvents && checkSkipEvent(event, icalEvent)){ @@ -464,7 +470,7 @@ function createEvent(event, calendarTz){ newEvent.recurrence = parseRecurrenceRule(event, calendarUTCOffset); } - newEvent.extendedProperties = { private: { MD5 : digest, fromGAS : "true", id : icalEvent.uid } }; + newEvent.extendedProperties = { private: { MD5 : digest, fromGAS : sourceCalendarName, id : icalEvent.uid } }; if (event.hasProperty('recurrence-id')){ var recID = new ICAL.Time.fromString(event.getFirstPropertyValue('recurrence-id').toString(), event.getFirstProperty('recurrence-id')); @@ -598,13 +604,13 @@ function checkSkipEvent(event, icalEvent){ * * @param {Calendar.Event} recEvent - The event instance to process */ -function processEventInstance(recEvent){ +function processEventInstance(recEvent, sourceCalendarName){ Logger.log("ID: " + recEvent.extendedProperties.private["id"] + " | Date: "+ recEvent.recurringEventId); var eventInstanceToPatch = callWithBackoff(function(){ return Calendar.Events.list(targetCalendarId, { singleEvents : true, - privateExtendedProperty : "fromGAS=true", + privateExtendedProperty : 'fromGAS=' + sourceCalendarName, privateExtendedProperty : "rec-id=" + recEvent.extendedProperties.private["id"] + "_" + recEvent.recurringEventId }).items; }, defaultMaxRetries); @@ -622,7 +628,7 @@ function processEventInstance(recEvent){ orderBy : "startTime", maxResults: 1, timeMin : recEvent.recurringEventId, - privateExtendedProperty : "fromGAS=true", + privateExtendedProperty : 'fromGAS=' + sourceCalendarName, privateExtendedProperty : "id=" + recEvent.extendedProperties.private["id"] }).items; }, defaultMaxRetries); @@ -646,7 +652,7 @@ function processEventInstance(recEvent){ * Deletes all events from the target calendar that no longer exist in the source calendars. * If onlyFutureEvents is set to true, events that have taken place since the last sync are also removed. */ -function processEventCleanup(){ +function processEventCleanup(sourceURL){ for (var i = 0; i < calendarEvents.length; i++){ var currentID = calendarEventsIds[i]; var feedIndex = icsEventsIds.indexOf(currentID); @@ -998,4 +1004,4 @@ function checkForUpdate(){ var version = json_decoded[0]["tag_name"]; return Number(version); } -} +} \ No newline at end of file From 021a9bb2567d4c8c1578cd18adb00200fc62cde5 Mon Sep 17 00:00:00 2001 From: Lonestarjeepin <54453237+Lonestarjeepin@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:05:19 -0600 Subject: [PATCH 2/3] Update Code.gs Updating this PR to include changes made in v5.8 of master. --- Code.gs | 60 +++++++++++++++++++++------------------------------------ 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/Code.gs b/Code.gs index d866cc4..0a476e5 100644 --- a/Code.gs +++ b/Code.gs @@ -21,38 +21,18 @@ *========================================= */ -var sourceCalendars = [ // The ics/ical urls that you want to get events from with a friendly name for the source calendar along with their target calendars (list a new row for each mapping of ICS url to Google Calendar) - /* For instance: ["https://p24-calendars.icloud.com/holidays/us_en.ics", - "Holidays", //Calendar Name *REQUIRED - Must be unique name from other source calendars in this script - "US Holidays", //Target Calendar Name *REQUIRED - This is the name of the Google Calendar the script writes to - "11", //Color *OPTIONAL - following mapping at https://developers.google.com/apps-script/reference/calendar/event-color - "60" //SyncDelay *OPTIONAL - Won't sync within 60 minutes of last run in this example. If SyncDelay is omitted or 0, calendar will sync every time the script runs. - ], - */ - ["icsUrl1",// *REQUIRED - URL - "Calendar1Name", // *REQUIRED - Calendar Name - "targetCalendar1",// *REQUIRED - Target Calendar - "", // *OPTIONAL - Color - "" // *OPTIONAL - Sync Delay - ], - ["icsUrl2",// *REQUIRED - URL - "Calendar2Name", // *REQUIRED - Calendar Name - "targetCalendar1",// *REQUIRED - Target Calendar - "", // *OPTIONAL - Color - "" // *OPTIONAL - Sync Delay - ], - ["icsUrl3",// *REQUIRED - URL - "Calendar3Name", // *REQUIRED - Calendar Name - "targetCalendar2",// *REQUIRED - Target Calendar - "", // *OPTIONAL - Color - "" // *OPTIONAL - Sync Delay - ] +var sourceCalendars = [ // The ics/ical urls that you want to get events from along with their target calendars (list a new row for each mapping of ICS url to Google Calendar) + // For instance: ["https://p24-calendars.icloud.com/holidays/us_en.ics", "US Holidays"] + // Or with colors following mapping https://developers.google.com/apps-script/reference/calendar/event-color, + // for instance: ["https://p24-calendars.icloud.com/holidays/us_en.ics", "US Holidays", "11"] + ["icsUrl1", "targetCalendar1"], + ["icsUrl2", "targetCalendar2"], + ["icsUrl3", "targetCalendar1"] ]; -var howFrequent = 5; // What interval (minutes) to run this script on to check for new events. Any integer can be used, but will be rounded up to 5, 10, 15, 30 or to the nearest hour after that.. 60, 120, etc. 1440 (24 hours) is the maximum value. Anything above that will be replaced with 1440. -var onlyFutureEvents = true; // If you turn this to "true", past events will not be synced (this will also removed past events from the target calendar if removeEventsFromCalendar is true) -var getPastDaysIfOnlyFutureEvents = 30; // If onlyFutureEvents is set to "true", you can get still some days in the past +var howFrequent = 15; // What interval (minutes) to run this script on to check for new events. Any integer can be used, but will be rounded up to 5, 10, 15, 30 or to the nearest hour after that.. 60, 120, etc. 1440 (24 hours) is the maximum value. Anything above that will be replaced with 1440. +var onlyFutureEvents = false; // If you turn this to "true", past events will not be synced (this will also removed past events from the target calendar if removeEventsFromCalendar is true) var addEventsToCalendar = true; // If you turn this to "false", you can check the log (View > Logs) to make sure your events are being read correctly before turning this on var modifyExistingEvents = true; // If you turn this to "false", any event in the feed that was modified after being added to the calendar will not update var removeEventsFromCalendar = true; // If you turn this to "true", any event created by the script that is not found in the feed will be removed. @@ -69,6 +49,8 @@ var addTasks = false; var emailSummary = false; // Will email you when an event is added/modified/removed to your calendar var email = ""; // OPTIONAL: If "emailSummary" is set to true or you want to receive update notifications, you will need to provide your email address +var customEmailSubject = ""; // OPTIONAL: If you want to change the email subject, provide a custom one here. Default: "GAS-ICS-Sync Execution Summary" +var dateFormat = "YYYY-MM-DD" // date format in the email summary (e.g. "YYYY-MM-DD", "DD.MM.YYYY", "MM/DD/YYYY". separators are ".", "-" and "/") /* *========================================= @@ -116,7 +98,6 @@ var email = ""; // OPTIONAL: If "emailSummary" is set //===================================================================================================== var defaultMaxRetries = 10; // Maximum number of retries for api functions (with exponential backoff) -//var howFrequent = getValidTriggerFrequency(howFrequent); // What interval (minutes) to run this script on to check for new events function install() { // Delete any already existing triggers so we don't create excessive triggers @@ -171,9 +152,7 @@ function startSync(){ } PropertiesService.getUserProperties().setProperty('LastRun', new Date().getTime()); - var currentDate = new Date(); - if (onlyFutureEvents) startUpdateTime = new ICAL.Time.fromJSDate(new Date(currentDate.setDate(currentDate.getDate() - getPastDaysIfOnlyFutureEvents))); @@ -193,7 +172,7 @@ function startSync(){ recurringEvents = []; var vevents; - //------------------------ Determine whether to sync each calendar based on SyncDelay ------------------------ +//------------------------ Determine whether to sync each calendar based on SyncDelay ------------------------ let sourceSyncDelay = Number(calendar[4])*60*1000; let currentTime = Number(new Date().getTime()); let lastSyncTime = Number(PropertiesService.getUserProperties().getProperty(sourceCalendarName)); @@ -210,7 +189,12 @@ function startSync(){ //------------------------ Fetch URL items ------------------------ var responses = fetchSourceCalendars([[sourceURL, color]]); - Logger.log("Syncing " + sourceCalendarName + " calendar to " + targetCalendarName); //" - " + responses.length + + //Skip the source calendar if a 5xx or 4xx error is returned. This prevents deleting all of the existing entries if the URL call fails. + if (responses.length == 0){ + Logger.log("Error Syncing " + sourceCalendarName + ". Skipping..."); + continue; + } + Logger.log("Syncing " + sourceCalendarName + " calendar to " + targetCalendarName); //------------------------ Get target calendar information------------------------ var targetCalendar = setupTargetCalendar(targetCalendarName); @@ -253,6 +237,7 @@ function startSync(){ callWithBackoff(function(){ return Calendar.Settings.get("timezone").value; }, defaultMaxRetries); + vevents.forEach(function(e){ processEvent(e, calendarTz, targetCalendarId, sourceCalendarName); }); @@ -277,9 +262,8 @@ function startSync(){ for (var recEvent of recurringEvents){ processEventInstance(recEvent); } - - //Set last sync time for given sourceCalendar - PropertiesService.getUserProperties().setProperty(sourceCalendarName, new Date().getTime()); + //Set last sync time for given sourceCalendar + PropertiesService.getUserProperties().setProperty(sourceCalendarName, new Date().getTime()); } if ((addedEvents.length + modifiedEvents.length + removedEvents.length) > 0 && emailSummary){ @@ -287,4 +271,4 @@ function startSync(){ } Logger.log("Sync finished!"); PropertiesService.getUserProperties().setProperty('LastRun', 0); -} \ No newline at end of file +} From 1c9c838bd7b10bf5a16d17000f34b4d987576447 Mon Sep 17 00:00:00 2001 From: Lonestarjeepin <54453237+Lonestarjeepin@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:06:02 -0600 Subject: [PATCH 3/3] Update Helpers.gs Updating PR for changes made in v5.8 release. --- Helpers.gs | 201 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 157 insertions(+), 44 deletions(-) diff --git a/Helpers.gs b/Helpers.gs index 852aab5..f791eec 100644 --- a/Helpers.gs +++ b/Helpers.gs @@ -1,3 +1,54 @@ +/** + * Formats the date and time according to the format specified in the configuration. + * + * @param {string} date The date to be formatted. + * @return {string} The formatted date string. + */ +function formatDate(date) { + const year = date.slice(0,4); + const month = date.slice(5,7); + const day = date.slice(8,10); + let formattedDate; + + if (dateFormat == "YYYY/MM/DD") { + formattedDate = year + "/" + month + "/" + day + } + else if (dateFormat == "DD/MM/YYYY") { + formattedDate = day + "/" + month + "/" + year + } + else if (dateFormat == "MM/DD/YYYY") { + formattedDate = month + "/" + day + "/" + year + } + else if (dateFormat == "YYYY-MM-DD") { + formattedDate = year + "-" + month + "-" + day + } + else if (dateFormat == "DD-MM-YYYY") { + formattedDate = day + "-" + month + "-" + year + } + else if (dateFormat == "MM-DD-YYYY") { + formattedDate = month + "-" + day + "-" + year + } + else if (dateFormat == "YYYY.MM.DD") { + formattedDate = year + "." + month + "." + day + } + else if (dateFormat == "DD.MM.YYYY") { + formattedDate = day + "." + month + "." + year + } + else if (dateFormat == "MM.DD.YYYY") { + formattedDate = month + "." + day + "." + year + } + + if (date.length < 11) { + return formattedDate + } + + const time = date.slice(11,16) + const timeZone = date.slice(19) + + return formattedDate + " at " + time + " (UTC" + (timeZone == "Z" ? "": timeZone) + ")" +} + + /** * Takes an intended frequency in minutes and adjusts it to be the closest * acceptable value to use Google "everyMinutes" trigger setting (i.e. one of @@ -81,26 +132,39 @@ function fetchSourceCalendars(sourceCalendarURLs){ for (var source of sourceCalendarURLs){ var url = source[0].replace("webcal://", "https://"); var colorId = source[1]; - + callWithBackoff(function() { var urlResponse = UrlFetchApp.fetch(url, { 'validateHttpsCertificates' : false, 'muteHttpExceptions' : true }); if (urlResponse.getResponseCode() == 200){ - var urlContent = RegExp("(BEGIN:VCALENDAR.*?END:VCALENDAR)", "s").exec(urlResponse.getContentText()); - if(urlContent == null){ - Logger.log("[ERROR] Incorrect ics/ical URL: " + url); - return; - } - else{ - result.push([urlContent[0], colorId]); - return; + var icsContent = urlResponse.getContentText() + const icsRegex = RegExp("(BEGIN:VCALENDAR.*?END:VCALENDAR)", "s") + var urlContent = icsRegex.exec(icsContent); + if (urlContent == null){ + // Microsoft Outlook has a bug that sometimes results in incorrectly formatted ics files. This tries to fix that problem. + // Add END:VEVENT for every BEGIN:VEVENT that's missing it + const veventRegex = /BEGIN:VEVENT(?:(?!END:VEVENT).)*?(?=.BEGIN|.END:VCALENDAR|$)/sg; + icsContent = icsContent.replace(veventRegex, (match) => match + "\nEND:VEVENT"); + + // Add END:VCALENDAR if missing + if (!icsContent.endsWith("END:VCALENDAR")){ + icsContent += "\nEND:VCALENDAR"; + } + urlContent = icsRegex.exec(icsContent) + if (urlContent == null){ + Logger.log("[ERROR] Incorrect ics/ical URL: " + url) + return + } + Logger.log("[WARNING] Microsoft is incorrectly formatting ics/ical at: " + url) } + result.push([urlContent[0], colorId]); + return; } else{ //Throw here to make callWithBackoff run again - throw "Error: Encountered HTTP error " + urlResponse.getResponseCode() + " when accessing " + url; + throw "Error: Encountered HTTP error " + urlResponse.getResponseCode() + " when accessing " + url; } }, defaultMaxRetries); } - + return result; } @@ -178,7 +242,7 @@ function parseResponses(responses){ }); } - //No need to process calcelled events as they will be added to gcal's trash anyway + //No need to process cancelled events as they will be added to gcal's trash anyway result = result.filter(function(event){ try{ return (event.getFirstPropertyValue('status').toString().toLowerCase() != "cancelled"); @@ -192,10 +256,21 @@ function parseResponses(responses){ event.updatePropertyWithValue('uid', Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, event.toString()).toString()); } if(event.hasProperty('recurrence-id')){ - var recID = new ICAL.Time.fromString(event.getFirstPropertyValue('recurrence-id').toString(), event.getFirstProperty('recurrence-id')); - var recUTC = recID.convertToZone(ICAL.TimezoneService.get('UTC')).toString(); - - icsEventsIds.push(event.getFirstPropertyValue('uid').toString() + "_" + recUTC); + let recID = new ICAL.Time.fromString(event.getFirstPropertyValue('recurrence-id').toString(), event.getFirstProperty('recurrence-id')); + if (event.getFirstProperty('recurrence-id').getParameter('tzid')){ + let recUTCOffset = 0; + let tz = event.getFirstProperty('recurrence-id').getParameter('tzid').toString(); + if (tz in tzidreplace){ + tz = tzidreplace[tz]; + } + let jsTime = new Date(); + let utcTime = new Date(Utilities.formatDate(jsTime, "Etc/GMT", "HH:mm:ss MM/dd/yyyy")); + let tgtTime = new Date(Utilities.formatDate(jsTime, tz, "HH:mm:ss MM/dd/yyyy")); + recUTCOffset = (tgtTime - utcTime)/-1000; + recID = recID.adjust(0,0,0,recUTCOffset).toString() + "Z"; + event.updatePropertyWithValue('recurrence-id', recID); + } + icsEventsIds.push(event.getFirstPropertyValue('uid').toString() + "_" + recID); } else{ icsEventsIds.push(event.getFirstPropertyValue('uid').toString()); @@ -211,7 +286,7 @@ function parseResponses(responses){ * @param {ICAL.Component} event - The event to process * @param {string} calendarTz - The timezone of the target calendar */ -function processEvent(event, calendarTz, targetCalendarId, sourceCalendarName){ + function processEvent(event, calendarTz, targetCalendarId, sourceCalendarName){ //------------------------ Create the event object ------------------------ var newEvent = createEvent(event, calendarTz, sourceCalendarName); if (newEvent == null) @@ -231,24 +306,24 @@ function processEvent(event, calendarTz, targetCalendarId, sourceCalendarName){ //------------------------ Send event object to gcal ------------------------ if (needsUpdate){ if (modifyExistingEvents){ + oldEvent = calendarEvents[index] Logger.log("Updating existing event " + newEvent.extendedProperties.private["id"]); newEvent = callWithBackoff(function(){ return Calendar.Events.update(newEvent, targetCalendarId, calendarEvents[index].id); }, defaultMaxRetries); if (newEvent != null && emailSummary){ - modifiedEvents.push([[newEvent.summary, newEvent.start.date||newEvent.start.dateTime], targetCalendarName]); + modifiedEvents.push([[oldEvent.summary, newEvent.summary, oldEvent.start.date||oldEvent.start.dateTime, newEvent.start.date||newEvent.start.dateTime, oldEvent.end.date||oldEvent.end.dateTime, newEvent.end.date||newEvent.end.dateTime, oldEvent.location, newEvent.location, oldEvent.description, newEvent.description], targetCalendarName]); } } } else{ if (addEventsToCalendar){ Logger.log("Adding new event " + newEvent.extendedProperties.private["id"]); - newEvent = callWithBackoff(function(){ return Calendar.Events.insert(newEvent, targetCalendarId); }, defaultMaxRetries); if (newEvent != null && emailSummary){ - addedEvents.push([[newEvent.summary, newEvent.start.date||newEvent.start.dateTime], targetCalendarName]); + addedEvents.push([[newEvent.summary, newEvent.start.date||newEvent.start.dateTime, newEvent.end.date||newEvent.end.dateTime, newEvent.location, newEvent.description], targetCalendarName]); } } } @@ -382,10 +457,8 @@ function createEvent(event, calendarTz, sourceCalendarName){ } if (addCalToTitle && event.hasProperty('parentCal')){ - var calName = event.getFirstPropertyValue('parentCal'); - newEvent.summary = "(" + calName + ") " + newEvent.summary; + newEvent.summary = newEvent.summary + " (" + sourceCalendarName + ")"; } - if (event.hasProperty('description')) newEvent.description = icalEvent.description; @@ -473,13 +546,17 @@ function createEvent(event, calendarTz, sourceCalendarName){ newEvent.extendedProperties = { private: { MD5 : digest, fromGAS : sourceCalendarName, id : icalEvent.uid } }; if (event.hasProperty('recurrence-id')){ - var recID = new ICAL.Time.fromString(event.getFirstPropertyValue('recurrence-id').toString(), event.getFirstProperty('recurrence-id')); - newEvent.recurringEventId = recID.convertToZone(ICAL.TimezoneService.get('UTC')).toString(); + newEvent.recurringEventId = event.getFirstPropertyValue('recurrence-id').toString(); newEvent.extendedProperties.private['rec-id'] = newEvent.extendedProperties.private['id'] + "_" + newEvent.recurringEventId; } if (event.hasProperty('color')){ - newEvent.colorId = event.getFirstPropertyValue('color').toString(); + let colorID = event.getFirstPropertyValue('color').toString(); + if (Object.keys(CalendarApp.EventColor).includes(colorID)){ + newEvent.colorId = CalendarApp.EventColor[colorID]; + }else if(Object.values(CalendarApp.EventColor).includes(colorID)){ + newEvent.colorId = colorID; + }; //else unsupported value } return newEvent; @@ -538,10 +615,19 @@ function checkSkipEvent(event, icalEvent){ var exDates = event.getAllProperties('exdate'); exDates.forEach(function(e){ - var ex = new ICAL.Time.fromString(e.getFirstValue().toString()); - if (ex < newStartDate){ + var values = e.getValues(); + values = values.filter(function(value){ + return (new ICAL.Time.fromString(value.toString()) > newStartDate); + }); + if (values.length == 0){ event.removeProperty(e); } + else if(values.length == 1){ + e.setValue(values[0]); + } + else if(values.length > 1){ + e.setValues(values); + } }); var rdates = event.getAllProperties('rdate'); @@ -604,7 +690,7 @@ function checkSkipEvent(event, icalEvent){ * * @param {Calendar.Event} recEvent - The event instance to process */ -function processEventInstance(recEvent, sourceCalendarName){ +function processEventInstance(recEvent){ Logger.log("ID: " + recEvent.extendedProperties.private["id"] + " | Date: "+ recEvent.recurringEventId); var eventInstanceToPatch = callWithBackoff(function(){ @@ -635,16 +721,20 @@ function processEventInstance(recEvent, sourceCalendarName){ } if (eventInstanceToPatch !== null && eventInstanceToPatch.length == 1){ - Logger.log("Updating existing event instance"); - callWithBackoff(function(){ - Calendar.Events.update(recEvent, targetCalendarId, eventInstanceToPatch[0].id); - }, defaultMaxRetries); + if (modifyExistingEvents){ + Logger.log("Updating existing event instance"); + callWithBackoff(function(){ + Calendar.Events.update(recEvent, targetCalendarId, eventInstanceToPatch[0].id); + }, defaultMaxRetries); + } } else{ - Logger.log("No Instance matched, adding as new event!"); - callWithBackoff(function(){ - Calendar.Events.insert(recEvent, targetCalendarId); - }, defaultMaxRetries); + if (addEventsToCalendar){ + Logger.log("No Instance matched, adding as new event!"); + callWithBackoff(function(){ + Calendar.Events.insert(recEvent, targetCalendarId); + }, defaultMaxRetries); + } } } @@ -672,7 +762,7 @@ function processEventCleanup(sourceURL){ }, defaultMaxRetries); if (emailSummary){ - removedEvents.push([[calendarEvents[i].summary, calendarEvents[i].start.date||calendarEvents[i].start.dateTime], targetCalendarName]); + removedEvents.push([[calendarEvents[i].summary, calendarEvents[i].start.date||calendarEvents[i].start.dateTime, calendarEvents[i].end.date||calendarEvents[i].end.dateTime, calendarEvents[i].location, calendarEvents[i].description], targetCalendarName]); } } } @@ -890,7 +980,7 @@ function sendSummary() { var subject; var body; - var subject = `GAS-ICS-Sync Execution Summary: ${addedEvents.length} new, ${modifiedEvents.length} modified, ${removedEvents.length} deleted`; + var subject = `${customEmailSubject ? customEmailSubject : "GAS-ICS-Sync Execution Summary"}: ${addedEvents.length} new, ${modifiedEvents.length} modified, ${removedEvents.length} deleted`; addedEvents = condenseCalendarMap(addedEvents); modifiedEvents = condenseCalendarMap(modifiedEvents); removedEvents = condenseCalendarMap(removedEvents); @@ -899,7 +989,13 @@ function sendSummary() { for (var tgtCal of addedEvents){ body += `
${tgtCal[0]}: ${tgtCal[1].length} added events
"; } @@ -907,7 +1003,18 @@ function sendSummary() { for (var tgtCal of modifiedEvents){ body += `
${tgtCal[0]}: ${tgtCal[1].length} modified events
"; } @@ -915,7 +1022,13 @@ function sendSummary() { for (var tgtCal of removedEvents){ body += `
${tgtCal[0]}: ${tgtCal[1].length} removed events
"; } @@ -985,7 +1098,7 @@ function checkForUpdate(){ var lastAlertedVersion = PropertiesService.getScriptProperties().getProperty("alertedForNewVersion"); try { - var thisVersion = 5.7; + var thisVersion = 5.8; var latestVersion = getLatestVersion(); if (latestVersion > thisVersion && latestVersion != lastAlertedVersion){ @@ -1004,4 +1117,4 @@ function checkForUpdate(){ var version = json_decoded[0]["tag_name"]; return Number(version); } -} \ No newline at end of file +}