Repository: Davepar/gcalendarsync Branch: master Commit: b1e2ef0c2c51 Files: 22 Total size: 78.6 KB Directory structure: gitextract_jvmk037x/ ├── .claspignore ├── .gitignore ├── Code.ts ├── GenericEvent.ts ├── LICENSE ├── README.md ├── Settings.ts ├── SettingsDialog.html ├── Util.ts ├── appsscript.json ├── jasmine.json ├── package.json ├── posttest ├── pretest ├── priorversion/ │ ├── README.md │ └── gcalendarsync.js ├── tests/ │ ├── Code_test.ts │ ├── FakeCalendarEvent.ts │ ├── GenericEvent_test.ts │ ├── Settings_test.ts │ └── Util_test.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claspignore ================================================ # ignore all files... **/** # except the extensions... !appsscript.json !**/*.gs !**/*.js !**/*.ts !**/*.html # ignore even valid files if in... .git/** node_modules/** tests/** site/** priorversion/** ================================================ FILE: .gitignore ================================================ node_modules/ .clasp.json *.sublime-* *.code-workspace ================================================ FILE: Code.ts ================================================ // Script to synchronize a calendar to a spreadsheet and vice versa. // // See https://github.com/Davepar/gcalendarsync for instructions on setting this up. // // All settings are now located in a pop-dialog or in the Settings.ts file. // These imports are only used for testing. Run pretest and posttest scripts to automatically // uncomment and re-comment these lines. /*% import {Settings, AllDayValue} from './Settings'; %*/ /*% import {Util} from './Util'; %*/ /*% import {EventColor, GenericEvent, GenericEventKey} from './GenericEvent'; %*/ // Check that the user authorized permissions for the add-on. When the add-on is installed by somebody // and used in a shared doc, for everybody else the add-on will be only "enabled" but not intalled. // They must visit the auth URL to add the permissions. function checkAuth(authMode: GoogleAppsScript.Script.AuthMode) { const authInfo = ScriptApp.getAuthorizationInfo(authMode); if (authInfo.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED) { const url = authInfo.getAuthorizationUrl(); Util.errorAlert(`Visit: ${url}\n to authorize the permissions needed for GCalendar Sync. Then reload this sheet.`) return false; } return true; } // Create the add-on menu when a doc is opened. function onOpen() { if (!checkAuth(ScriptApp.AuthMode.LIMITED)) { return; } SpreadsheetApp.getUi().createMenu('GCalendar Sync') .addItem('Update from Calendar', 'syncFromCalendar') .addItem('Update to Calendar', 'syncToCalendar') .addItem('Settings', 'Settings.showSettingsDialog') .addToUi(); } // Create the menu when this add-on is installed. function onInstall() { onOpen(); } // Synchronize from calendar to spreadsheet. function syncFromCalendar() { if (!checkAuth(ScriptApp.AuthMode.FULL)) { return; } const userSettings = Settings.loadFromPropertyService(); //Logger.log('Starting sync from calendar'); // Loop through all sheets const allSheets = SpreadsheetApp.getActiveSpreadsheet().getSheets(); let calendarIdsFound: string[] = []; for (let sheet of allSheets) { const sheetName = sheet.getName(); // Get sheet data and pull calendar ID from first row let data = sheet.getDataRange().getValues(); if (data.length <= Util.CALENDAR_ID_ROW || !data[Util.CALENDAR_ID_ROW][0].replace(/\s/g, '').toLowerCase().startsWith('calendarid')) { // Only sync sheets that start with "Calendar ID" in cell A1 continue; } // Get calendar events const calendarId = data[Util.CALENDAR_ID_ROW][1]; if (calendarIdsFound.indexOf(calendarId) >= 0) { if (Util.errorAlertHalt(`Calendar ID "${calendarId}" is in more than one sheet. This can have unpredictable results.`)) { return; } } calendarIdsFound.push(calendarId); let calendar = CalendarApp.getCalendarById(calendarId); if (!calendar) { Util.errorAlert(`Could not find calendar with ID "${calendarId}" from sheet "${sheetName}".`); continue; } const calEvents = calendar.getEvents(userSettings.begin_date, userSettings.end_date); // Check if spreadsheet needs a title row added if (data.length <= Util.TITLE_ROW || (data.length == Util.TITLE_ROW + 1 && data[Util.TITLE_ROW].length == 1 && data[Util.TITLE_ROW][0] === '')) { Util.setUpSheet(sheet, calEvents.length); // Refresh data from first two rows data = sheet.getDataRange().getValues().slice(0, Util.FIRST_DATA_ROW); } // Map spreadsheet column titles to indices const idxMap = Util.createIdxMap(data[Util.TITLE_ROW]); // Verify title row has all required fields const includeAllDay = userSettings.all_day_events === AllDayValue.use_column; let missingFields = Util.missingRequiredFields(idxMap, includeAllDay); if (missingFields.length > 0) { Util.displayMissingFields(missingFields, sheetName); continue; } // Get all of the event IDs from the sheet const idIdx = idxMap.indexOf('id'); const sheetEventIds = data.map(row => row[idIdx]); // Loop through calendar events and update or add to sheet data let eventFound = new Array(data.length); for (let calEvent of calEvents) { const calEventId = calEvent.getId(); let rowIdx = sheetEventIds.indexOf(calEventId); if (rowIdx < Util.FIRST_DATA_ROW) { // Event not found, create it rowIdx = data.length; let newRow = Array(idxMap.length).fill(''); data.push(newRow); } else { eventFound[rowIdx] = true; } // Update event in spreadsheet data GenericEvent.fromCalendarEvent(calEvent).toSpreadsheetRow(idxMap, data[rowIdx]); } // Remove any data rows not found in the calendar from the bottom up let rowsDeleted = 0; for (let idx = eventFound.length - 1; idx > Util.TITLE_ROW; idx--) { if (!eventFound[idx] && sheetEventIds[idx]) { data.splice(idx, 1); rowsDeleted++; } } // Save spreadsheet changes let range = sheet.getRange(1, 1, data.length, data[Util.TITLE_ROW].length); range.setValues(data); if (rowsDeleted > 0) { sheet.deleteRows(data.length + 1, rowsDeleted); } } if (calendarIdsFound.length === 0) { Util.errorAlert('Could not find any calendar IDs in sheets. See Help for setup instructions.'); } } // Synchronize from spreadsheet to calendar. function syncToCalendar() { if (!checkAuth(ScriptApp.AuthMode.FULL)) { return; } const userSettings = Settings.loadFromPropertyService(); //Logger.log('Starting sync to calendar'); let scriptStart = Date.now(); // Loop through all sheets const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); const sheetTimeZone = spreadsheet.getSpreadsheetTimeZone(); const allSheets = spreadsheet.getSheets(); let calendarIdsFound: string[] = []; for (let sheet of allSheets) { const sheetName = sheet.getName(); // Get sheet data and pull calendar ID from first row let range = sheet.getDataRange(); let data = range.getValues(); if (!data[Util.CALENDAR_ID_ROW][0].replace(/\s/g, '').toLowerCase().startsWith('calendarid')) { // Only sync sheets that have a calendar ID continue; } if (data.length < Util.FIRST_DATA_ROW + 1) { Util.errorAlert(`Sheet "${sheetName}" must have a title row and at least one data row.`); continue; } // Get calendar events const calendarId = data[Util.CALENDAR_ID_ROW][1]; if (calendarIdsFound.indexOf(calendarId) >= 0) { if (Util.errorAlertHalt(`Calendar ID "${calendarId}" is in more than one sheet. This can have unpredictable results.`)) { return; } } calendarIdsFound.push(calendarId); let calendar = CalendarApp.getCalendarById(calendarId); if (!calendar) { Util.errorAlert(`Could not find calendar with ID "${calendarId}" from sheet "${sheetName}".`); continue; } const calTimeZone = calendar.getTimeZone(); const calEvents = calendar.getEvents(userSettings.begin_date, userSettings.end_date); let calEventIds = calEvents.map(val => val.getId()); // Map column headers to indices let idxMap = Util.createIdxMap(data[Util.TITLE_ROW]); // Verify title row has all required fields const includeAllDay = userSettings.all_day_events === AllDayValue.use_column; let missingFields = Util.missingRequiredFields(idxMap, includeAllDay); if (missingFields.length > 0) { Util.displayMissingFields(missingFields, sheetName); continue; } let keysToAdd = Util.missingFields(idxMap); // Get calendar IDs let idIdx = idxMap.indexOf('id'); let idRange = range.offset(0, idIdx, data.length, 1); let idData = idRange.getValues() // Loop through sheet rows let numAdded = 0; let numUpdates = 0; let eventsAdded = false; for (let ridx = Util.FIRST_DATA_ROW; ridx < data.length; ridx++) { let sheetEvent = GenericEvent.fromSpreadsheetRow(data[ridx], idxMap, keysToAdd, userSettings.all_day_events, sheetTimeZone); // If enabled, skip rows with blank/invalid start and end times if (userSettings.skip_blank_rows && !(sheetEvent.starttime instanceof Date) && !(sheetEvent.endtime instanceof Date)) { continue; } // Do some error checking first if (!sheetEvent.title) { Util.errorAlert('must have title', sheetEvent, ridx); continue; } if (!(sheetEvent.starttime instanceof Date)) { Util.errorAlert('start time must be a date/time', sheetEvent, ridx); continue; } if (!(sheetEvent.endtime instanceof Date)) { Util.errorAlert('end time must be a date/time', sheetEvent, ridx); continue; } if (sheetEvent.endtime < sheetEvent.starttime) { Util.errorAlert('end time must be after start time', sheetEvent, ridx); continue; } // Ignore events outside of the begin/end range desired. if (sheetEvent.starttime > userSettings.end_date) { continue; } if (sheetEvent.endtime < userSettings.begin_date) { continue; } // Determine if spreadsheet event is already in calendar and matches let addEvent = true; if (sheetEvent.id) { let eventIdx = calEventIds.indexOf(sheetEvent.id); if (eventIdx >= 0) { calEventIds[eventIdx] = null; // Prevents removing event below addEvent = false; let calEvent = calEvents[eventIdx]; let calGenericEvent = GenericEvent.fromCalendarEvent(calEvent); let eventDiffs = calGenericEvent.eventDifferences(sheetEvent); if (eventDiffs > 0) { // When there are only 1 or 2 event differences, it's quicker to // update the event. For more event diffs, delete and re-add the event. if (eventDiffs < 3) { numUpdates += calGenericEvent.updateEvent(sheetEvent, calEvent); } else { addEvent = true; calEventIds[eventIdx] = sheetEvent.id; } } } } //Logger.log(`${sheetEvent.title} ${numUpdates} updates, time: ${Date.now() - scriptStart} msecs`); if (addEvent) { const eventOptions = { description: sheetEvent.description, location: sheetEvent.location, guests: sheetEvent.guests, sendInvites: userSettings.send_email_invites, } let newEvent: GoogleAppsScript.Calendar.CalendarEvent; if (sheetEvent.allday) { if (sheetEvent.endtime.getHours() === 23 && sheetEvent.endtime.getMinutes() === 59) { sheetEvent.endtime.setSeconds(sheetEvent.endtime.getSeconds() + 1); } newEvent = calendar.createAllDayEvent(sheetEvent.title, sheetEvent.starttime, sheetEvent.endtime, eventOptions); } else { newEvent = calendar.createEvent(sheetEvent.title, sheetEvent.starttime, sheetEvent.endtime, eventOptions); } // Put event ID back into spreadsheet idData[ridx][0] = newEvent.getId(); eventsAdded = true; // Set event color const numericColor = parseInt(sheetEvent.color); if (numericColor > 0 && numericColor < 12) { newEvent.setColor(sheetEvent.color); } // Throttle updates. numAdded++; Utilities.sleep(Settings.THROTTLE_SLEEP_TIME); if (numAdded % 10 === 0) { //Logger.log('%d events added, time: %d msecs', numAdded, Date.now() - scriptStart); } } // If the script is getting close to timing out, save the event IDs added so far to avoid lots // of duplicate events. if ((Date.now() - scriptStart) > Settings.MAX_RUN_TIME) { idRange.setValues(idData); } } // Save spreadsheet changes if (eventsAdded) { idRange.setValues(idData); } // Remove any calendar events not found in the spreadsheet const countNonNull = (prevVal: number, curVal: string) => curVal === null ? prevVal : prevVal + 1; const numToRemove = calEventIds.reduce(countNonNull, 0); if (numToRemove > 0) { const ui = SpreadsheetApp.getUi(); const response = ui.alert(`Delete ${numToRemove} calendar event(s) not found in spreadsheet?`, ui.ButtonSet.YES_NO); if (response == ui.Button.YES) { let numRemoved = 0; calEventIds.forEach((id, idx) => { if (id != null) { calEvents[idx].deleteEvent(); Utilities.sleep(Settings.THROTTLE_SLEEP_TIME); numRemoved++; // if (numRemoved % 10 === 0) { // Logger.log('%d events removed, time: %d msecs', numRemoved, Date.now() - scriptStart); // } } }); } } } if (calendarIdsFound.length === 0) { Util.errorAlert('Could not find any calendar IDs in sheets. See Help for setup instructions.'); } } // Simple function to test syntax of this script, since otherwise it's not exercised until the // code is uploaded via Clasp and run in Sheets. export function exerciseSyntax() { return true; } ================================================ FILE: GenericEvent.ts ================================================ // These imports are only used for testing. Run pretest and posttest scripts to automatically // uncomment and re-comment these lines. /*% import {Settings, AllDayValue} from './Settings'; %*/ /*% export %*/ enum EventColor { PALE_BLUE, PALE_GREEN, MAUVE, PALE_RED, YELLOW, ORANGE, CYAN, GRAY, BLUE, GREEN, RED } /*% export %*/ type GenericEventKey = 'id' | 'title' | 'description' | 'location' | 'guests' | 'color' | 'allday' | 'starttime' | 'endtime'; /*% export %*/ class GenericEvent { constructor( public id: string, public title: string, public description: string, public location: string, public guests: string, public color: string, // Stored as color number public allday: boolean, public starttime: Date, public endtime: Date ) { } static fromArray(params: any[]) { let [id, title, description, location, guests, color, allday, starttime, endtime] = params; let convertedColor = color ? (EventColor[color] + 1).toString() : ''; return new GenericEvent(id, title, description, location, guests, convertedColor, allday, starttime, endtime); } // Converts a GCalendar event to an instance of this class. static fromCalendarEvent(calEvent: GoogleAppsScript.Calendar.CalendarEvent) { const allday = calEvent.isAllDayEvent(); let starttime: Date, endtime: Date; if (allday) { starttime = calEvent.getAllDayStartDate() as Date; endtime = calEvent.getAllDayEndDate() as Date; if (endtime.getHours() === 0 && endtime.getMinutes() === 0 && endtime.getSeconds() === 0) { endtime.setDate(endtime.getDate() - 1); } } else { starttime = calEvent.getStartTime() as Date; endtime = calEvent.getEndTime() as Date; } return new GenericEvent( calEvent.getId(), calEvent.getTitle(), calEvent.getDescription(), calEvent.getLocation(), calEvent.getGuestList().map(x => x.getEmail()).join(','), calEvent.getColor(), allday, starttime, endtime); } // Convert a spreadsheet row to an instance of this class. static fromSpreadsheetRow(row: any[], idxMap: GenericEventKey[], keysToAdd: string[], all_day_events: AllDayValue, timeZone = 'America/Los_Angeles') { const eventObject = row.reduce((event, value, idx) => { const field = idxMap[idx]; if (field != null) { if (field === 'starttime' || field === 'endtime') { event[field] = (isNaN(value) || value == 0) ? null : value; } else if (field === 'allday') { event[field] = (value === true); } else if (field === 'color') { if (value) { value = EventColor[value]; event[field] = isNaN(value) ? '' : (value + 1).toString(); } else { event[field] = ''; } } else { event[field] = value; } } return event; }, {}); for (let keyToAdd of keysToAdd) { eventObject[keyToAdd] = (keyToAdd === 'starttime' || keyToAdd === 'endTime') ? null : ''; } let { id, title, description, location, guests, color, allday, starttime, endtime } = eventObject; if (all_day_events !== AllDayValue.use_column) { allday = (all_day_events === AllDayValue.always_all_day); } // Adjust allday events to correct time zone and add 1 day to end time if (allday) { starttime = GenericEvent.convertTimeZone(starttime, timeZone); if (endtime) { endtime = GenericEvent.convertTimeZone(endtime, timeZone, 1); } } return new GenericEvent(id, title, description, location, guests, color, allday, starttime, endtime); } toSpreadsheetRow(idxMap: GenericEventKey[], spreadsheetRow: any[]) { for (let idx = 0; idx < idxMap.length; idx++) { if (idxMap[idx] !== null) { const label = idxMap[idx]; const value = this[label] as any; if (label === 'allday') { spreadsheetRow[idx] = !!value; } else if (label === 'color') { spreadsheetRow[idx] = value ? EventColor[Number(value) - 1] : ''; } else { spreadsheetRow[idx] = value; } } } } // Determines the number of field differences between this and a another event eventDifferences(other: GenericEvent) { let eventDiffs = 0; if (this.title !== other.title) eventDiffs += 1; if (this.description !== other.description) eventDiffs += 1; if (this.location !== other.location) eventDiffs += 1; if (this.starttime.toString() !== other.starttime.toString()) eventDiffs += 1; if (this.endtime.toString() !== other.endtime.toString()) eventDiffs += 1; if (this.guests !== other.guests) eventDiffs += 1; if (this.color !== other.color) eventDiffs += 1; if (this.allday !== other.allday) eventDiffs += 1; if (eventDiffs > 0 && this.guests) { // When an event changes and it has guests, set the diffs to one. This will force the // calling function to update all of the fields instead of deleting and re-adding the event, // which would force every guest to re-confirm the event. eventDiffs = 1; } return eventDiffs; } // Updates a calendar event from a sheet event. updateEvent(sheetEvent: GenericEvent, calEvent: GoogleAppsScript.Calendar.CalendarEvent) { let numChanges = 0; const isAllDayChanged = (this.allday !== sheetEvent.allday); if (this.starttime.toString() !== sheetEvent.starttime.toString() || this.endtime.toString() !== sheetEvent.endtime.toString() || isAllDayChanged) { if (sheetEvent.allday) { calEvent.setAllDayDates(sheetEvent.starttime, sheetEvent.endtime); } else { calEvent.setTime(sheetEvent.starttime, sheetEvent.endtime); } numChanges++; } if (this.title !== sheetEvent.title) { calEvent.setTitle(sheetEvent.title); numChanges++; } if (this.description !== sheetEvent.description) { calEvent.setDescription(sheetEvent.description); numChanges++; } if (this.location !== sheetEvent.location) { calEvent.setLocation(sheetEvent.location); numChanges++; } if (this.color !== ('' + sheetEvent.color)) { const color = parseInt(sheetEvent.color); if (color > 0 && color < 12) { calEvent.setColor('' + color); numChanges++; } } if (this.guests !== sheetEvent.guests) { const guestCal = calEvent.getGuestList().map(x => ({ email: x.getEmail(), added: false })); const sheetGuests = sheetEvent.guests || ''; let guests = sheetGuests.split(',').map((x) => x ? x.trim() : ''); // Check guests that are already invited. for (let gIx = 0; gIx < guestCal.length; gIx++) { const index = guests.indexOf(guestCal[gIx].email); if (index >= 0) { guestCal[gIx].added = true; guests.splice(index, 1); } } for (let guest of guests) { if (guest) { calEvent.addGuest(guest); numChanges++; } } for (let guest of guestCal) { if (!guest.added) { calEvent.removeGuest(guest.email); numChanges++; } } } // Throttle updates. Utilities.sleep(Settings.THROTTLE_SLEEP_TIME * numChanges); return numChanges; } // AppScripts seem to always run in Pacific time. This function will convert // a date to whatever time zone the script is running in. static convertTimeZone(d: Date, timeZone: string, daydelta = 0) { if (!(d instanceof Date)) { return null } const adjDate = d.toLocaleDateString('en-US', { timeZone }); let [month, day, year] = adjDate.split('/').map(x => parseInt(x)); return new Date(year, month - 1, day + daydelta) } } // GenericEvent ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Dave Parsons Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # UPDATE: Unfortunately, Google has removed this add-on from their marketplace. Somehow a review was triggered again, and Google is requiring additional steps to re-publish the add-on. Their review was a difficult process the first time I went through it, and I don't want to do it again. I enjoy coding and was happy to provide this free add-on to the community. However, I don't wish to spend time on needless bureaucracy. Sorry, but for now the add-on is no longer available. # GCalendar Sync A Google Sheet add-on for synchronizing events with Google Calendar. [See the website](http://www.ballardsoftwarefoundry.com/gcalendarsync.html) for full instructions. The add-on is in the [G Suite Marketplace](https://gsuite.google.com/marketplace/app/gcalendar_sync/831559814916). ## Contributing The project started as a simple script to publish swim team practice times on a Google Calendar and has evolved from there. Fixes and improvements are done by volunteers, so please be patient. You're also welcome to dive into the code and send suggested changes and fixes as a pull request or as text. To try your own changes, you'll need to install Clasp in order to compile the Typescript and push it to a project. Follow the instructions [here](https://developers.google.com/apps-script/guides/typescript). This will run the tests: $ npm test ================================================ FILE: Settings.ts ================================================ /*% import {Util} from './Util'; %*/ // Values for the "all day" setting. The enum names must match the IDs in the HTML radio // button definitions. /*% export %*/ enum AllDayValue { use_column = 'USE_COLUMN', always_all_day = 'ALWAYS_ALL_DAY', never_all_day = 'NEVER_ALL_DAY', } // Defines the fields for user settings in the dialog and PropertiesService. interface SettingsBaseType { begin_date: string; end_date: string; send_email_invites: boolean; skip_blank_rows: boolean; all_day_events: string; } /*% export %*/ class Settings { static readonly SETTINGS_VERSION = 'v1'; // Updating too many events in a short time period triggers an error. These values // were successfully used for deleting and adding 240 events. Values in milliseconds. static readonly THROTTLE_SLEEP_TIME = 200; static readonly MAX_RUN_TIME = 5.75 * 60 * 1000; // These member names must match the names used in SettingsDialog.html. constructor( public begin_date: Date, public end_date: Date, public send_email_invites: boolean, public skip_blank_rows: boolean, public all_day_events: AllDayValue, ) { } // Show modal dialog for sync settings. static showSettingsDialog() { const html = HtmlService.createHtmlOutputFromFile('SettingsDialog'); SpreadsheetApp.getUi().showModalDialog(html, 'Settings'); } static getDefaultSettings(): Settings { return new Settings(new Date(1970, 0, 1), new Date(2500, 0, 1), false, false, AllDayValue.use_column); } // Retrieves settings from storage. static loadFromPropertyService(propertiesService = PropertiesService) { const storedSettingsJson = propertiesService.getDocumentProperties().getProperty(Settings.SETTINGS_VERSION); const storedSettings = JSON.parse(storedSettingsJson) as SettingsBaseType; if (!storedSettings) { // Get defaults and store them. const defaultSettings = Settings.getDefaultSettings(); Settings.saveToPropertyService(defaultSettings.convertToBaseTypes(), propertiesService); return defaultSettings; } // The JSON parser won't correctly parse dates, so manually do it const begin_date = new Date(storedSettings.begin_date); const end_date = new Date(storedSettings.end_date); return new Settings(begin_date, end_date, storedSettings.send_email_invites, storedSettings.skip_blank_rows, storedSettings.all_day_events as AllDayValue); } // Save user settings entered in modal dialog. static saveToPropertyService(formValues: SettingsBaseType, propertiesService = PropertiesService) { // Check that dates are valid. if (!Util.isValidDate(formValues.begin_date)) { throw ('Invalid start date'); } if (!Util.isValidDate(formValues.end_date)) { throw ('Invalid end date'); } propertiesService.getDocumentProperties().setProperty(Settings.SETTINGS_VERSION, JSON.stringify(formValues)); return true; } // Convert to base type convertToBaseTypes(): SettingsBaseType { return { begin_date: Settings.convertDateToString(this.begin_date), end_date: Settings.convertDateToString(this.end_date), send_email_invites: this.send_email_invites, skip_blank_rows: this.skip_blank_rows, all_day_events: this.all_day_events, }; } // Called by HTML script to get saved settings in a format compatible with the form. static convertForDialog(): SettingsBaseType { const userSettings = Settings.loadFromPropertyService(); const convertedSettings = userSettings.convertToBaseTypes(); convertedSettings.all_day_events = convertedSettings.all_day_events.toLowerCase(); return convertedSettings; } // Formats a date for display in the settings dialog, e.g. 2020-3-1. static convertDateToString(datestr: Date): string { return `${datestr.getFullYear()}-${datestr.getMonth() + 1}-${datestr.getDate()}`; } } // This can be used during debugging from the Script Editor to remove all user settings. function killUserSettings() { PropertiesService.getDocumentProperties().deleteAllProperties(); } // Make these two static methods available to the dialog JS. function convertForDialog() { return Settings.convertForDialog(); } function saveToPropertyService(formValues: SettingsBaseType) { return Settings.saveToPropertyService(formValues); } ================================================ FILE: SettingsDialog.html ================================================
v1.0.17
All day events:
  Help
================================================ FILE: Util.ts ================================================ // Utility functions /*% import {EventColor, GenericEvent, GenericEventKey} from './GenericEvent'; %*/ const TITLE_ROW_MAP: Map = new Map([ ['title', 'Title'], ['description', 'Description'], ['location', 'Location'], ['starttime', 'Start Time'], ['endtime', 'End Time'], ['guests', 'Guests'], ['color', 'Color'], ['allday', 'All Day'], ['id', 'Id'], ]); /*% export %*/ class Util { // Be aware that row indices are tricky, since Apps Script is one-based and Typescript arrays are zero-based. // These values are zero-based, so add one when using them with Apps Script API. static CALENDAR_ID_ROW = 0; static TITLE_ROW = 1; static FIRST_DATA_ROW = 2; static MAX_DATA_ROWS = 999; // Sets up an empty spreadsheet with a title row and suggested data formats static setUpSheet(sheet: GoogleAppsScript.Spreadsheet.Sheet, numDataRows: number) { // Date format to use in the spreadsheet. Meaning of letters defined at // https://developers.google.com/sheets/api/guides/formats const dateFormat = 'M/d/yyyy H:mm a/p'; // Add title row const titleRowValues = Array.from(TITLE_ROW_MAP.values()); let range = sheet.getRange(Util.TITLE_ROW + 1, 1, 1, titleRowValues.length); range.setValues([titleRowValues]); // Set up date formats, checkbox for all day, and dropdown for color names const titleRowKeys = Array.from(TITLE_ROW_MAP.keys()); const getRangeByFieldName = (fieldName: GenericEventKey, numRows: number) => sheet.getRange(Util.FIRST_DATA_ROW + 1, titleRowKeys.indexOf(fieldName) + 1, numRows); getRangeByFieldName('starttime', Util.MAX_DATA_ROWS).setNumberFormat(dateFormat); getRangeByFieldName('endtime', Util.MAX_DATA_ROWS).setNumberFormat(dateFormat); let checkboxRule = SpreadsheetApp.newDataValidation().requireCheckbox().build(); numDataRows = Math.max(numDataRows, 1); getRangeByFieldName('allday', numDataRows).setDataValidation(checkboxRule); const colorList = []; for (let enumColor in EventColor) { if (isNaN(parseInt(enumColor, 10))) { colorList.push(enumColor); } } let colorDropdownRule = SpreadsheetApp.newDataValidation().requireValueInList(colorList, true).build(); getRangeByFieldName('color', numDataRows).setDataValidation(colorDropdownRule); // Hide ID column so people are less liken to modify it accidentally sheet.hideColumns(titleRowKeys.indexOf('id') + 1); } // Creates a mapping array between spreadsheet column and event field name static createIdxMap(row: any[]): GenericEventKey[] { let idxMap: GenericEventKey[] = []; for (let fieldFromHdr of row) { let found = false; for (let titleKey of Array.from(TITLE_ROW_MAP.keys())) { if (TITLE_ROW_MAP.get(titleKey) == fieldFromHdr) { idxMap.push(titleKey); found = true; break; } } if (!found) { // Header field not in map, so add null idxMap.push(null); } } return idxMap; } // Returns list of fields that aren't in spreadsheet static missingFields(idxMap: GenericEventKey[]): GenericEventKey[] { return Array.from(TITLE_ROW_MAP.keys()).filter(val => idxMap.indexOf(val) < 0); } // Return list of missing required fields. static missingRequiredFields(idxMap: GenericEventKey[], includeAllDay: boolean): GenericEventKey[] { let requiredFields: GenericEventKey[] = ['id', 'title', 'starttime', 'endtime']; if (includeAllDay) { requiredFields.push('allday'); } return requiredFields.filter(val => idxMap.indexOf(val) < 0); } static displayMissingFields(missingFields: GenericEventKey[], sheetName: string) { const reqFieldNames = missingFields.map(x => `"${TITLE_ROW_MAP.get(x)}"`).join(', '); Util.errorAlert(`Sheet "${sheetName}" is missing ${reqFieldNames} columns. See Help for setup instructions.`); } // Display error alert static errorAlert(msg: string, evt: GenericEvent = null, ridx = 0) { const ui = SpreadsheetApp.getUi(); if (evt) { ui.alert(`Skipping row: ${msg} in event "${evt.title}", row ${ridx + 1}`); } else { ui.alert(msg); } } static errorAlertHalt(msg: string) { const ui = SpreadsheetApp.getUi(); const response = ui.alert(`${msg} Continue?`, ui.ButtonSet.YES_NO); return response === ui.Button.NO; } static isValidDate(d: string) { return isNaN(Date.parse(d)) === false; } } ================================================ FILE: appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": { }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.container.ui" ] } ================================================ FILE: jasmine.json ================================================ { "spec_dir": "tests", "spec_files": ["**/*[tT]est.ts"] } ================================================ FILE: package.json ================================================ { "name": "gcalendarsync", "version": "1.0.0", "description": "Google Apps Script for syncing a calendar with a sheet.", "main": "gcalendarsync.js", "scripts": { "pretest": "./pretest", "posttest": "./posttest", "test": "ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json || :" }, "repository": { "type": "git", "url": "git+https://github.com/Davepar/gcalendarsync.git" }, "keywords": [ "gcalendar", "spreadsheet", "calendar", "sync" ], "author": "", "license": "ISC", "bugs": { "url": "https://github.com/Davepar/gcalendarsync/issues" }, "homepage": "https://github.com/Davepar/gcalendarsync#readme", "dependencies": {}, "devDependencies": { "@types/google-apps-script": "1.0.37", "@types/jasmine": "^3.8.2", "jasmine": "^3.8.0", "ts-node": "^10.2.1", "typescript": "^4.3.5" } } ================================================ FILE: posttest ================================================ #!/bin/bash for f in *.ts; do sed -i 's|/\*% \*/|/\*%|;s|/\* %\*/|%\*/|' $f done ================================================ FILE: pretest ================================================ #!/bin/bash for f in *.ts; do sed -i 's|/\*%|/\*% \*/|;s|%\*/|/\* %\*/|' $f done ================================================ FILE: priorversion/README.md ================================================ # gcalendarsync **Note:** This is an old version. A newer version exists which is available in the GSuite Marketplace, making it much easier to install and use. See the [new version](https://github.com/Davepar/gcalendarsync) for more info. Apps Script for syncing a Google Spreadsheet with Google Calendar. Two commands are added in a "Calendar Sync" menu. One copies events from a calendar into a spreadsheet. The other commands goes in the opposite direction, spreadsheet to calendar. I started this project for creating and updating a quarterly calendar for my swimming group. It's much easier to type a work out schedule into a spreadsheet than Google Calendar. I'm no longer actively using the script, but happy to work on bugs or features occasionally. Also feel free to make fixes yourself and send me pull requests. (Oct 2017) ## Limitations WARNING: Events may be removed! If you're copying to the calendar, any event not found in the spreadsheet will be deleted! Likewise copying to the spreadsheet will delete any rows not found in the calendar. It's a good idea to try copying into a fresh spreadsheet tab as an experiment the first time you run it. "Undo" may also save you. Recurring events are not currenty supported. Google Calendar doesn't support multi-day "all day" events in the API. This causes the "all day" indicator to disappear when syncing multi-day events to the spreadsheet and back to the calendar. Leave the end time blank in the spreadsheet to create an all day event for one day. ## Set Up **Part 1** Set up the calendar: * Create a new Google Calendar (in the dropdown next to "Other calendars" in the left sidebar of Calendar). * Give the calendar a name and change other fields as desired, e.g. time zone. Exit settings and you should see the new calendar in the left sidebar. * Open the new calendar's settings ("Settings and sharing" in the dropdown next to the calendar name). * Scroll down to the "Integrate calendar" section. Copy the "Calendar ID". It should look like an email address. **Part 2** You have 2 options for the spreadsheet. Copy an example spreadsheet that already has the script set up, or use your own spreadsheet and add the script to it. The first option is a little easier. Using your own spreadsheet will just take a little extra attention to detail to set up the column headers correctly. Option 2a. Copy and modify the example spreadsheet: * Make a copy of [this spreadsheet](https://docs.google.com/spreadsheets/d/1b0BBnmoDT4uDbN0pYsH--mpasFR45QlgNMTwUH-7MqU) (use File -> Make a copy). * In the Tools menu, select Script Editor. * Replace the "calendarId" value near the beginning of the script with the Calendar ID from Part 1, above. * Set the correct time zone in File, Spreadsheet settings. * Save the script. Option 2b. If instead you want to create a new spreadsheet from scratch, or use one you already have: * Create or open a Google Spreadsheet at http://drive.google.com. * Create columns with these exact names (can be in any order and capitalization isn't significant): * Title - event title * Description - event description (optional) * Start Time - start date and time for the event, e.g. "1/27/2016 5:25pm". Should be just a date for all-day events. Set the format of this column to "Date time". * End Time - end date and time for the event. Set to blank for a one day all-day event. Set the format of this column to "Date time". * Location - event location. (optional) * Guests - comma separated list of guest email addresses. (optional) * Color - a number from 1 to 11 that represents a color to set on the event. See the [list of colors](https://developers.google.com/apps-script/reference/calendar/event-color). (optional) * Id - used for syncing with calendar. This column could be hidden to prevent accidental edits. * In the Tools menu, select Script Editor. * Give the project a name. * Paste in the code from [gcalendarsync.js](https://raw.githubusercontent.com/Davepar/gcalendarsync/master/gcalendarsync.js). * For the "calendarId" value in the script, paste in the Calendar ID from above. * Save the script. That's it. Start entering and modifying events. You can add extra columns, and they'll be ignored. See the "How to Sync" section below for how to run the script. ## Configuration options There are three variables near the top of the script that can be modified: * beginDate/endDate - Set these to sync up a smaller range of dates. The numbers are year, month, date, where month is 0 for Jan through 11 for Dec. beginDate can also be set to today's date to ignore everything in the past: `var beginDate = new Date()`. When syncing from calendar to sheet, any rows outside the range will be removed. Syncing from sheet to calendar simply ignores any events outside the range. * dateFormat - The date/time format to use when setting up the spreadsheet. ## Custom column names Custom column names are now supported. In the script, find the "titleRowMap" variable. Change the second entry on each line to match your column names. If you're not using one of the optional fields, just leave it in titleRowMap. ## Time zones There doesn't seem to be a way to enter a time zone for individual events into Google Spreadsheet. The only option is to change the timezone for the entire spreadsheet. Look in the File menu, Spreadsheet settings ([more info](https://support.google.com/docs/answer/58515?hl=en)). ## How to Sync In the spreadsheet, select the desired sheet (tabs at the bottom of the spreadsheet) and then look for a "Calendar Sync" menu. Choose "Update from Calendar" or "Update to Calendar" depending on the direction you want to sync. The first time you do this, an "Authorization Required" dialog will pop up. Click "Continue" and select your account in the next dialog. Then a somewhat scary dialog will appear. Google is trying to protect against some malicious Apps scripts that were floating around. I haven't jumped through the steps yet to become a trusted developer. By publishing the source for this script, everybody can verify for themselves that it isn't doing anything nefarious. If you trust that, click on "Advanced" and then the link at the bottom ending in "(unsafe)". Finally, click "Allow" in the next dialog. Depending on the number of changes, the script runs in a few seconds to a few minutes. ## Automatically syncing You can also set up the script to automatically update a calendar whenever the sheet is updated. This is really handy if your sheet is associated with a form for adding events. Use the Run -> Run function menu to execute the "createSpreadsheetEditTrigger" function one time. You will need to approve some special permissions. A popup dialog will say "This app isn't verified". This is because the spreadsheet will be modifying the calendar even when you aren't logged in. You can get around this by clicking "Advanced" in the dialog and then clicking on your spreadsheet name. Approve the permissions in the next dialog. You can modify the trigger by reading the [documentation](https://developers.google.com/apps-script/guides/triggers/events). To remove the trigger, use the same menu command to run the deleteTrigger function. IMPORTANT: Be careful who has permissions to edit the spreadsheet and script. Once you set up the trigger to run, someone else could modify the script maliciously. ## Troubleshooting When an error occurs, the sync can generally be tried again without any bad side effects. There are some data checks in the script for correctly formatted dates and times. If you see a pop-up dialog, it will tell you which event has the error. Fix the error and run it again. See the [test spreadsheet](https://docs.google.com/spreadsheets/d/1b0BBnmoDT4uDbN0pYsH--mpasFR45QlgNMTwUH-7MqU) for examples of correct and incorrect date/times. If the script runs more than several minutes, it will run out of time and be stopped. You should be able to run it again and it will do the next batch of changes. About 900 calendar operations can be done in one run, where an operation is updating one event field, adding an event, or removing an event. If you get an error about too many Calendar events being added or removed in a short amount of time, try increasing the THROTTLE_SLEEP_TIME value in Utilities.sleep(). I did several experiements with 240 events, and found that 200 msec is very reliable. Other issues? Contact me or file an issue in GitHub. ================================================ FILE: priorversion/gcalendarsync.js ================================================ // Script to synchronize a calendar to a spreadsheet and vice versa. // // See https://github.com/Davepar/gcalendarsync for instructions on setting this up. // // Set this value to match your calendar!!! // Calendar ID can be found in the "Calendar Address" section of the Calendar Settings. var calendarId = '@group.calendar.google.com'; // Set the beginning and end dates that should be synced. beginDate can be set to Date() to use // today. The numbers are year, month, date, where month is 0 for Jan through 11 for Dec. var beginDate = new Date(1970, 0, 1); // Default to Jan 1, 1970 var endDate = new Date(2500, 0, 1); // Default to Jan 1, 2500 // Date format to use in the spreadsheet. var dateFormat = 'M/d/yyyy H:mm'; var titleRowMap = { 'title': 'Title', 'description': 'Description', 'location': 'Location', 'starttime': 'Start Time', 'endtime': 'End Time', 'guests': 'Guests', 'color': 'Color', 'id': 'Id' }; var titleRowKeys = ['title', 'description', 'location', 'starttime', 'endtime', 'guests', 'color', 'id']; var requiredFields = ['id', 'title', 'starttime', 'endtime']; // This controls whether email invites are sent to guests when the event is created in the // calendar. Note that any changes to the event will cause email invites to be resent. var SEND_EMAIL_INVITES = false; // Setting this to true will silently skip rows that have a blank start and end time // instead of popping up an error dialog. var SKIP_BLANK_ROWS = false; // Updating too many events in a short time period triggers an error. These values // were successfully used for deleting and adding 240 events. Values in milliseconds. var THROTTLE_SLEEP_TIME = 200; var MAX_RUN_TIME = 5.75 * 60 * 1000; // Special flag value. Don't change. var EVENT_DIFFS_WITH_GUESTS = 999; // Adds the custom menu to the active spreadsheet. function onOpen() { var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var menuEntries = [ { name: "Update from Calendar", functionName: "syncFromCalendar" }, { name: "Update to Calendar", functionName: "syncToCalendar" } ]; spreadsheet.addMenu('Calendar Sync', menuEntries); } // Creates a mapping array between spreadsheet column and event field name function createIdxMap(row) { var idxMap = []; for (var idx = 0; idx < row.length; idx++) { var fieldFromHdr = row[idx]; for (var titleKey in titleRowMap) { if (titleRowMap[titleKey] == fieldFromHdr) { idxMap.push(titleKey); break; } } if (idxMap.length <= idx) { // Header field not in map, so add null idxMap.push(null); } } return idxMap; } // Converts a spreadsheet row into an object containing event-related fields function reformatEvent(row, idxMap, keysToAdd) { var reformatted = row.reduce(function(event, value, idx) { if (idxMap[idx] != null) { event[idxMap[idx]] = value; } return event; }, {}); for (var k in keysToAdd) { reformatted[keysToAdd[k]] = ''; } return reformatted; } // Converts a calendar event to a psuedo-sheet event. function convertCalEvent(calEvent) { convertedEvent = { 'id': calEvent.getId(), 'title': calEvent.getTitle(), 'description': calEvent.getDescription(), 'location': calEvent.getLocation(), 'guests': calEvent.getGuestList().map(function(x) {return x.getEmail();}).join(','), 'color': calEvent.getColor() }; if (calEvent.isAllDayEvent()) { convertedEvent.starttime = calEvent.getAllDayStartDate(); var endtime = calEvent.getAllDayEndDate(); if (endtime - convertedEvent.starttime === 24 * 3600 * 1000) { convertedEvent.endtime = ''; } else { convertedEvent.endtime = endtime; if (endtime.getHours() === 0 && endtime.getMinutes() == 0) { convertedEvent.endtime.setSeconds(endtime.getSeconds() - 1); } } } else { convertedEvent.starttime = calEvent.getStartTime(); convertedEvent.endtime = calEvent.getEndTime(); } return convertedEvent; } // Converts calendar event into spreadsheet data row function calEventToSheet(calEvent, idxMap, dataRow) { convertedEvent = convertCalEvent(calEvent); for (var idx = 0; idx < idxMap.length; idx++) { if (idxMap[idx] !== null) { dataRow[idx] = convertedEvent[idxMap[idx]]; } } } // Returns empty string or time in milliseconds for Date object function getEndTime(ev) { return ev.endtime === '' ? '' : ev.endtime.getTime(); } // Determines the number of field differences between a calendar event and // a spreadsheet event function eventDifferences(convertedCalEvent, sev) { var eventDiffs = 0 + (convertedCalEvent.title !== sev.title) + (convertedCalEvent.description !== sev.description) + (convertedCalEvent.location !== sev.location) + (convertedCalEvent.starttime.toString() !== sev.starttime.toString()) + (getEndTime(convertedCalEvent) !== getEndTime(sev)) + (convertedCalEvent.guests !== sev.guests) + (convertedCalEvent.color !== ('' + sev.color)); if (eventDiffs > 0 && convertedCalEvent.guests) { // Use a special flag value if an event changed, but it has guests. eventDiffs = EVENT_DIFFS_WITH_GUESTS; } return eventDiffs; } // Determine whether required fields are missing function areRequiredFieldsMissing(idxMap) { return requiredFields.some(function(val) { return idxMap.indexOf(val) < 0; }); } // Returns list of fields that aren't in spreadsheet function missingFields(idxMap) { return titleRowKeys.filter(function(val) { return idxMap.indexOf(val) < 0; }); } // Set up formats and hide ID column for empty spreadsheet function setUpSheet(sheet, fieldKeys) { sheet.getRange(1, fieldKeys.indexOf('starttime') + 1, 999).setNumberFormat(dateFormat); sheet.getRange(1, fieldKeys.indexOf('endtime') + 1, 999).setNumberFormat(dateFormat); sheet.hideColumns(fieldKeys.indexOf('id') + 1); } // Display error alert function errorAlert(msg, evt, ridx) { var ui = SpreadsheetApp.getUi(); if (evt) { ui.alert('Skipping row: ' + msg + ' in event "' + evt.title + '", row ' + (ridx + 1)); } else { ui.alert(msg); } } // Updates a calendar event from a sheet event. function updateEvent(calEvent, convertedCalEvent, sheetEvent){ var numChanges = 0; sheetEvent.sendInvites = SEND_EMAIL_INVITES; if (convertedCalEvent.starttime.toString() !== sheetEvent.starttime.toString() || getEndTime(convertedCalEvent) !== getEndTime(sheetEvent)) { if (sheetEvent.endtime === '') { calEvent.setAllDayDate(sheetEvent.starttime); } else { calEvent.setTime(sheetEvent.starttime, sheetEvent.endtime); } numChanges++; } if (convertedCalEvent.title !== sheetEvent.title) { calEvent.setTitle(sheetEvent.title); numChanges++; } if (convertedCalEvent.description !== sheetEvent.description) { calEvent.setDescription(sheetEvent.description); numChanges++; } if (convertedCalEvent.location !== sheetEvent.location) { calEvent.setLocation(sheetEvent.location); numChanges++; } if (convertedCalEvent.color !== ('' + sheetEvent.color)) { if (sheetEvent.color > 0 && sheetEvent.color < 12) { calEvent.setColor('' + sheetEvent.color); numChanges++; } } if (convertedCalEvent.guests !== sheetEvent.guests) { var guestCal = calEvent.getGuestList().map(function (x) { return { email: x.getEmail(), added: false }; }); var sheetGuests = sheetEvent.guests || ''; var guests = sheetGuests.split(',').map(function (x) { return x ? x.trim() : ''; }); // Check guests that are already invited. for (var gIx = 0; gIx < guestCal.length; gIx++) { var index = guests.indexOf(guestCal[gIx].email); if (index >= 0) { guestCal[gIx].added = true; guests.splice(index, 1); } } guests.forEach(function (guest) { if (guest) { calEvent.addGuest(guest); numChanges++; } }); guestCal.forEach(function (guest) { if (!guest.added) { calEvent.removeGuest(guest.email); numChanges++; } }); } // Throttle updates. Utilities.sleep(THROTTLE_SLEEP_TIME * numChanges); return numChanges; } // Synchronize from calendar to spreadsheet. function syncFromCalendar() { console.info('Starting sync from calendar'); // Get calendar and events var calendar = CalendarApp.getCalendarById(calendarId); var calEvents = calendar.getEvents(beginDate, endDate); // Get spreadsheet and data var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var sheet = spreadsheet.getActiveSheet(); var range = sheet.getDataRange(); var data = range.getValues(); var eventFound = new Array(data.length); // Check if spreadsheet is empty and add a title row var titleRow = []; for (var idx = 0; idx < titleRowKeys.length; idx++) { titleRow.push(titleRowMap[titleRowKeys[idx]]); } if (data.length < 1) { data.push(titleRow); range = sheet.getRange(1, 1, data.length, data[0].length); range.setValues(data); setUpSheet(sheet, titleRowKeys); } if (data.length == 1 && data[0].length == 1 && data[0][0] === '') { data[0] = titleRow; range = sheet.getRange(1, 1, data.length, data[0].length); range.setValues(data); setUpSheet(sheet, titleRowKeys); } // Map spreadsheet headers to indices var idxMap = createIdxMap(data[0]); var idIdx = idxMap.indexOf('id'); // Verify header has all required fields if (areRequiredFieldsMissing(idxMap)) { var reqFieldNames = requiredFields.map(function(x) {return titleRowMap[x];}).join(', '); errorAlert('Spreadsheet must have ' + reqFieldNames + ' columns'); return; } // Array of IDs in the spreadsheet var sheetEventIds = data.slice(1).map(function(row) {return row[idIdx];}); // Loop through calendar events for (var cidx = 0; cidx < calEvents.length; cidx++) { var calEvent = calEvents[cidx]; var calEventId = calEvent.getId(); var ridx = sheetEventIds.indexOf(calEventId) + 1; if (ridx < 1) { // Event not found, create it ridx = data.length; var newRow = []; var rowSize = idxMap.length; while (rowSize--) newRow.push(''); data.push(newRow); } else { eventFound[ridx] = true; } // Update event in spreadsheet data calEventToSheet(calEvent, idxMap, data[ridx]); } // Remove any data rows not found in the calendar var rowsDeleted = 0; for (var idx = eventFound.length - 1; idx > 0; idx--) { //event doesn't exists and has an event id if (!eventFound[idx] && sheetEventIds[idx - 1]) { data.splice(idx, 1); rowsDeleted++; } } // Save spreadsheet changes range = sheet.getRange(1, 1, data.length, data[0].length); range.setValues(data); if (rowsDeleted > 0) { sheet.deleteRows(data.length + 1, rowsDeleted); } } // Synchronize from spreadsheet to calendar. function syncToCalendar() { console.info('Starting sync to calendar'); var scriptStart = Date.now(); // Get calendar and events var calendar = CalendarApp.getCalendarById(calendarId); if (!calendar) { errorAlert('Cannot find calendar. Check instructions for set up.'); } var calEvents = calendar.getEvents(beginDate, endDate); var calEventIds = calEvents.map(function(val) {return val.getId();}); // Get spreadsheet and data var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var sheet = spreadsheet.getActiveSheet(); var range = sheet.getDataRange(); var data = range.getValues(); if (data.length < 2) { errorAlert('Spreadsheet must have a title row and at least one data row'); return; } // Map headers to indices var idxMap = createIdxMap(data[0]); var idIdx = idxMap.indexOf('id'); var idRange = range.offset(0, idIdx, data.length, 1); var idData = idRange.getValues() // Verify header has all required fields if (areRequiredFieldsMissing(idxMap)) { var reqFieldNames = requiredFields.map(function(x) {return titleRowMap[x];}).join(', '); errorAlert('Spreadsheet must have ' + reqFieldNames + ' columns'); return; } var keysToAdd = missingFields(idxMap); // Loop through spreadsheet rows var numAdded = 0; var numUpdates = 0; var eventsAdded = false; for (var ridx = 1; ridx < data.length; ridx++) { var sheetEvent = reformatEvent(data[ridx], idxMap, keysToAdd); // If enabled, skip rows with blank/invalid start and end times if (SKIP_BLANK_ROWS && !(sheetEvent.starttime instanceof Date) && !(sheetEvent.endtime instanceof Date)) { continue; } // Do some error checking first if (!sheetEvent.title) { errorAlert('must have title', sheetEvent, ridx); continue; } if (!(sheetEvent.starttime instanceof Date)) { errorAlert('start time must be a date/time', sheetEvent, ridx); continue; } if (sheetEvent.endtime !== '') { if (!(sheetEvent.endtime instanceof Date)) { errorAlert('end time must be empty or a date/time', sheetEvent, ridx); continue; } if (sheetEvent.endtime < sheetEvent.starttime) { errorAlert('end time must be after start time for event', sheetEvent, ridx); continue; } } // Ignore events outside of the begin/end range desired. if (sheetEvent.starttime > endDate) { continue; } if (sheetEvent.endtime === '') { if (sheetEvent.starttime < beginDate) { continue; } } else { if (sheetEvent.endtime < beginDate) { continue; } } // Determine if spreadsheet event is already in calendar and matches var addEvent = true; if (sheetEvent.id) { var eventIdx = calEventIds.indexOf(sheetEvent.id); if (eventIdx >= 0) { calEventIds[eventIdx] = null; // Prevents removing event below addEvent = false; var calEvent = calEvents[eventIdx]; var convertedCalEvent = convertCalEvent(calEvent); var eventDiffs = eventDifferences(convertedCalEvent, sheetEvent); if (eventDiffs > 0) { // When there are only 1 or 2 event differences, it's quicker to // update the event. For more event diffs, delete and re-add the event. The one // exception is if the event has guests (eventDiffs=99). We don't // want to force guests to re-confirm, so go through the slow update // process instead. if (eventDiffs < 3 && eventDiffs !== EVENT_DIFFS_WITH_GUESTS) { numUpdates += updateEvent(calEvent, convertedCalEvent, sheetEvent); } else { addEvent = true; calEventIds[eventIdx] = sheetEvent.id; } } } } console.info('%d updates, time: %d msecs', numUpdates, Date.now() - scriptStart); if (addEvent) { var newEvent; sheetEvent.sendInvites = SEND_EMAIL_INVITES; if (sheetEvent.endtime === '') { newEvent = calendar.createAllDayEvent(sheetEvent.title, sheetEvent.starttime, sheetEvent); } else { newEvent = calendar.createEvent(sheetEvent.title, sheetEvent.starttime, sheetEvent.endtime, sheetEvent); } // Put event ID back into spreadsheet idData[ridx][0] = newEvent.getId(); eventsAdded = true; // Set event color if (sheetEvent.color > 0 && sheetEvent.color < 12) { newEvent.setColor('' + sheetEvent.color); } // Throttle updates. numAdded++; Utilities.sleep(THROTTLE_SLEEP_TIME); if (numAdded % 10 === 0) { console.info('%d events added, time: %d msecs', numAdded, Date.now() - scriptStart); } } // If the script is getting close to timing out, save the event IDs added so far to avoid lots // of duplicate events. if ((Date.now() - scriptStart) > MAX_RUN_TIME) { idRange.setValues(idData); } } // Save spreadsheet changes if (eventsAdded) { idRange.setValues(idData); } // Remove any calendar events not found in the spreadsheet var numToRemove = calEventIds.reduce(function(prevVal, curVal) { if (curVal !== null) { prevVal++; } return prevVal; }, 0); if (numToRemove > 0) { var ui = SpreadsheetApp.getUi(); var response = ui.alert('Delete ' + numToRemove + ' calendar event(s) not found in spreadsheet?', ui.ButtonSet.YES_NO); if (response == ui.Button.YES) { var numRemoved = 0; calEventIds.forEach(function(id, idx) { if (id != null) { calEvents[idx].deleteEvent(); Utilities.sleep(THROTTLE_SLEEP_TIME); numRemoved++; if (numRemoved % 10 === 0) { console.info('%d events removed, time: %d msecs', numRemoved, Date.now() - scriptStart); } } }); } } } // Set up a trigger to automatically update the calendar when the spreadsheet is // modified. See the instructions for how to use this. function createSpreadsheetEditTrigger() { var ss = SpreadsheetApp.getActive(); ScriptApp.newTrigger('syncToCalendar') .forSpreadsheet(ss) .onEdit() .create(); } // Delete the trigger. Use this to stop automatically updating the calendar. function deleteTrigger() { // Loop over all triggers. var allTriggers = ScriptApp.getProjectTriggers(); for (var idx = 0; idx < allTriggers.length; idx++) { if (allTriggers[idx].getHandlerFunction() === 'syncToCalendar') { ScriptApp.deleteTrigger(allTriggers[idx]); } } } ================================================ FILE: tests/Code_test.ts ================================================ import {exerciseSyntax} from "../Code"; describe('Code', () => { it('has correct syntax', () => { expect(exerciseSyntax()).toBeTruthy(); }); }); ================================================ FILE: tests/FakeCalendarEvent.ts ================================================ import { EventColor } from '../GenericEvent'; class FakeGuest implements GoogleAppsScript.Calendar.EventGuest { constructor( public email: string ) { } getEmail() { return this.email; } getAdditionalGuests(): number { throw "not implemented"; }; getGuestStatus(): GoogleAppsScript.Calendar.GuestStatus { throw "not implemented"; }; getName(): string { throw "not implemented"; }; getStatus(): string { throw "not implemented"; }; } export class FakeCalendarEvent implements GoogleAppsScript.Calendar.CalendarEvent { constructor( public id: string, public title: string, public description: string, public location: string, public guests: GoogleAppsScript.Calendar.EventGuest[], public color: string, public allday: boolean, public starttime: Date, public endtime: Date ) { } static fromArray(params: any[]) { const [id, title, description, location, guests, color, allday, starttime, endtime] = params; let guestList = guests ? guests.split(',').map((x: string) => new FakeGuest(x.trim())) : []; let convertedColor = color ? (EventColor[color] + 1).toString() : ''; if (allday) { endtime.setDate(endtime.getDate() + 1); } return new FakeCalendarEvent(id, title, description, location, guestList, convertedColor, allday, starttime, endtime); } getId() { return this.id; } getTitle() { return this.title; } getDescription() { return this.description; } getLocation() { return this.location; } getGuestList() { return this.guests; } getColor() { return this.color; } isAllDayEvent() { return this.allday; } getAllDayStartDate() { if (!this.allday) { throw "invalid call"; } return this.starttime; } getAllDayEndDate() { if (!this.allday) { throw "invalid call"; } return this.endtime; } getStartTime() { if (this.allday) { throw "invalid call"; } return this.starttime; } getEndTime() { if (this.allday) { throw "invalid call"; } return this.endtime; } addEmailReminder(minutesBefore: number): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; addGuest(email: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; addPopupReminder(minutesBefore: number): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; addSmsReminder(minutesBefore: number): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; anyoneCanAddSelf(): boolean { throw "not implemented"; }; deleteEvent() { throw "not implemented"; }; deleteTag(key: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; getAllTagKeys(): string[] { throw "not implemented"; }; getCreators(): string[] { throw "not implemented"; }; getDateCreated(): Date { throw "not implemented"; }; getEmailReminders(): number[] { throw "not implemented"; }; getEventSeries(): GoogleAppsScript.Calendar.CalendarEventSeries { throw "not implemented"; }; getGuestByEmail(email: string): GoogleAppsScript.Calendar.EventGuest { throw "not implemented"; }; getLastUpdated(): Date { throw "not implemented"; }; getMyStatus(): GoogleAppsScript.Calendar.GuestStatus { throw "not implemented"; }; getOriginalCalendarId(): string { throw "not implemented"; }; getPopupReminders(): number[] { throw "not implemented"; }; getSmsReminders(): number[] { throw "not implemented"; }; getTag(key: string): string { throw "not implemented"; }; getVisibility(): GoogleAppsScript.Calendar.Visibility { throw "not implemented"; }; guestsCanInviteOthers(): boolean { throw "not implemented"; }; guestsCanModify(): boolean { throw "not implemented"; }; guestsCanSeeGuests(): boolean { throw "not implemented"; }; isOwnedByMe(): boolean { throw "not implemented"; }; isRecurringEvent(): boolean { throw "not implemented"; }; removeAllReminders(): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; removeGuest(email: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; resetRemindersToDefault(): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setAllDayDate(date: Date): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setAllDayDates(startDate: Date, endDate: Date): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setAnyoneCanAddSelf(anyoneCanAddSelf: boolean): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setColor(color: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setDescription(description: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setGuestsCanInviteOthers(guestsCanInviteOthers: boolean): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setGuestsCanModify(guestsCanModify: boolean): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setGuestsCanSeeGuests(guestsCanSeeGuests: boolean): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setLocation(location: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setMyStatus(status: GoogleAppsScript.Calendar.GuestStatus): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setTag(key: string, value: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setTime(startTime: Date, endTime: Date): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setTitle(title: string): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; setVisibility(visibility: GoogleAppsScript.Calendar.Visibility): GoogleAppsScript.Calendar.CalendarEvent { throw "not implemented"; }; } ================================================ FILE: tests/GenericEvent_test.ts ================================================ import { GenericEvent } from '../GenericEvent'; import { Util } from '../Util'; import { AllDayValue } from '../Settings'; import { FakeCalendarEvent } from './FakeCalendarEvent'; const DATE1 = new Date('1995-12-17T03:24:00'); const DATE2 = new Date('1995-12-18T04:56:00'); const DATE3 = new Date('1995-12-19T07:08:00'); const DATE4 = new Date('1995-12-20T00:00:00-08:00'); const DATE5 = new Date('1995-12-22T00:00:00-08:00'); const EVENT1_VALUES = ['testid1', 'Test Title 1', 'Test Description 1', 'Test Location 1', 'guest1@example.com,guest2@example.com', 'ORANGE', false, DATE1, DATE2]; const EVENT2_VALUES = ['testid2', 'Test Title 2', 'Test Description 2', 'Test Location 2', 'guest3@example.com,guest4@example.com', 'GRAY', false, DATE2, DATE3]; const EVENT_NOGUESTS_VALUES = ['testid3', 'Test Title 3', 'Test Description 3', 'Test Location 3', '', 'MAUVE', false, DATE2, DATE3]; const EVENT_ALLDAY_VALUES = ['testid4', 'Test Title 4', 'Test Description 4', 'Test Location 4', '', '', true, DATE4, DATE5]; const EVENT_BADDATES_VALUES = ['testid5', 'Test Title 5', 'Test Description 5', 'Test Location 5', '', '', false, 'abc', 0] const EVENT_BADCOLOR_VALUES = ['testid6', 'Test Title 6', 'Test Description 6', 'Test Location 6', '', 'foobar', false, DATE2, DATE3] const IDX_MAP = Util.createIdxMap(['Id', 'Title', 'Description', 'Location', 'Guests', 'Color', 'All Day', 'Start Time', 'End Time']); const IDX_MAP_NO_GUESTS = Util.createIdxMap(['Id', 'Title', 'Description', 'Location', 'Color', 'All Day', 'Start Time', 'End Time']); const PACIFIC_TZ = 'America/Los_Angeles'; describe('GenericEvent', () => { let event1: GenericEvent, event2: GenericEvent, event_noguests: GenericEvent, event_allday: GenericEvent, event_allday_plusoneday: GenericEvent; beforeAll(() => { event1 = GenericEvent.fromArray(EVENT1_VALUES); event2 = GenericEvent.fromArray(EVENT2_VALUES); event_noguests = GenericEvent.fromArray(EVENT_NOGUESTS_VALUES); event_allday = GenericEvent.fromArray(EVENT_ALLDAY_VALUES); const plus_one_day = Object.assign([], EVENT_ALLDAY_VALUES) // Convert start/end time of expected values to whole date in correct time zone and add one day to end time. plus_one_day[7] = GenericEvent.convertTimeZone(plus_one_day[7], PACIFIC_TZ) plus_one_day[8] = GenericEvent.convertTimeZone(plus_one_day[8], PACIFIC_TZ, 1) event_allday_plusoneday = GenericEvent.fromArray(plus_one_day); }); it('instantiates correctly', () => { expect(event1.id).toBe('testid1'); expect(event1.color).toBe('6'); }); describe('fromCalendarEvent', () => { it('initantiates correctly with all fields', () => { const fakeCalEvent = FakeCalendarEvent.fromArray(EVENT1_VALUES); expect(fakeCalEvent.getColor()).toEqual('6') const event1_fromcal = GenericEvent.fromCalendarEvent(fakeCalEvent); expect(event1_fromcal).toEqual(event1); }); it('initantiates correctly for all day events', () => { const fakeCalEvent = FakeCalendarEvent.fromArray(EVENT_ALLDAY_VALUES); const event_allday_fromcal = GenericEvent.fromCalendarEvent(fakeCalEvent); expect(event_allday_fromcal).toEqual(event_allday); }); }); describe('fromSpreadsheetRow', () => { it('instantiates correctly with all fields', () => { const event1_fromsheet = GenericEvent.fromSpreadsheetRow(EVENT1_VALUES, IDX_MAP, [], AllDayValue.use_column); expect(event1_fromsheet).toEqual(event1); }); it('instantiates all day event correctly', () => { const event_allday_fromsheet = GenericEvent.fromSpreadsheetRow(EVENT_ALLDAY_VALUES, IDX_MAP, [], AllDayValue.use_column); expect(event_allday_fromsheet).toEqual(event_allday_plusoneday); }); it('instantiates correctly with a blank field added later', () => { const event_noguests_fromsheet = GenericEvent.fromSpreadsheetRow( EVENT_NOGUESTS_VALUES.filter(val => val !== ''), IDX_MAP_NO_GUESTS, ['guests'], AllDayValue.use_column); expect(event_noguests_fromsheet).toEqual(event_noguests); }); it('turns bad dates into null', () => { const event_baddates = GenericEvent.fromSpreadsheetRow(EVENT_BADDATES_VALUES, IDX_MAP, [], AllDayValue.use_column); expect(event_baddates.starttime).toBeNull(); expect(event_baddates.endtime).toBeNull(); }); it('handles always all-day event', () => { const event1_fromsheet = GenericEvent.fromSpreadsheetRow(EVENT1_VALUES, IDX_MAP, [], AllDayValue.always_all_day); expect(event1_fromsheet.allday).toBeTruthy(); }); it('handles never all-day event', () => { const event_allday_fromsheet = GenericEvent.fromSpreadsheetRow(EVENT_ALLDAY_VALUES, IDX_MAP, [], AllDayValue.never_all_day); expect(event_allday_fromsheet.allday).toBeFalsy(); }); it('skips bad color', () => { const event_allday_fromsheet = GenericEvent.fromSpreadsheetRow(EVENT_BADCOLOR_VALUES, IDX_MAP, [], AllDayValue.use_column); expect(event_allday_fromsheet.color).toEqual(''); }); }); describe('toSpreadsheetRow', () => { it('translates to spreadsheet row', () => { const row = new Array(8); event1.toSpreadsheetRow(IDX_MAP, row) expect(row).toEqual(EVENT1_VALUES) }); }); describe('eventDifferences', () => { it('calculates correct number of diffs', () => { expect(event_noguests.eventDifferences(event1)).toBe(7); }); it('calculates correct number when no diffs', () => { expect(event1.eventDifferences(event1)).toBe(0); }); it('sets diffs to 1 when there are guests', () => { expect(event1.eventDifferences(event2)).toBe(1); }); }); describe('convertTimeZone', () => { it('handles non-date', () => { expect(GenericEvent.convertTimeZone('foo' as any, PACIFIC_TZ)).toBe(null); }) it('returns date', () => { expect(GenericEvent.convertTimeZone(new Date('2020-12-10T09:08:07.000-08:00'), PACIFIC_TZ)).toEqual(new Date(2020, 11, 10)); }) it('returns date - 1', () => { // Remember month is zero-index expect(GenericEvent.convertTimeZone(new Date('2020-12-10T09:08:07.000-08:00'), PACIFIC_TZ, -10)).toEqual(new Date(2020, 10, 30)); }) }); }); ================================================ FILE: tests/Settings_test.ts ================================================ import { Settings, AllDayValue } from '../Settings'; describe('Settings', () => { let fakePropertiesService: any; let fakeDocumentProperties: any; beforeEach(() => { fakeDocumentProperties = { getProperty: () => '', setProperty: (key: string, value: string): void => { }, }; fakePropertiesService = { getDocumentProperties: () => fakeDocumentProperties as any, }; }); it('creates defaults', () => { const settings = Settings.getDefaultSettings(); expect(settings.begin_date).toEqual(new Date(1970, 0, 1)); }); it('loads from PropertiesService', () => { const jsonSettings = '{"begin_date":"1980-1-1","end_date":"2400-1-1","send_email_invites":true,"skip_blank_rows":true,"all_day_events":"USE_COLUMN"}'; spyOn(fakeDocumentProperties, 'getProperty').and.returnValue(jsonSettings); const settings = Settings.loadFromPropertyService(fakePropertiesService as any); expect(settings).toEqual(new Settings( new Date(1980, 0, 1), new Date(2400, 0, 1), true, true, AllDayValue.use_column )); }); it('loads defaults from PropertyService', () => { spyOn(fakeDocumentProperties, 'getProperty').and.returnValue(null); const setPropertySpy = spyOn(fakeDocumentProperties, 'setProperty'); const settings = Settings.loadFromPropertyService(fakePropertiesService as any); expect(settings).toEqual(Settings.getDefaultSettings()); expect(setPropertySpy).toHaveBeenCalled(); }); it('saves to PropertiesService', () => { const setPropertySpy = spyOn(fakeDocumentProperties, 'setProperty'); const formValues = { begin_date: '1980-1-1', end_date: '2400-1-1', send_email_invites: true, skip_blank_rows: true, all_day_events: 'USE_COLUMN', } Settings.saveToPropertyService(formValues, fakePropertiesService); const expectedJson = '{"begin_date":"1980-1-1","end_date":"2400-1-1","send_email_invites":true,"skip_blank_rows":true,"all_day_events":"USE_COLUMN"}'; expect(setPropertySpy).toHaveBeenCalledWith('v1', expectedJson); }); it('converts to base types', () => { const settings = Settings.getDefaultSettings(); expect(settings.convertToBaseTypes()).toEqual({ begin_date: '1970-1-1', end_date: '2500-1-1', send_email_invites: false, skip_blank_rows: false, all_day_events: 'USE_COLUMN', }) }); it('converts for dialog', () => { const savedSettings = Settings.getDefaultSettings(); spyOn(Settings, 'loadFromPropertyService').and.returnValue(savedSettings); expect(Settings.convertForDialog()).toEqual({ begin_date: '1970-1-1', end_date: '2500-1-1', send_email_invites: false, skip_blank_rows: false, all_day_events: 'use_column', }); }); it('converts date to string', () => { const str = Settings.convertDateToString(new Date(1970, 0, 1)); expect(str).toEqual('1970-1-1'); }); }); ================================================ FILE: tests/Util_test.ts ================================================ import { Util } from "../Util"; import { GenericEventKey } from '../GenericEvent'; const DATE1 = new Date('1995-12-17T03:24:00'); const FAKE_IDX_MAP: GenericEventKey[] = ['id', null, 'title', 'description', 'color']; describe('createIdxMap', () => { it('creates map', () => { const result = Util.createIdxMap(['Title', 'Color', 'unknown', 'Start Time']); expect(result).toEqual(['title', 'color', null, 'starttime']); }); }); describe('missingFields', () => { it('returns missing fields', () => { const result = Util.missingFields(FAKE_IDX_MAP); expect(result).toEqual(['location', 'starttime', 'endtime', 'guests', 'allday']); }); }); describe('missingRequiredFields', () => { it('returns missing required fields', () => { const result1 = Util.missingRequiredFields(FAKE_IDX_MAP, true); expect(result1).toEqual(['starttime', 'endtime', 'allday']); const result2 = Util.missingRequiredFields(FAKE_IDX_MAP, false); expect(result2).toEqual(['starttime', 'endtime']); }); }); describe('isValidDate', () => { it('identifies valid dates', () => { expect(Util.isValidDate('5/1/2020')).toBeTruthy(); expect(Util.isValidDate('2020-5-1')).toBeTruthy(); expect(Util.isValidDate('30/1/2020')).toBeFalsy(); expect(Util.isValidDate('2020-5-89')).toBeFalsy(); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "typeRoots" : ["./node_modules/@types"], "noImplicitAny" : true, "lib": ["es5", "es6"] } }