├── .gitignore ├── odo_user_guide_github.pdf ├── README.gs ├── README.md ├── slides.gs ├── CONTRIBUTING.md ├── fileAccess.gs ├── drive.gs ├── sheets.gs ├── docs.gs ├── appsscript.json ├── definitions.gs ├── main.gs ├── gmail.gs ├── mergeKeys.gs ├── integrationTypeService.gs ├── config.gs ├── integrationTypeFileRepo.gs ├── LICENSE ├── integrationTypeImageLibrary.gs ├── integrationTypeAll.gs └── integrationTypeRecords.gs /.gitignore: -------------------------------------------------------------------------------- 1 | .clasp.json 2 | .clasp.json.dev 3 | .clasp.json.prod 4 | -------------------------------------------------------------------------------- /odo_user_guide_github.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googleworkspace/gws-odo-addon/HEAD/odo_user_guide_github.pdf -------------------------------------------------------------------------------- /README.gs: -------------------------------------------------------------------------------- 1 | ///////////////////////////////// 80 cols ////////////////////////////////////// 2 | 3 | /** 4 | * Odo is a configurable Workspace Add-on that will allow its' user 5 | * to easily showcase/demo to others what the Google Workspace extensibility platform 6 | * is capable of doing for, and with, an organization's in-house tools and processes. 7 | * 8 | * This is not an officially supported Google product. 9 | */ 10 | 11 | // Change Log: 12 | // 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ///////////////////////////////// 80 cols ////////////////////////////////////// 2 | 3 | /** 4 | * Odo is a configurable Workspace Add-on that will allow its' user 5 | * to easily showcase/demo to others what the Google Workspace extensibility platform 6 | * is capable of doing for, and with, an organization's in-house tools and processes. 7 | * 8 | * Be sure to read the user guide included in this repo: odo_user_guide_github.pdf 9 | * 10 | * This is not an officially supported Google product. 11 | */ 12 | 13 | // Change Log: 14 | // 15 | -------------------------------------------------------------------------------- /slides.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Functions specific to how the Add-on looks and acts when 3 | * in the context of the Slides editor. 4 | */ 5 | 6 | /** 7 | * Function that's called (per manifest) for Slides homepage trigger 8 | * 9 | * return {CardService.Card} Card to show for Slides Homepage 10 | */ 11 | function onSlidesHomepage(e) { 12 | console.log("e: " + JSON.stringify(e)); 13 | 14 | if (e.slides.addonHasFileScopePermission) { 15 | return buildIntegrationCard(CALL_CONTEXT.SLIDES); 16 | 17 | } 18 | 19 | return buildFilePermissionCard(); 20 | } 21 | 22 | /** 23 | * Function that's called (per manifest) when file access is granted 24 | * with drive.file scope. 25 | * 26 | * return {CardService.Card} Card to show on success 27 | */ 28 | function onSlidesFileScopeGranted(e) { 29 | return onSlidesHomepage(); 30 | } 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. 4 | 5 | ## Before you begin 6 | 7 | ### Sign our Contributor License Agreement 8 | 9 | Contributions to this project must be accompanied by a 10 | [Contributor License Agreement](https://cla.developers.google.com/about) (CLA). 11 | You (or your employer) retain the copyright to your contribution; this simply 12 | gives us permission to use and redistribute your contributions as part of the 13 | project. 14 | 15 | If you or your current employer have already signed the Google CLA (even if it 16 | was for a different project), you probably don't need to do it again. 17 | 18 | Visit to see your current agreements or to 19 | sign a new one. 20 | 21 | ### Review our community guidelines 22 | 23 | This project follows 24 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 25 | 26 | ## Contribution process 27 | 28 | ### Code reviews 29 | 30 | All submissions, including submissions by project members, require review. We 31 | use GitHub pull requests for this purpose. Consult 32 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 33 | information on using pull requests. 34 | -------------------------------------------------------------------------------- /fileAccess.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * Code related to obtaining permission to access Editor file with 3 | * drive.file scope. 4 | */ 5 | 6 | /** 7 | * Constructs card to request user grant Add-on to editor file that is open. 8 | * 9 | * @return {CardService.Card} 10 | */ 11 | function buildFilePermissionCard() { 12 | // If the add-on does not have access permission, add a button that 13 | // allows the user to provide that permission on a per-file basis. 14 | var card = CardService.newCardBuilder(); 15 | let cardSection = CardService.newCardSection(); 16 | 17 | cardSection.addWidget( 18 | CardService.newTextParagraph().setText( 19 | "The Add-on needs permission to access this file's contents." 20 | ) 21 | ); 22 | let buttonAction = CardService.newAction().setFunctionName( 23 | 'onRequestFileScopeButtonClicked' 24 | ); 25 | let button = CardService.newTextButton() 26 | .setText('Grant permission') 27 | .setOnClickAction(buttonAction); 28 | 29 | cardSection.addWidget(button); 30 | return card.addSection(cardSection).build(); 31 | } 32 | 33 | /** 34 | * Callback function for a button action. Instructs Docs to display a 35 | * permissions dialog to the user, requesting `drive.file` scope for the 36 | * current file on behalf of this add-on. 37 | * 38 | * @param {Object} e The parameters object that contains the document’s ID 39 | * @return {editorFileScopeActionResponse} 40 | */ 41 | function onRequestFileScopeButtonClicked(e) { 42 | return CardService.newEditorFileScopeActionResponseBuilder() 43 | .requestFileScopeForActiveDocument() 44 | .build(); 45 | } 46 | -------------------------------------------------------------------------------- /drive.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Functions specific to how the Add-on looks and acts when 3 | * in the context of a user's Drive. 4 | */ 5 | 6 | /** 7 | * Function that's called (per manifest) for Drive homepage trigger 8 | * 9 | * @param {Object} e The event object. 10 | * 11 | * @return {CardService.Card} Card to show for Drive Homepage 12 | */ 13 | function onDriveHomepage(e) { 14 | return buildIntegrationCard(CALL_CONTEXT.DRIVE); 15 | } 16 | 17 | 18 | /** 19 | * Callback for rendering the card for specific Drive items. 20 | * @param {Object} e - The event object. 21 | * 22 | * @return {CardService.Card} The card to show to the user. 23 | */ 24 | function onDriveItemsSelected(e) { 25 | 26 | // Grab first file selected 27 | var selectedFile = e.drive.selectedItems[0]; 28 | 29 | // Set up merge tags, based on selected file. 30 | addMergeKeyValuePair('{{fileMimeType}}', selectedFile.mimeType); 31 | addMergeKeyValuePair('{{fileName}}', selectedFile.title); 32 | addMergeKeyValuePair('{{fileId}}', selectedFile.id); 33 | 34 | return buildIntegrationCard(CALL_CONTEXT.DRIVE); 35 | 36 | } 37 | 38 | /** 39 | * Returns special folder for storing Odo specific data in the user's 40 | * Drive. Creates the folder first if not already present. 41 | * 42 | * @return {Object} A Folder object for use with DriveApp 43 | */ 44 | function createOrGetOdoDataFolder() { 45 | let dataFolder; 46 | let folders = DriveApp.getFoldersByName(ODO_DATA_FOLDER_NAME); 47 | 48 | if (!folders.hasNext()) { 49 | dataFolder = DriveApp.createFolder(ODO_DATA_FOLDER_NAME); 50 | } else { 51 | dataFolder = folders.next(); 52 | } 53 | 54 | return dataFolder; 55 | } 56 | -------------------------------------------------------------------------------- /sheets.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Functions specific to how the Add-on looks and acts when 3 | * in the context of the Sheets editor. 4 | */ 5 | 6 | /** 7 | * Function that's called (per manifest) for Docs homepage trigger 8 | * 9 | * return {CardService.Card} Card to show for Docs Homepage 10 | */ 11 | function onSheetsHomepage(e) { 12 | if (e.sheets.addonHasFileScopePermission) { 13 | return buildSheetsKeyOnSelectedCellCard(); 14 | } 15 | 16 | return buildFilePermissionCard(); 17 | } 18 | 19 | /** 20 | * Function that's called (per manifest) when file access is granted 21 | * with drive.file scope. 22 | * 23 | * return {CardService.Card} Card to show on success 24 | */ 25 | function onSheetsFileScopeGranted(e) { 26 | return onSheetsHomepage(); 27 | } 28 | 29 | /** 30 | * Build card that shows related info for user selected call in sheet. 31 | * 32 | * @return {CardService.Card} 33 | */ 34 | function buildSheetsKeyOnSelectedCellCard() { 35 | let selectedText = getSelectedCellText(); 36 | 37 | addMergeKeyValuePair('{{selectedText}}', selectedText); 38 | addMergeKeyValuePair('{{token}}', selectedText); 39 | 40 | return buildIntegrationCard(CALL_CONTEXT.SHEETS); 41 | } 42 | 43 | /** 44 | * Returns text of currently selected cell 45 | * 46 | * @return {string} First word of text in selected cell, or empty string 47 | * if no selection. 48 | */ 49 | function getSelectedCellText() { 50 | let sheet = SpreadsheetApp.getActiveSheet(); 51 | let cell = sheet.getActiveCell(); 52 | 53 | let selectedText = ''; 54 | 55 | if (!cell) { 56 | return ''; 57 | } 58 | 59 | selectedText = cell.getValue().toString(); 60 | 61 | // Google Doc UI "word selection" (double click) selects trailing 62 | // spaces - trim them 63 | selectedText = selectedText.trim(); 64 | 65 | // limit to just first word. multi-word selections won't make a lot of 66 | // sense with respect to simulated integrations like customer records. 67 | selectedText = selectedText.split(' ')[0]; 68 | 69 | return selectedText; 70 | } 71 | 72 | /** Returns true if active cell is A1, indicating that the sheet has been 73 | * loaded but the user hasn't actually done much (i.e. hasn't explicitly 74 | * selected any cell to look up a record for) 75 | * 76 | * @return {boolean} 77 | */ 78 | function isFreshSheetLoad() { 79 | let sheet = SpreadsheetApp.getActiveSheet(); 80 | let cell = sheet.getActiveCell(); 81 | 82 | if (cell.getA1Notation() === 'A1') { 83 | return true; 84 | } 85 | 86 | return false; 87 | } 88 | -------------------------------------------------------------------------------- /docs.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Functions specific to how the Add-on looks and acts when 3 | * in the context of the Docs editor. 4 | */ 5 | 6 | /** 7 | * Function that's called (per manifest) for Docs homepage trigger 8 | * 9 | * return {CardService.Card} Card to show for Docs Homepage 10 | */ 11 | function onDocsHomepage(e) { 12 | if (e.docs.addonHasFileScopePermission) { 13 | return buildDocsKeyOnSelectedTextCard(); 14 | } 15 | 16 | return buildFilePermissionCard(); 17 | } 18 | 19 | /** 20 | * Function that's called (per manifest) when file access is granted 21 | * with drive.file scope. 22 | * 23 | * return {CardService.Card} Card to show on success 24 | */ 25 | function onDocsFileScopeGranted(e) { 26 | return buildDocsKeyOnSelectedTextCard(); 27 | } 28 | 29 | /** 30 | * Build card that shows related info for user selected text in document. 31 | * 32 | * @return {CardService.Card} 33 | */ 34 | function buildDocsKeyOnSelectedTextCard() { 35 | let selectedText = getSelectedText(); 36 | 37 | if (selectedText) { 38 | addMergeKeyValuePair('{{selectedText}}', selectedText); 39 | addMergeKeyValuePair('{{token}}', selectedText); 40 | } 41 | 42 | return buildIntegrationCard(CALL_CONTEXT.DOCS); 43 | } 44 | 45 | /** 46 | * Return currently selected text (within a paragraph). 47 | * https://stackoverflow.com/questions/16639331/get-user-selected-text 48 | * 49 | * @return {string} First word of selected text or empty string if no selection. 50 | */ 51 | function getSelectedText() { 52 | let doc = DocumentApp.getActiveDocument(); 53 | let selection = doc.getSelection(); 54 | 55 | let selectedText = ''; 56 | 57 | if (!selection) { 58 | return ''; 59 | } 60 | 61 | let elements = selection.getSelectedElements(); 62 | let element = elements[0].getElement(); // first element (before '\n') 63 | let startOffset = elements[0].getStartOffset(); // -1 if whole element 64 | let endOffset = elements[0].getEndOffsetInclusive(); // -1 if whole element 65 | 66 | selectedText = element.asText().getText(); // All text from element 67 | 68 | // is only part of the element selected? 69 | if (elements[0].isPartial()) { 70 | selectedText = selectedText.substring(startOffset, endOffset + 1); 71 | } 72 | 73 | // Google Doc UI "word selection" (double click) selects trailing 74 | // spaces - trim them 75 | selectedText = selectedText.trim(); 76 | 77 | // limit to just first word. multi-word selections won't make a lot of 78 | // sense with respect to simulated integrations like customer records. 79 | selectedText = selectedText.split(' ')[0]; 80 | 81 | return selectedText; 82 | } 83 | -------------------------------------------------------------------------------- /appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/Los_Angeles", 3 | "exceptionLogging": "STACKDRIVER", 4 | "dependencies": { 5 | "enabledAdvancedServices": [ 6 | { 7 | "userSymbol": "Drive", 8 | "version": "v2", 9 | "serviceId": "drive" 10 | } 11 | ] 12 | }, 13 | "runtimeVersion": "V8", 14 | "oauthScopes": [ 15 | "https://www.googleapis.com/auth/gmail.addons.execute", 16 | "https://www.googleapis.com/auth/gmail.addons.current.message.readonly", 17 | "https://www.googleapis.com/auth/gmail.addons.current.message.metadata", 18 | "https://www.googleapis.com/auth/gmail.addons.current.message.action", 19 | "https://www.googleapis.com/auth/gmail.addons.current.action.compose", 20 | "https://www.googleapis.com/auth/spreadsheets.currentonly", 21 | "https://www.googleapis.com/auth/documents.currentonly", 22 | "https://www.googleapis.com/auth/presentations.currentonly", 23 | "https://www.googleapis.com/auth/drive.file", 24 | "https://www.googleapis.com/auth/drive.addons.metadata.readonly", 25 | "https://www.googleapis.com/auth/drive.appdata", 26 | "https://www.googleapis.com/auth/drive", 27 | "https://www.googleapis.com/auth/script.storage" 28 | ], 29 | "addOns": { 30 | "common": { 31 | "name": " ", 32 | "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/stars_black_24dp.png", 33 | "universalActions": [ 34 | { 35 | "label": "Configure Odo", 36 | "runFunction": "onOdoConfig" 37 | } 38 | ] 39 | }, 40 | "gmail": { 41 | "homepageTrigger": { 42 | "runFunction": "onGmailHomepage" 43 | }, 44 | "contextualTriggers": [ 45 | { 46 | "unconditional": {}, 47 | "onTriggerFunction": "onGmailMessageOpened" 48 | } 49 | ], 50 | "composeTrigger": { 51 | "draftAccess": "METADATA", 52 | "selectActions": [ 53 | { 54 | "runFunction": "onGmailCompose", 55 | "text": "Insert" 56 | } 57 | ] 58 | } 59 | }, 60 | "drive": { 61 | "homepageTrigger": { 62 | "runFunction": "onDriveHomepage" 63 | }, 64 | "onItemsSelectedTrigger": { 65 | "runFunction": "onDriveItemsSelected" 66 | } 67 | }, 68 | "docs": { 69 | "homepageTrigger": { 70 | "runFunction": "onDocsHomepage" 71 | }, 72 | "onFileScopeGrantedTrigger": { 73 | "runFunction": "onDocsFileScopeGranted" 74 | } 75 | }, 76 | "sheets": { 77 | "homepageTrigger": { 78 | "runFunction": "onSheetsHomepage" 79 | }, 80 | "onFileScopeGrantedTrigger": { 81 | "runFunction": "onSheetsFileScopeGranted" 82 | } 83 | }, 84 | "slides": { 85 | "homepageTrigger": { 86 | "runFunction": "onSlidesHomepage" 87 | }, 88 | "onFileScopeGrantedTrigger": { 89 | "runFunction": "onSlidesFileScopeGranted" 90 | } 91 | } 92 | 93 | } 94 | } -------------------------------------------------------------------------------- /definitions.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Constants and definitions used throughout the Odo Add-on. 3 | */ 4 | 5 | /** 6 | * Contexts from which integration specific handlers can be called. 7 | * @enum {string} 8 | */ 9 | const CALL_CONTEXT = { 10 | GMAIL_VIEW: 'GMAIL_VIEW', // viewing a message in Gmail 11 | GMAIL_HOMEPAGE: 'GMAIL_HOMEPAGE', // homepage in Gmail 12 | GMAIL_COMPOSE: 'GMAIL_COMPOSE', // composing a message in Gmail 13 | SHEETS: 'SHEETS', // called from a Google Sheet 14 | DOCS: 'DOCS', // called from a Google Doc 15 | SLIDES: 'SLIDES', // called from Google Slides 16 | DRIVE: 'DRIVE', // call from Google Drive 17 | DEFAULT: 'DEFAULT', // used to handle any context if none has been defined 18 | }; 19 | 20 | // Image/icon shown in headers and Record fields 21 | // Some also used in sidebar (ergo also defined in the manifest file) 22 | const ODO_ICON = 23 | 'https://www.gstatic.com/images/icons/material/' + 24 | 'system/1x/stars_black_24dp.png'; 25 | const RECORD_ICON = 26 | 'https://www.gstatic.com/images/icons/material/' + 27 | 'system/1x/article_black_24dp.png'; 28 | const TEXT_ICON = 29 | 'https://www.gstatic.com/images/icons/material/' + 30 | 'system/1x/notes_black_24dp.png'; 31 | const EMAIL_ICON = 32 | 'https://www.gstatic.com/images/icons/material/' + 33 | 'system/1x/email_black_24dp.png'; 34 | const PERSON_ICON = 35 | 'https://www.gstatic.com/images/icons/material/' + 36 | 'system/1x/person_black_24dp.png'; 37 | const ORG_ICON = 38 | 'https://www.gstatic.com/images/icons/material/' + 39 | 'system/1x/corporate_fare_black_24dp.png'; 40 | const DATE_ICON = 41 | 'https://www.gstatic.com/images/icons/material/' + 42 | 'system/1x/event_black_24dp.png'; 43 | const FILE_ICON = 44 | 'https://www.gstatic.com/images/icons/material/' + 45 | 'system/1x/file_present_black_24dp.png'; 46 | const FOLDER_ICON = 47 | 'https://www.gstatic.com/images/icons/material/' + 48 | 'system/1x/folder_open_black_24dp.png'; 49 | const CHECKOUT_FILE_ICON = 50 | 'https://www.gstatic.com/images/icons/material/' + 51 | 'system/1x/check_black_24dp.png'; 52 | const OPEN_EMAIL_ICON = 53 | 'https://storage.googleapis.com/odo-workspace-add-on/' + 54 | 'open_email_message2.png'; 55 | const SELECT_FILE_ICON = 56 | 'https://storage.googleapis.com/odo-workspace-add-on/' + 57 | 'select_file2.png'; // https://freeicons.io/test/file-icon-3237# 58 | 59 | const DEFAULT_CUSTOMER_NAME = 'Cymbal'; 60 | const DEFAULT_CUSTOMER_LOGO_URL = 61 | 'https://storage.googleapis.com/' + 62 | 'odo-workspace-add-on/cymbal_logo_new2.png'; 63 | const DEFAULT_CUSTOMER_TOOL_NAME = 'Clang'; 64 | 65 | const ODO_LOGO_URL = 66 | 'https://storage.googleapis.com/' + 67 | 'odo-workspace-add-on/odo-logo-animated6.gif'; 68 | 69 | const USER_PROPERTY_CONFIG = 'USER_PROP_CONFIG'; 70 | const ODO_DATA_FOLDER_NAME = 'ODO_DATA'; 71 | 72 | const THIRD_PARTY_SERVICE_URL = 73 | 'https://script.google.com/macros/s/' + 74 | 'AKfycbwao0FuYr5m7NTRMIBZcVdEiPZSN7L4cFrILcvQgr' + 75 | '5-GRPOe9JKkNcCh5bP9TZaZAAl/exec'; 76 | 77 | const DEFAULT_RECORD_FILE = 78 | 'https://drive.google.com/file/d/' + '1EJSDuBYSMXzNEiCHXIekOEg8b4JqcX8f/view'; 79 | 80 | // Properties 81 | const PROP_STRINGIFIED_INTEGRATION_CUSTOMIZATIONS = 82 | 'PROP_STRINGIFIED_INTEGRATION_CUSTOMIZATIONS'; 83 | -------------------------------------------------------------------------------- /main.gs: -------------------------------------------------------------------------------- 1 | ////////////////////////////////// 80 cols ///////////////////////////////////// 2 | 3 | /** 4 | * @fileoverview Code related to common or universal Cards, actions, and 5 | * processing. 6 | */ 7 | 8 | 9 | /** 10 | * Card builder that is called when user has yet to configure Odo. 11 | * 12 | * @return {CardService.Card} Welcome Card for new Odo user 13 | */ 14 | function buildOdoWelcomeCard() { 15 | 16 | let card = CardService.newCardBuilder(); 17 | card.setHeader(CardService.newCardHeader() 18 | .setTitle('Welcome to Odo') 19 | //.setSubtitle('') 20 | .setImageStyle(CardService.ImageStyle.SQUARE) 21 | .setImageUrl(ODO_ICON)); 22 | 23 | let section = CardService.newCardSection(); 24 | section.addWidget(CardService.newTextParagraph() 25 | .setText('Odo is a configurable Workspace Add-on that lets you ' 26 | + 'demo Add-on capabilities, and help your customers ' 27 | + 'envision their own Add-on solution in Workspace!')); 28 | 29 | let logoImage = CardService.newImage() 30 | .setImageUrl(ODO_LOGO_URL); 31 | 32 | section.addWidget(logoImage); 33 | 34 | section.addWidget(CardService.newTextParagraph() 35 | .setText('Click the button below to get started!')); 36 | 37 | let action = CardService.newAction() 38 | .setFunctionName('onOdoConfig'); 39 | 40 | section.addWidget(CardService.newTextButton() 41 | .setText("Configure Odo") 42 | .setOnClickAction(action)); 43 | 44 | card.addSection(section); 45 | 46 | return card.build(); 47 | } 48 | 49 | /** 50 | * Builds and returns a Card header that is branded with the customer's name, 51 | * logo thumbnail, and tool/integration name. 52 | * 53 | * @return {CardService.CardHeader} Card header with customer branding based on 54 | * Odo configuration. 55 | */ 56 | function buildCustomerBrandedHeader() { 57 | 58 | let config = getConfig(); 59 | 60 | let cardHeader = CardService.newCardHeader() 61 | .setTitle(config.toolName) 62 | .setSubtitle("by " + config.customerName) 63 | .setImageStyle(CardService.ImageStyle.SQUARE) 64 | .setImageUrl(config.customerLogoUrl); 65 | 66 | return cardHeader; 67 | } 68 | 69 | /** 70 | * Lets the user know that a page refresh is needed for saved changes 71 | * to take effect. 72 | * 73 | * @return {CardService.Card} Card to show that a refresh is now needed . 74 | */ 75 | function buildRefreshNeededCard() { 76 | let card = CardService.newCardBuilder(); 77 | card.setHeader(CardService.newCardHeader() 78 | .setTitle('Refresh Needed') 79 | .setImageStyle(CardService.ImageStyle.SQUARE) 80 | .setImageUrl(ODO_ICON)); 81 | 82 | let descriptionSection = CardService.newCardSection(); 83 | descriptionSection.addWidget(CardService.newTextParagraph() 84 | .setText('Your changes have been saved.

To ensure they take full ' 85 | + 'effect, please refresh this page in your browser, as well ' 86 | + 'as any other pages where the Odo Add-on is in use.')); 87 | 88 | card.addSection(descriptionSection); 89 | 90 | return card.build(); 91 | } 92 | 93 | 94 | /** 95 | * Simple internal function to display a card with a message. For 96 | * debugging and development only. 97 | */ 98 | function showMessageCard(message) { 99 | let card = CardService.newCardBuilder(); 100 | card.setHeader(CardService.newCardHeader() 101 | .setTitle('Message') 102 | .setImageStyle(CardService.ImageStyle.SQUARE) 103 | .setImageUrl(ODO_ICON)); 104 | 105 | let messageSection = CardService.newCardSection(); 106 | messageSection.addWidget(CardService.newTextParagraph() 107 | .setText(message)); 108 | 109 | card.addSection(messageSection); 110 | 111 | return card.build(); 112 | } 113 | -------------------------------------------------------------------------------- /gmail.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to Cards and actions specific to the Add-on when 3 | * showing in the context of a user's Gmail. 4 | */ 5 | 6 | /** 7 | * Function that's called (per manifest) for Gmail homepage trigger 8 | * 9 | * return {CardService.Card} Card to show for Gmail Homepage 10 | */ 11 | function onGmailHomepage() { 12 | return buildIntegrationCard(CALL_CONTEXT.GMAIL_HOMEPAGE); 13 | } 14 | 15 | /** 16 | * Function that's called (per manifest) for Gmail message opened trigger. 17 | * 18 | * @return {CardService.Card} Card to show for opened message. 19 | */ 20 | function onGmailMessageOpened(event) { 21 | let message = getCurrentMessage(event); 22 | 23 | // form and pass values from this context to use as merge key/values 24 | let senderName = getMessageSenderName(message); 25 | let senderEmail = getMessageSenderEmail(message); 26 | 27 | addMergeKeyValuePair('{{senderName}}', senderName); 28 | addMergeKeyValuePair('{{senderEmail}}', senderEmail); 29 | addMergeKeyValuePair('{{token}}', senderEmail); 30 | 31 | return buildIntegrationCard(CALL_CONTEXT.GMAIL_VIEW); 32 | } 33 | 34 | /** 35 | * Function that's called (per manifest) for Gmail compose trigger. 36 | * 37 | * @return {CardService.Card} Card to show for opened message. 38 | */ 39 | function onGmailCompose(event) { 40 | let toRecipients = event.draftMetadata.toRecipients; 41 | let toRecipient = ''; 42 | 43 | if (toRecipients.length > 0) { 44 | toRecipient = toRecipients[0]; 45 | } 46 | 47 | return buildGmailComposeCard(toRecipient); 48 | } 49 | 50 | /** 51 | * Function that returns Gmail homepage Card 52 | * 53 | * return {CardService.Card} Card to show for Gmail Homepage 54 | */ 55 | function buildGmailHomepage() { 56 | let card = CardService.newCardBuilder(); 57 | 58 | let brandedHeader = buildCustomerBrandedHeader(); 59 | card.setHeader(brandedHeader); 60 | 61 | let section = CardService.newCardSection(); 62 | 63 | section.addWidget(CardService.newImage().setImageUrl(OPEN_EMAIL_ICON)); 64 | 65 | section.addWidget( 66 | CardService.newTextParagraph().setText( 67 | '     ' + 68 | '        ' + 69 | 'Open an email to get started!' 70 | ) 71 | ); 72 | 73 | card.addSection(section); 74 | 75 | return card.build(); 76 | } 77 | 78 | /** 79 | * Function that returns Gmail compose Card 80 | * 81 | * return {CardService.Card} Card to show in Gmail compose message 82 | */ 83 | function buildGmailComposeCard(toRecipient) { 84 | if (!toRecipient) { 85 | let card = CardService.newCardBuilder(); 86 | 87 | let section = CardService.newCardSection(); 88 | 89 | section.addWidget( 90 | CardService.newTextParagraph().setText( 91 | 'No actions can be taken for an unaddressed email.' 92 | ) 93 | ); 94 | card.addSection(section); 95 | 96 | return card.build(); 97 | } 98 | 99 | addMergeKeyValuePair('{{senderName}}', toRecipient); 100 | addMergeKeyValuePair('{{senderEmail}}', toRecipient); 101 | addMergeKeyValuePair('{{token}}', toRecipient); 102 | 103 | return buildIntegrationCard(CALL_CONTEXT.GMAIL_COMPOSE); 104 | } 105 | 106 | /** 107 | * Retrieves the current message given an action event object. 108 | * @param {Event} event Action event object 109 | * @return {Message} 110 | */ 111 | function getCurrentMessage(event) { 112 | let accessToken = event.messageMetadata.accessToken; 113 | let messageId = event.messageMetadata.messageId; 114 | GmailApp.setCurrentMessageAccessToken(accessToken); 115 | return GmailApp.getMessageById(messageId); 116 | } 117 | 118 | /** 119 | * Determines date the email was received. 120 | * 121 | * @param {Message} message - The message currently open. 122 | * @returns {String} 123 | */ 124 | function getMessageReceivedDate(message) { 125 | return message.getDate().toLocaleDateString(); 126 | } 127 | 128 | /** 129 | * Determines the name of whomever sent the message. 130 | * 131 | * @param {Message} message - The message currently open. 132 | * @returns {String} 133 | */ 134 | function getMessageSenderName(message) { 135 | let sender = message.getFrom(); 136 | 137 | let senderName = sender.split('<')[0].trim(); 138 | 139 | return senderName; 140 | } 141 | 142 | /** 143 | * Determines the email address of whomever sent the message. 144 | * 145 | * @param {Message} message - The message currently open. 146 | * @returns {String} 147 | */ 148 | function getMessageSenderEmail(message) { 149 | let sender = message.getFrom(); 150 | let senderEmail = ''; 151 | 152 | // look for email address in a string like 'Sender Name ' 153 | let re = /[^< ]+(?=>)/g; 154 | 155 | let senderParts = sender.match(re); 156 | if (senderParts) { 157 | senderEmail = sender.match(re)[0]; 158 | } else { 159 | // can be just straight up email address with no preceeding name or <> symbols 160 | if (sender.includes('@')) { 161 | senderEmail = sender; 162 | } 163 | } 164 | 165 | return senderEmail; 166 | } 167 | -------------------------------------------------------------------------------- /mergeKeys.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to merge keys. Merge keys are used 3 | * to pass context specific data (i.e. sender email of opened email) to 4 | * implementations of the different integration types (i.e. email field of 5 | * a record for a Records based integration). 6 | */ 7 | MERGE_KV_PAIRS = {}; 8 | 9 | mergeInit(); 10 | 11 | /** 12 | * Initialtize merge keys. Called each time this file is loaded to ensure 13 | * merge key data is always properly initialized. 14 | */ 15 | function mergeInit() { 16 | let config = getConfig(); 17 | 18 | if (config) { 19 | MERGE_KV_PAIRS['{{toolName}}'] = config.toolName; 20 | MERGE_KV_PAIRS['{{companyName}}'] = config.companyName; 21 | } 22 | } 23 | 24 | /** 25 | * Adds a merge key/value pair, making it later available by calling 26 | * getMergeKeyValue. 27 | * 28 | * @param {string} key 29 | * @param {Object} value 30 | */ 31 | function addMergeKeyValuePair(key, value) { 32 | MERGE_KV_PAIRS[key] = value; 33 | } 34 | 35 | /** 36 | * Retrieves a merge value based on its key. 37 | * 38 | * @param {string} key - The merge key associated (i.e. '{{sender_email}}') 39 | */ 40 | function getMergeKeyValue(key) { 41 | if (MERGE_KV_PAIRS.hasOwnProperty(key)) { 42 | return MERGE_KV_PAIRS[key]; 43 | } 44 | 45 | return undefined; 46 | } 47 | 48 | /** 49 | * Returns a structure containing all merge key/value pairs 50 | * 51 | * @return {Object} 52 | */ 53 | function getAllMergeKeyPairs() { 54 | return MERGE_KV_PAIRS; 55 | } 56 | 57 | 58 | /** 59 | * Replaces all occurences of each merge key in the target string with its 60 | * corresponding value from the given key/value pairs object. 61 | * 62 | * @param {Object} kvPairs Object of merge key/value pairs 63 | * @param {string} targetString String with merge keys that should be replaced. 64 | * 65 | * @return {string} 66 | */ 67 | function findAndReplaceMergeKeys(targetString, opts) { 68 | if (typeof targetString !== 'string') { 69 | return targetString; 70 | } 71 | 72 | let kvPairs = getAllMergeKeyPairs(); 73 | 74 | // search for a {{mergetag}} 75 | let regex = /.*?(\{\{.*?\}\}).*?/g; 76 | 77 | //console.log("targetString before: " + targetString); 78 | 79 | // look for default-value merge keys in targetString of the form 80 | // {{actualMergeKey || defaultValue}}. If actualMergeKey is present in kvPairs 81 | // then replace the entire pattern with {{actualMergeKey}}. Else, replace it 82 | // with defaultValue. All of this is done prior to actually replacing any 83 | // merge keys in the targetString. 84 | let match; 85 | match = regex.exec(targetString); 86 | 87 | while (match && match.length > 1) { 88 | // check for a default value to use if merge string not found 89 | let mergeTag = match[1]; 90 | //console.log('mergeTag=' + mergeTag) 91 | let mergeDefaultPair = mergeTag.split('||'); 92 | //console.log('mergeDefaultPair =' + mergeDefaultPair) 93 | if (mergeDefaultPair.length > 1) { 94 | let actualMergeTag = mergeDefaultPair[0].trim() + '}}'; 95 | if ( 96 | !kvPairs.hasOwnProperty(actualMergeTag) || 97 | kvPairs[actualMergeTag] === '' 98 | ) { 99 | let defaultMergeValue = mergeDefaultPair[1].trim().slice(0, -2); 100 | //console.log('defaultMergeValue = ' + defaultMergeValue) 101 | targetString = targetString.replace(mergeTag, defaultMergeValue); 102 | } else { 103 | targetString = targetString.replace(mergeTag, actualMergeTag); 104 | } 105 | } 106 | 107 | match = regex.exec(targetString); 108 | } 109 | 110 | // Replace any merge keys in targetString with their corresponding values 111 | // based on the key/value pairs in kvPairs. 112 | for (let [key, value] of Object.entries(kvPairs)) { 113 | if (!key) continue; // skip odd blank key seen sometimes 114 | 115 | //console.log("checking for key: " + key) 116 | 117 | if (opts && opts.uriEncodeValue) { 118 | value = encodeURIComponent(value); 119 | } 120 | if (opts && opts.boldValue) { 121 | value = `${value}`; 122 | } 123 | 124 | targetString = targetString.replace(new RegExp(key, 'g'), value); 125 | } 126 | 127 | return targetString; 128 | } 129 | 130 | /** 131 | * Given a source string with a merge tag in it, checks if the 132 | * merge tag is of the type {{mergeKey || defaultValue}}, and if so 133 | * returns defaultValue. If no merge key is detected, or a merge key 134 | * is detected but is has no default value, then the empty-string is 135 | * returned. 136 | * 137 | * @param {string} sourceString - The string to check 138 | * 139 | * @return {string} 140 | */ 141 | function getDefaultMergeValue(sourceString) { 142 | let regex = /.*?(\{\{.*?\}\}).*?/g; 143 | let match; 144 | 145 | match = regex.exec(sourceString); 146 | 147 | if (!match || match.length < 2) { 148 | return ''; 149 | } 150 | 151 | let mergeTag = match[1]; 152 | let mergeDefaultPair = mergeTag.split('||'); 153 | 154 | if (mergeDefaultPair.length < 2) { 155 | return ''; 156 | } 157 | 158 | let defaultMergeValue = mergeDefaultPair[1].trim().slice(0, -2); 159 | 160 | return defaultMergeValue; 161 | } 162 | 163 | /** Internal test function 164 | * 165 | */ 166 | function _testMergeKeys() { 167 | mergeInit(); 168 | 169 | addMergeKeyValuePair('{{senderEmal}}', 'testemail@domain.com'); 170 | addMergeKeyValuePair('{{token}}', 'Test User'); 171 | 172 | let targetString = 173 | '{{senderName || Bobby McFee}} plus also {{senderEmail}}' 174 | + 'and {{token || participants}}'; 175 | console.log('final result: ' + findAndReplaceMergeKeys(targetString)); 176 | } 177 | -------------------------------------------------------------------------------- /integrationTypeService.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to information that the Odo Add-on 3 | * may show in various contexts if the chosen integration type is 4 | * INTEGRATION_TYPE.GENERIC_SERVICE 5 | */ 6 | 7 | /** 8 | * Function used to return the record data as a formatted Card to be 9 | * displayed. Called from integrationTypeAll.gs as a context specific 10 | * handler for this integration. 11 | * 12 | * @param {string} Calling context (i.e. CALL_CONTEXT.GMAIL_VIEW) 13 | * 14 | * @return {CardService.Card} 15 | */ 16 | function buildServiceBasicCard(context) { 17 | let config = getConfig(); 18 | 19 | let integrationData; 20 | 21 | // if there is previously stored data for this integration type, 22 | // display it. else, show the default values. 23 | if ( 24 | config.saved && 25 | config.integrationType === INTEGRATION_TYPE.GENERIC_SERVICE 26 | ) { 27 | integrationData = config.integrationData; 28 | } else { 29 | integrationData = serviceBasicGetDefaultConfig(); 30 | } 31 | 32 | let buttonUrl = integrationData.buttonUrl; 33 | let message = integrationData.message; 34 | let buttonText = integrationData.buttonText; 35 | 36 | buttonUrl = findAndReplaceMergeKeys(buttonUrl, { uriEncodeValue: true }); 37 | message = findAndReplaceMergeKeys(message); 38 | buttonText = findAndReplaceMergeKeys(buttonText); 39 | 40 | let card = CardService.newCardBuilder(); 41 | 42 | let brandedHeader = buildCustomerBrandedHeader(); 43 | card.setHeader(brandedHeader); 44 | 45 | let section = CardService.newCardSection(); 46 | section.addWidget(CardService.newTextParagraph().setText(message)); 47 | 48 | section.addWidget( 49 | CardService.newTextButton() 50 | .setText(buttonText) 51 | .setOpenLink(CardService.newOpenLink().setUrl(buttonUrl)) 52 | ); 53 | 54 | let button2Url = integrationData.button2Url; 55 | let message2 = integrationData.message2; 56 | let button2Text = integrationData.button2Text; 57 | 58 | if (button2Text && button2Url) { 59 | button2Url = findAndReplaceMergeKeys(button2Url, { uriEncodeValue: true }); 60 | 61 | button2Text = findAndReplaceMergeKeys(button2Text); 62 | 63 | if (message2) { 64 | message2 = findAndReplaceMergeKeys(message2); 65 | section.addWidget(CardService.newTextParagraph().setText(message2)); 66 | } 67 | 68 | section.addWidget( 69 | CardService.newTextButton() 70 | .setText(button2Text) 71 | .setOpenLink(CardService.newOpenLink().setUrl(button2Url)) 72 | ); 73 | } 74 | 75 | card.addSection(section); 76 | 77 | return card.build(); 78 | } 79 | 80 | /** 81 | * Creates and returns the card that gives the user options to configure 82 | * the Service integration. Called from integrationTypeAll.gs based on the 83 | * value of the 'buildConfigureIntegrationCard' parameter. 84 | * 85 | * @return {CardService.Card} 86 | */ 87 | function buildServiceBasicConfigureCard() { 88 | let config = getConfig(); 89 | let integrationData; 90 | 91 | integrationData = getConfigIntegrationData(INTEGRATION_TYPE.GENERIC_SERVICE); 92 | /* 93 | if ( 94 | config.saved && 95 | config.integrationType === INTEGRATION_TYPE.GENERIC_SERVICE 96 | ) { 97 | integrationData = config.integrationData; 98 | } else { 99 | integrationData = serviceBasicGetDefaultConfig(); 100 | } 101 | */ 102 | 103 | let buttonUrl = integrationData.buttonUrl; 104 | let message = integrationData.message; 105 | let buttonText = integrationData.buttonText; 106 | 107 | let button2Url = integrationData.button2Url; 108 | let message2 = integrationData.message2; 109 | let button2Text = integrationData.button2Text; 110 | 111 | let card = CardService.newCardBuilder(); 112 | let section = CardService.newCardSection(); 113 | let input; 114 | 115 | section.addWidget(CardService.newTextParagraph().setText('Primary Button:')); 116 | 117 | input = CardService.newTextInput() 118 | .setFieldName('message') 119 | .setTitle('Message') 120 | .setValue(message); 121 | section.addWidget(input); 122 | 123 | input = CardService.newTextInput() 124 | .setFieldName('buttonText') 125 | .setTitle('Button Text') 126 | .setValue(buttonText); 127 | section.addWidget(input); 128 | 129 | input = CardService.newTextInput() 130 | .setFieldName('buttonUrl') 131 | .setTitle('Button URL') 132 | .setValue(buttonUrl); 133 | section.addWidget(input); 134 | 135 | section.addWidget( 136 | CardService.newTextParagraph().setText('

Optional Secondary Button:') 137 | ); 138 | 139 | input = CardService.newTextInput() 140 | .setFieldName('message2') 141 | .setTitle('Message 2') 142 | .setValue(message2); 143 | section.addWidget(input); 144 | 145 | input = CardService.newTextInput() 146 | .setFieldName('button2Text') 147 | .setTitle('Button 2 Text') 148 | .setValue(button2Text); 149 | section.addWidget(input); 150 | 151 | input = CardService.newTextInput() 152 | .setFieldName('button2Url') 153 | .setTitle('Button 2 URL') 154 | .setValue(button2Url); 155 | section.addWidget(input); 156 | 157 | card.addSection(section); 158 | 159 | return card; 160 | } 161 | 162 | 163 | /** 164 | * Function that gets called for this particular integration when user 165 | * clicks '← Done' button in integration configuration card. Saves the 166 | * selections and returns them as an object to be stored in the 167 | * 'integrationData' field of the config object if/when the user saves their 168 | * configurations. 169 | * 170 | * This is the handler that's defined as 171 | * 'saveConfigureIntegrationSelections' in integrationTypeAll.gs. 172 | * 173 | * @param {object} formInputs - Contains user selections 174 | * 175 | * @return {object} 176 | */ 177 | function saveServiceBasicConfigureSelections(formInputs) { 178 | let message = formInputs['message'].stringInputs.value[0]; 179 | let buttonText = formInputs['buttonText'].stringInputs.value[0]; 180 | let buttonUrl = formInputs['buttonUrl'].stringInputs.value[0]; 181 | 182 | let message2 = ''; 183 | let button2Text = ''; 184 | let button2Url = ''; 185 | 186 | if (formInputs['message2']) { 187 | message2 = formInputs['message2'].stringInputs.value[0]; 188 | } 189 | if (formInputs['button2Text']) { 190 | button2Text = formInputs['button2Text'].stringInputs.value[0]; 191 | } 192 | if (formInputs['button2Url']) { 193 | button2Url = formInputs['button2Url'].stringInputs.value[0]; 194 | } 195 | 196 | let integrationData = { 197 | message: message, 198 | buttonText: buttonText, 199 | buttonUrl: buttonUrl, 200 | message2: message2, 201 | button2Text: button2Text, 202 | button2Url: button2Url, 203 | }; 204 | 205 | return integrationData; 206 | } 207 | 208 | /** 209 | * Function that returns default configuration fields and values for 210 | * an integration type of INTEGRATION_TYPE.GENERIC_SERVICE, to be stored 211 | * as the 'integrationData' field in 'config'. Called when setting 212 | * up default configuration. 213 | * 214 | * @return {Object} 215 | */ 216 | function serviceBasicGetDefaultConfig() { 217 | return { 218 | message: 219 | 'Click the button below to launch a ' + 220 | '{{toolName}} session ' + 221 | 'with {{token || participants}}.', 222 | 223 | buttonText: 'Launch {{toolName}}', 224 | 225 | // note: any merge tags in the buttonUrl will get URI encoded 226 | buttonUrl: 227 | THIRD_PARTY_SERVICE_URL + 228 | '?loadingMessage=Loading%20' + 229 | '{{toolName}}%20with%20' + 230 | '{{token || participants}}', 231 | 232 | message2: '', 233 | button2Text: '', 234 | button2Url: '', 235 | }; 236 | } 237 | -------------------------------------------------------------------------------- /config.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to configuration of the Odo Add-on. 3 | */ 4 | 5 | /** 6 | * Card builder that's called when user selects the Univeral action "Configure" 7 | * 8 | * @return {CardService.Card} Card to show for configuration interface. 9 | */ 10 | function onOdoConfig() { 11 | let wasConfigured = true; 12 | 13 | let config = getConfig(); 14 | if (!config.saved) { 15 | wasConfigured = false; 16 | } 17 | 18 | // clear out any prior selections from the "Customize Integrations" card 19 | let up = PropertiesService.getUserProperties(); 20 | up.deleteProperty(PROP_STRINGIFIED_INTEGRATION_CUSTOMIZATIONS); 21 | 22 | let card = CardService.newCardBuilder(); 23 | let header = CardService.newCardHeader(); 24 | header 25 | .setTitle('Configure Odo') 26 | .setSubtitle('Configure your demo Add-on') 27 | .setImageStyle(CardService.ImageStyle.SQUARE) 28 | .setImageUrl(ODO_ICON); 29 | 30 | card.setHeader(header); 31 | card.setName('mainConfigurationCard'); 32 | 33 | //// General Config Section //// 34 | let appearanceSection = CardService.newCardSection(); 35 | appearanceSection.setHeader('General'); 36 | 37 | let customerNameWidget = CardService.newTextInput() 38 | .setFieldName('customerName') 39 | .setValue(config.customerName) 40 | .setTitle('Customer Name'); 41 | appearanceSection.addWidget(customerNameWidget); 42 | 43 | let customerLogoWidget = CardService.newTextInput() 44 | .setFieldName('customerLogoUrl') 45 | .setValue(config.customerLogoUrl) 46 | .setTitle('Customer Logo URL'); 47 | appearanceSection.addWidget(customerLogoWidget); 48 | 49 | let toolNameWidget = CardService.newTextInput() 50 | .setFieldName('toolName') 51 | .setValue(config.toolName) 52 | .setTitle('Tool Name'); 53 | appearanceSection.addWidget(toolNameWidget); 54 | 55 | let welcomeMessageWidget = CardService.newTextInput() 56 | .setFieldName('welcomeSplashMessage') 57 | .setValue(config.welcomeSplashMessage) 58 | .setTitle('Welcome Splash Card Message'); 59 | appearanceSection.addWidget(welcomeMessageWidget); 60 | 61 | let dateFormatWidget = CardService.newSelectionInput() 62 | .setFieldName('dateFormat') 63 | .setType(CardService.SelectionInputType.DROPDOWN) 64 | .setTitle('Date Format'); 65 | 66 | let supportedDateFormats = [ 67 | 'MM/dd/yyyy', 68 | 'dd MMM, yyyy', 69 | 'dd-MM-yyyy', 70 | 'MM-dd-yyyy', 71 | 'dd/MM/yyyy', 72 | 'MMM dd, yyyy', 73 | ]; 74 | 75 | for (let i = 0; i < supportedDateFormats.length; i++) { 76 | let selected = supportedDateFormats[i] === config.dateFormat; 77 | 78 | dateFormatWidget.addItem( 79 | supportedDateFormats[i], 80 | supportedDateFormats[i], 81 | selected 82 | ); 83 | } 84 | 85 | appearanceSection.addWidget(dateFormatWidget); 86 | appearanceSection.addWidget( 87 | CardService.newTextParagraph().setText('

') 88 | ); 89 | card.addSection(appearanceSection); 90 | ////////////////////// 91 | 92 | //// Integration Section //// 93 | let integrationSection = CardService.newCardSection(); 94 | integrationSection.setHeader('Simulated Integration'); 95 | 96 | let integrationTypeWidget = CardService.newSelectionInput() 97 | .setFieldName('integrationType') 98 | .setType(CardService.SelectionInputType.DROPDOWN) 99 | .setTitle('Integration Type'); 100 | 101 | for (integrationType in INTEGRATION_HOOKS) { 102 | if (!INTEGRATION_HOOKS.hasOwnProperty(integrationType)) { 103 | continue; 104 | } 105 | 106 | let hooks = INTEGRATION_HOOKS[integrationType]; 107 | 108 | let selected = integrationType === config.integrationType; 109 | 110 | integrationTypeWidget.addItem( 111 | integrationTypeToPrintableString(integrationType), 112 | integrationType, 113 | selected 114 | ); 115 | } 116 | 117 | integrationSection.addWidget(integrationTypeWidget); 118 | 119 | let customizeIntegrationAction = CardService.newAction(); 120 | 121 | customizeIntegrationAction.setFunctionName( 122 | 'onUserSelectedCustomizeIntegration' 123 | ); 124 | 125 | integrationSection.addWidget( 126 | CardService.newTextButton() 127 | .setText('Customize Integration →') 128 | .setOnClickAction(customizeIntegrationAction) 129 | ); 130 | 131 | card.addSection(integrationSection); 132 | ////////////////////// 133 | 134 | //// Footer with Button(s) //// 135 | let footer = CardService.newFixedFooter(); 136 | 137 | let saveAction = CardService.newAction().setFunctionName( 138 | 'onUserSelectedConfigSaveSelections' 139 | ); 140 | 141 | footer.setPrimaryButton( 142 | CardService.newTextButton().setText('Save').setOnClickAction(saveAction) 143 | ); 144 | 145 | if (wasConfigured) { 146 | let resetAction = CardService.newAction().setFunctionName( 147 | 'onUserSelectedConfigResetSelections' 148 | ); 149 | 150 | footer.setSecondaryButton( 151 | CardService.newTextButton() 152 | .setText('Reset All') 153 | .setOnClickAction(resetAction) 154 | ); 155 | } 156 | 157 | card.setFixedFooter(footer); 158 | ////////////////////// 159 | 160 | return card.build(); 161 | } 162 | 163 | 164 | /** 165 | * Builds a card that shows the user options to customize their integration. 166 | * Contents will vary based on the chosen integration type. Called when 167 | * user clicks "Customize Integration" from "Configure Odo" card. 168 | * 169 | * @param {Object} event Event information passed in by Card framework. 170 | * 171 | * @return {CardService.Card} Card to show result. 172 | */ 173 | function onUserSelectedCustomizeIntegration(event) { 174 | // get the user selected integration type to customize 175 | let formInputs = event.commonEventObject.formInputs; 176 | let selectedIntegrationType = 177 | formInputs.integrationType.stringInputs.value[0]; 178 | 179 | return buildCustomizeIntegrationCard(selectedIntegrationType); 180 | } 181 | 182 | /** 183 | * Saves the user selected configurations from the Configure Odo page 184 | * 185 | * @param {Object} event Event information passed in by Card framework. 186 | * 187 | * @return {CardService.Card} Card to show result. 188 | */ 189 | function onUserSelectedConfigSaveSelections(event) { 190 | let config = {}; 191 | let formInputs = event.commonEventObject.formInputs; 192 | 193 | config.saved = true; 194 | 195 | config.customerName = formInputs.customerName.stringInputs.value[0]; 196 | config.customerLogoUrl = formInputs.customerLogoUrl.stringInputs.value[0]; 197 | config.toolName = formInputs.toolName.stringInputs.value[0]; 198 | config.integrationType = formInputs.integrationType.stringInputs.value[0]; 199 | config.dateFormat = formInputs.dateFormat.stringInputs.value[0]; 200 | config.welcomeSplashMessage = 201 | formInputs.welcomeSplashMessage.stringInputs.value[0]; 202 | 203 | let up = PropertiesService.getUserProperties(); 204 | let integrationDataStr = up.getProperty( 205 | PROP_STRINGIFIED_INTEGRATION_CUSTOMIZATIONS 206 | ); 207 | 208 | if (integrationDataStr) { 209 | let integrationData = JSON.parse(integrationDataStr); 210 | console.log(JSON.stringify(integrationData)) 211 | config.integrationData = integrationData; 212 | } else { 213 | let hooks = INTEGRATION_HOOKS[config.integrationType]; 214 | config.integrationData = hooks.defaultIntegrationConfig(); 215 | } 216 | 217 | saveConfig(config); 218 | 219 | return buildRefreshNeededCard(); 220 | } 221 | 222 | /** 223 | * Resets the Odo configurations 224 | * 225 | * @return {CardService.Card} Card to show result 226 | */ 227 | function onUserSelectedConfigResetSelections() { 228 | resetConfig(); 229 | 230 | return buildRefreshNeededCard(); 231 | } 232 | 233 | /** 234 | * Returns the current Odo configuration, which is an Object (struct) with 235 | * various members related to Odo's config. Can be modified and saved via a 236 | * call to saveConfig(). 237 | * 238 | * @return {Object} 239 | */ 240 | function getConfig() { 241 | let up = PropertiesService.getUserProperties(); 242 | 243 | let configStr = up.getProperty(USER_PROPERTY_CONFIG); 244 | 245 | let config = null; 246 | 247 | if (configStr) { 248 | config = JSON.parse(configStr); 249 | } else { 250 | return _getDefaultConfig(); 251 | } 252 | 253 | return config; 254 | } 255 | 256 | /** 257 | * Takes a configuration Object (struct) and saves it. Will 258 | * be returned with the same data the next time getConfig() is called. 259 | * @param {Object} config - The configuration object to be saved. 260 | * 261 | */ 262 | function saveConfig(config) { 263 | config.saved = true; 264 | let configStr = JSON.stringify(config); 265 | 266 | let up = PropertiesService.getUserProperties(); 267 | up.setProperty(USER_PROPERTY_CONFIG, configStr); 268 | } 269 | 270 | /** 271 | * Function that fully resets the configuration. 272 | * 273 | */ 274 | function resetConfig() { 275 | let up = PropertiesService.getUserProperties(); 276 | 277 | // Delete all stored properties. This will include the USER_PROPERTY_CONFIG, 278 | // as well as any integration specific properties. 279 | up.deleteAllProperties(); 280 | } 281 | 282 | /** 283 | * Dump contents of config to console. For debugging purposes only. 284 | */ 285 | function _dumpConfig() { 286 | let config = getConfig(); 287 | 288 | console.log(JSON.stringify(config)); 289 | } 290 | 291 | /** 292 | * Private function that constructs and returns a default config (i.e. user 293 | * has not configured Odo yet). Used internally by getConfig(). 294 | * 295 | * @return {Object} 296 | */ 297 | function _getDefaultConfig() { 298 | let defaultIntegrationType = INTEGRATION_TYPE.RECORDS_BASED; 299 | 300 | let config = { 301 | saved: false, // set to true first time configi saved by user, 302 | welcomeSplashShhown: false, 303 | customerName: DEFAULT_CUSTOMER_NAME, 304 | customerLogoUrl: DEFAULT_CUSTOMER_LOGO_URL, 305 | toolName: DEFAULT_CUSTOMER_TOOL_NAME, 306 | integrationType: defaultIntegrationType, 307 | dateFormat: 'MMM dd, yyyy', 308 | welcomeSplashMessage: 309 | 'Welcome to {{toolName}} for Workspace. Click the button below ' 310 | + 'to get going!', 311 | }; 312 | 313 | let hooks = INTEGRATION_HOOKS[defaultIntegrationType]; 314 | config.integrationData = hooks.defaultIntegrationConfig(); 315 | 316 | return config; 317 | } 318 | -------------------------------------------------------------------------------- /integrationTypeFileRepo.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to information that the Odo Add-on 3 | * may show in various contexts if the chosen integration type is 4 | * INTEGRATION_TYPE.FILE_REPOSITORY 5 | */ 6 | 7 | /** 8 | * Function that returns default configuration fields and values for 9 | * an integration type of INTEGRATION_TYPE.FILE_REPOSITORY, to be stored 10 | * as the 'integrationData' field in 'config'. Called when setting 11 | * up default configuration. 12 | * 13 | * @return {Object} 14 | */ 15 | function fileRepoGetDefaultConfig() { 16 | let integrationData = { 17 | maxFiles: 5, 18 | }; 19 | 20 | return integrationData; 21 | } 22 | 23 | /** 24 | * Creates and returns the card that gives the user options to configure 25 | * the File Repository integration. Called from integrationTypeAll.gs based 26 | * on the value of the 'buildConfigureIntegrationCard' parameter. 27 | * 28 | * @return {CardService.Card} 29 | */ 30 | function buildFileRepoConfigureCard() { 31 | let config = getConfig(); 32 | 33 | const fileLimits = [5, 10, 25, 50]; 34 | 35 | let card = CardService.newCardBuilder(); 36 | 37 | integrationData = getConfigIntegrationData(INTEGRATION_TYPE.FILE_REPOSITORY); 38 | let selectedMaxFiles = integrationData.maxFiles; 39 | /* 40 | if (config.saved && 41 | config.integrationType === INTEGRATION_TYPE.FILE_REPOSITORY 42 | ) { 43 | selectedMaxFiles = config.integrationData.maxFiles; 44 | } else { 45 | selectedMaxFiles = fileLimits[0]; // default 46 | } 47 | */ 48 | 49 | let section = CardService.newCardSection(); 50 | 51 | let selectMaxFilesWidget = CardService.newSelectionInput() 52 | .setFieldName('maxFiles') 53 | .setType(CardService.SelectionInputType.DROPDOWN) 54 | .setTitle('Max Files Shown') 55 | for (let i=0; i < fileLimits.length; i++) { 56 | let limit = fileLimits[i]; 57 | selectMaxFilesWidget.addItem(limit.toString(), limit, 58 | (limit === Number(selectedMaxFiles))); 59 | } 60 | 61 | section.addWidget(selectMaxFilesWidget); 62 | 63 | card.addSection(section); 64 | 65 | return card; 66 | } 67 | 68 | 69 | /** 70 | * Function that gets called for this particular integration when user 71 | * clicks '← Done' button in integration configuration card. Saves the 72 | * selections and returns them as an object to be stored in the 73 | * 'integrationData' field of the config object if/when the user saves their 74 | * configurations. 75 | * 76 | * This is the handler that's defined as 77 | * 'saveConfigureIntegrationSelections' in integrationTypeAll.gs. 78 | * 79 | * @param {object} formInputs - Contains user selections 80 | * 81 | * @return {object} 82 | */ 83 | function saveFileRepoConfigureSelections(formInputs) { 84 | let integrationData = {}; 85 | 86 | integrationData.maxFiles = formInputs.maxFiles.stringInputs.value[0]; 87 | 88 | return integrationData; 89 | } 90 | 91 | 92 | /** 93 | * Function used to return the file repository data as a formatted Card to be 94 | * displayed. Called from integrationTypeAll.gs as a context specific 95 | * handler for this integration. 96 | * 97 | * @param {string} Calling context (i.e. CALL_CONTEXT.DRIVE) 98 | * 99 | * @return {Card} 100 | */ 101 | function buildFileRepoCard(context) { 102 | 103 | // if file selected, giver user chance to check it in 104 | let fileName = getMergeKeyValue('{{fileName}}'); 105 | let mimeType = getMergeKeyValue('{{fileMimeType}}'); 106 | let fileId = getMergeKeyValue('{{fileId}}'); 107 | 108 | let card = CardService.newCardBuilder(); 109 | let brandedHeader = buildCustomerBrandedHeader(); 110 | card.setHeader(brandedHeader); 111 | 112 | if (fileName) { 113 | // file is selected 114 | let section = CardService.newCardSection(); 115 | 116 | // check mimeType to ensure it's DOCX 117 | if (!_isOfficeFile(mimeType)) { 118 | console.log(mimeType) 119 | let message = 'Only MS Office documents can be checked-in.'; 120 | section.addWidget(CardService.newTextParagraph().setText(message)); 121 | // inform user that only office files are allowed 122 | } else { 123 | // give user chance to check in file 124 | let url = _getOfficeFileIconUrl(mimeType) 125 | let icon = CardService.newIconImage().setIconUrl(url); 126 | let textField = CardService.newDecoratedText() 127 | .setStartIcon(icon) 128 | .setText(fileName); 129 | 130 | section.addWidget(textField); 131 | 132 | let params = { 133 | fileId: fileId, 134 | fileName: fileName, 135 | }; 136 | 137 | let buttonAction = CardService.newAction() 138 | .setFunctionName('_checkInFile') 139 | .setParameters(params); 140 | 141 | let button = CardService.newTextButton() 142 | .setText("Check-in File") 143 | .setOnClickAction(buttonAction); 144 | 145 | section.addWidget(button); 146 | 147 | } 148 | card.addSection(section); 149 | 150 | } else { 151 | // no file is selected 152 | let section = CardService.newCardSection(); 153 | 154 | section.addWidget(CardService.newImage().setImageUrl(SELECT_FILE_ICON)); 155 | 156 | section.addWidget( 157 | CardService.newTextParagraph().setText( 158 | '     ' + 159 | '        ' + 160 | 'Select a file to check it in' 161 | )); 162 | 163 | card.addSection(section); 164 | 165 | // show user list of files they can "check out" 166 | let ciFiles = _getCheckedInFiles(); 167 | 168 | if (ciFiles.length) { 169 | let section2 = CardService.newCardSection(); 170 | 171 | let message = '
Or select a file listed below to check-out:'; 172 | section2.addWidget(CardService.newTextParagraph().setText(message)); 173 | 174 | for (let i=0; i < ciFiles.length; i++) { 175 | let file = ciFiles[i]; 176 | 177 | let fileListEntry = CardService.newDecoratedText(); 178 | fileListEntry.setText(file.getName()) 179 | .setBottomLabel('Modified: ' + file.getLastUpdated()) 180 | let iconImage = CardService.newIconImage() 181 | .setIconUrl(_getOfficeFileIconUrl(file.getMimeType())); 182 | fileListEntry.setStartIcon(iconImage); 183 | let action = CardService.newAction() 184 | .setFunctionName('_checkOutFile') 185 | .setParameters({fileId: file.getId(), fileName: file.getName()}); 186 | 187 | let button = CardService.newImageButton() 188 | .setIconUrl(CHECKOUT_FILE_ICON) 189 | .setAltText('Checkout File') 190 | .setOnClickAction(action); 191 | fileListEntry.setButton(button); 192 | 193 | section2.addWidget(fileListEntry); 194 | } 195 | 196 | card.addSection(section2); 197 | } 198 | } 199 | 200 | return card.build(); 201 | } 202 | 203 | /** 204 | * Private function that handles the user's request to check out 205 | * a selected file when the button next to it is clicked. 206 | * @param {Object} event 207 | */ 208 | function _checkOutFile(event) { 209 | let up = PropertiesService.getUserProperties(); 210 | let fileId = event.parameters.fileId; 211 | let fileName = event.parameters.fileName; 212 | let destFolder; 213 | 214 | let destFolderId = up.getProperty(fileId); 215 | 216 | if (!destFolderId) { 217 | destFolder = DriveApp.getRootFolder(); 218 | } else { 219 | destFolder = DriveApp.getFolderById(destFolderId); 220 | } 221 | 222 | let f = DriveApp.getFileById(fileId); 223 | f.moveTo(destFolder); 224 | 225 | let card = CardService.newCardBuilder(); 226 | let brandedHeader = buildCustomerBrandedHeader(); 227 | card.setHeader(brandedHeader); 228 | 229 | let section = CardService.newCardSection(); 230 | 231 | let message = `File ${fileName}"" has been checked-out`; 232 | 233 | let url = f.getUrl(); 234 | section.addWidget(CardService.newTextParagraph().setText(message)); 235 | 236 | card.addSection(section); 237 | let updateCard = card.build(); 238 | 239 | let actionResponse = CardService.newActionResponseBuilder() 240 | .setNavigation(CardService.newNavigation().updateCard(updateCard)) 241 | .setStateChanged(true) 242 | .build(); 243 | 244 | return actionResponse; 245 | } 246 | 247 | /** 248 | * Private function that handles the user's request to check in 249 | * a selected file when the "Check-In File" button is clicked. 250 | * 251 | * @param {Object} event 252 | * 253 | */ 254 | function _checkInFile(event) { 255 | let up = PropertiesService.getUserProperties(); 256 | 257 | let fileId = event.parameters.fileId; 258 | let fileName = event.parameters.fileName; 259 | 260 | let f = DriveApp.getFileById(fileId); 261 | let parentFolderIter = f.getParents(); 262 | 263 | if (parentFolderIter.hasNext()) { 264 | let parentFolderId = parentFolderIter.next().getId(); 265 | // record where this file lived so we can put it back there 266 | // upon next check-in 267 | up.setProperty(fileId, parentFolderId); 268 | } 269 | 270 | // "check-in" the file. Here we simulate this by just moving 271 | // the file to a special folder so we can hide it from view. 272 | let odf = createOrGetOdoDataFolder(); 273 | let file = DriveApp.getFileById(fileId); 274 | file.moveTo(odf); 275 | 276 | let card = CardService.newCardBuilder(); 277 | 278 | let brandedHeader = buildCustomerBrandedHeader(); 279 | card.setHeader(brandedHeader); 280 | 281 | let section = CardService.newCardSection(); 282 | 283 | let message = `File "${fileName}" has been checked-in`; 284 | section.addWidget(CardService.newTextParagraph().setText(message)); 285 | 286 | card.addSection(section); 287 | let updateCard = card.build(); 288 | 289 | let actionResponse = CardService.newActionResponseBuilder() 290 | .setNavigation(CardService.newNavigation().updateCard(updateCard)) 291 | .setStateChanged(true) 292 | .build(); 293 | 294 | return actionResponse; 295 | } 296 | 297 | /** 298 | * Private function that returns a list of Files that have been checked in 299 | * (sorted by last modified date). Number returned is limited to 300 | * config.integrationData.maxFiles 301 | * 302 | * @return {Object} Array of Files 303 | */ 304 | function _getCheckedInFiles() { 305 | let config = getConfig(); 306 | 307 | let ciFiles = []; 308 | let odf = createOrGetOdoDataFolder(); 309 | let count = 0; 310 | let filesIter = odf.getFiles(); 311 | 312 | while (filesIter.hasNext()) { 313 | let file = filesIter.next(); 314 | ciFiles.push(file); 315 | count++; 316 | if (count === config.integrationData.maxFiles) { 317 | break; 318 | } 319 | } 320 | 321 | return ciFiles; 322 | } 323 | 324 | /** 325 | * Private function that returns true if given mimeType corresponds to 326 | * an MS Office file (Word, Excel, Powerpoint), false otherwise. 327 | * 328 | * @return {Boolean} 329 | */ 330 | function _isOfficeFile(mimeType) { 331 | if (mimeType.includes('officedocument')) { 332 | return true; 333 | } 334 | 335 | return false; 336 | } 337 | 338 | /** 339 | * Private function that returns a URL for an icon for Word, Excel, or 340 | * Powerpoint files (based on the passed mimeType). Call _isOfficeFile 341 | * first to verify if mimeType corresponds to Office file. 342 | * 343 | * @return {String} 344 | */ 345 | function _getOfficeFileIconUrl(mimeType) { 346 | let url; 347 | 348 | if (mimeType.includes('word')) { 349 | url = 'https://img.icons8.com/color/512/ms-word.png'; 350 | } else if (mimeType.includes('presentation')) { 351 | url = 'https://img.icons8.com/color/512/ms-powerpoint.png'; 352 | } else if (mimeType.includes('spreadsheet')) { 353 | url = 'https://img.icons8.com/color/512/ms-excel.png'; 354 | } else { 355 | url = 'https://img.icons8.com/color/512/office-365.png'; 356 | } 357 | 358 | return url; 359 | } 360 | 361 | 362 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /integrationTypeImageLibrary.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to information that the Odo Add-on 3 | * may show in various contexts if the chosen integration type is 4 | * INTEGRATION_TYPE.IMG_LIBRARY 5 | */ 6 | 7 | 8 | const PROP_SELECTED_IMG_LIBRARY_REPO = 'PROP_SELECTED_IMG_LIBRARY_REPO'; 9 | 10 | /** 11 | * Function that returns default configuration fields and values for 12 | * an integration type of INTEGRATION_TYPE.IMG_LIBRARY, to be stored 13 | * as the 'integrationData' field in 'config'. Called when setting 14 | * up default configuration. 15 | * 16 | * @return {Object} 17 | */ 18 | function imgLibraryGetDefaultConfig() { 19 | let integrationData = { 20 | repos: [ 21 | { 22 | name: 'Finance', 23 | driveFolderUrl: 'https://drive.google.com/drive/folders/' 24 | + '1XmcEiWzyiYNIOQuMr0WuuqLonDXeL6mb' 25 | }, 26 | { 27 | name: 'HR', 28 | driveFolderUrl: 'https://drive.google.com/drive/folders/' 29 | + '1g4VlalVo3W32wSeYp5u0-3Jy1WOp5dPe' 30 | }, 31 | { 32 | name: '', 33 | driveFolderUrl: '' 34 | }, 35 | ] 36 | }; 37 | 38 | return integrationData; 39 | } 40 | 41 | /** 42 | * Creates and returns the card that gives the user options to configure 43 | * the Image Library integration. Called from integrationTypeAll.gs based 44 | * on the value of the 'buildConfigureIntegrationCard' parameter. 45 | * 46 | * @return {CardService.Card} 47 | */ 48 | function buildImgLibraryConfigureCard() { 49 | let config = getConfig(); 50 | let integrationData; 51 | 52 | integrationData = getConfigIntegrationData(INTEGRATION_TYPE.IMAGE_LIBRARY); 53 | 54 | let repos = integrationData.repos; 55 | let repoName1 = integrationData.repos[0].name; 56 | let driveFolderUrl1 = repos[0].driveFolderUrl; 57 | 58 | let repoName2 = integrationData.repos[1].name; 59 | let driveFolderUrl2 = repos[1].driveFolderUrl; 60 | 61 | let repoName3 = integrationData.repos[2].name; 62 | let driveFolderUrl3 = repos[2].driveFolderUrl; 63 | 64 | let card = CardService.newCardBuilder(); 65 | let section = CardService.newCardSection(); 66 | let input; 67 | 68 | section.addWidget(CardService.newTextParagraph().setText('Repo #1:')); 69 | 70 | input = CardService.newTextInput() 71 | .setFieldName('repoName1') 72 | .setTitle('Repo Name') 73 | .setValue(repoName1); 74 | section.addWidget(input); 75 | 76 | input = CardService.newTextInput() 77 | .setFieldName('driveFolderUrl1') 78 | .setTitle('Drive Folder Url') 79 | .setValue(driveFolderUrl1); 80 | section.addWidget(input); 81 | 82 | section.addWidget( 83 | CardService.newTextParagraph().setText('

Repo #2:') 84 | ); 85 | 86 | input = CardService.newTextInput() 87 | .setFieldName('repoName2') 88 | .setTitle('Repo Name') 89 | .setValue(repoName2); 90 | section.addWidget(input); 91 | 92 | input = CardService.newTextInput() 93 | .setFieldName('driveFolderUrl2') 94 | .setTitle('Drive Folder Url') 95 | .setValue(driveFolderUrl2); 96 | section.addWidget(input); 97 | 98 | section.addWidget( 99 | CardService.newTextParagraph().setText('

Repo #3:') 100 | ); 101 | 102 | input = CardService.newTextInput() 103 | .setFieldName('repoName3') 104 | .setTitle('Repo Name') 105 | .setValue(repoName3); 106 | section.addWidget(input); 107 | 108 | input = CardService.newTextInput() 109 | .setFieldName('driveFolderUrl3') 110 | .setTitle('Drive Folder Url') 111 | .setValue(driveFolderUrl3); 112 | section.addWidget(input); 113 | card.addSection(section); 114 | 115 | return card; 116 | } 117 | 118 | 119 | /** 120 | * Function that gets called for this particular integration when user 121 | * clicks '← Done' button in integration configuration card. Saves the 122 | * selections and returns them as an object to be stored in the 123 | * 'integrationData' field of the config object if/when the user saves their 124 | * configurations. 125 | * 126 | * This is the handler that's defined as 127 | * 'saveConfigureIntegrationSelections' in integrationTypeAll.gs. 128 | * 129 | * @param {object} formInputs - Contains user selections 130 | * 131 | * @return {object} 132 | */ 133 | function saveImgLibraryConfigureSelections(formInputs) { 134 | let repoName1 = ''; 135 | let driveFolderUrl1 = ''; 136 | let repoName2 = ''; 137 | let driveFolderUrl2 = ''; 138 | let repoName3 = ''; 139 | let driveFolderUrl3 = ''; 140 | 141 | if (formInputs.hasOwnProperty('repoName1') 142 | && formInputs.hasOwnProperty('driveFolderUrl1')) { 143 | repoName1 = formInputs['repoName1'].stringInputs.value[0]; 144 | driveFolderUrl1 = formInputs['driveFolderUrl1'].stringInputs.value[0]; 145 | } 146 | 147 | if (formInputs.hasOwnProperty('repoName2') 148 | && formInputs.hasOwnProperty('driveFolderUrl2')) { 149 | repoName2 = formInputs['repoName2'].stringInputs.value[0]; 150 | driveFolderUrl2 = formInputs['driveFolderUrl2'].stringInputs.value[0]; 151 | } 152 | 153 | if (formInputs.hasOwnProperty('repoName3') 154 | && formInputs.hasOwnProperty('driveFolderUrl3')) { 155 | repoName3 = formInputs['repoName3'].stringInputs.value[0]; 156 | driveFolderUrl3 = formInputs['driveFolderUrl3'].stringInputs.value[0]; 157 | } 158 | 159 | console.log(repoName1); 160 | 161 | let integrationData = { 162 | repos: [ 163 | { 164 | name: repoName1, 165 | driveFolderUrl: driveFolderUrl1 166 | }, 167 | { 168 | name: repoName2, 169 | driveFolderUrl: driveFolderUrl2 170 | }, 171 | { 172 | name: repoName3, 173 | driveFolderUrl: driveFolderUrl3 174 | }, 175 | ] 176 | }; 177 | 178 | return integrationData; 179 | } 180 | 181 | 182 | /** 183 | * Function used to return the image library data as a formatted Card to be 184 | * displayed. Called from integrationTypeAll.gs as a context specific 185 | * handler for this integration. 186 | * 187 | * @param {string} Calling context (i.e. CALL_CONTEXT.SLIDES) 188 | * 189 | * @return {Card} 190 | */ 191 | function buildImgLibraryCard(context) { 192 | let up = PropertiesService.getUserProperties(); 193 | let config = getConfig(); 194 | 195 | let card = CardService.newCardBuilder(); 196 | let brandedHeader = buildCustomerBrandedHeader(); 197 | card.setHeader(brandedHeader); 198 | 199 | let repos = config.integrationData.repos; 200 | if (repos.length === 0) { 201 | let section = CardService.newCardSection(); 202 | section.addWidget(CardService.newTextParagraph() 203 | .setText("No image library repositories have been configured")); 204 | card.addSection(section); 205 | return card.build; 206 | } 207 | 208 | // gather info on the currently selected (or default) image repo 209 | let selectedRepoUrl = up.getProperty(PROP_SELECTED_IMG_LIBRARY_REPO); 210 | let resourceKey; 211 | 212 | if (!selectedRepoUrl) { 213 | selectedRepoId = _extractFolderIdFromUrl(repos[0].driveFolderUrl); 214 | resourceKey = _extractResourceKeyFromUrl(repos[0].driveFolderUrl); 215 | name = repos[0].name; 216 | } else { 217 | // get all the info on the previously selected repo 218 | for (let i=0; i < repos.length; i++) { 219 | if (repos[i].driveFolderUrl 220 | && repos[i].driveFolderUrl === selectedRepoUrl) { 221 | selectedRepoId = _extractFolderIdFromUrl(repos[i].driveFolderUrl); 222 | resourceKey = _extractResourceKeyFromUrl(repos[i].driveFolderUrl); 223 | name = repos[i].name; 224 | break; 225 | } 226 | } 227 | } 228 | 229 | // show repo drop-down selector 230 | let repoSection = CardService.newCardSection(); 231 | 232 | let onChangeAction = CardService.newAction() 233 | .setFunctionName('_refreshImageRepoCard'); 234 | 235 | let selectRepoWidget = CardService.newSelectionInput() 236 | .setFieldName('selectedRepo') 237 | .setType(CardService.SelectionInputType.DROPDOWN) 238 | .setTitle('Image Repository') 239 | .setOnChangeAction(onChangeAction); 240 | 241 | for (let i=0; i < repos.length; i++) { 242 | if (repos[i].driveFolderUrl) { 243 | selectRepoWidget.addItem(repos[i].name, repos[i].driveFolderUrl, 244 | (repos[i].driveFolderUrl === selectedRepoUrl)); 245 | } 246 | } 247 | 248 | repoSection.addWidget(selectRepoWidget); 249 | 250 | 251 | // show images in repo folder 252 | let imgSection = CardService.newCardSection(); 253 | imgSection.addWidget(CardService.newTextParagraph() 254 | .setText("Click on an image to insert it:")); 255 | 256 | let repoFolder; 257 | if (resourceKey) { 258 | repoFolder = DriveApp.getFolderByIdAndResourceKey( 259 | selectedRepoId, 260 | resourceKey); 261 | } else { 262 | repoFolder = DriveApp.getFolderById(selectedRepoId); 263 | } 264 | 265 | let imgFileIter = repoFolder.getFiles(); 266 | while (imgFileIter.hasNext()) { 267 | let imgFile = imgFileIter.next(); 268 | 269 | let imgFileId = imgFile.getId(); 270 | let imgFileResourceKey = imgFile.getResourceKey(); 271 | if (imgFileResourceKey === null) { 272 | // can't pass a null parameter 273 | imgFileResourceKey = ''; 274 | } 275 | let imgUrl = 'https://docs.google.com/uc?id=' + imgFileId; 276 | if (resourceKey) { 277 | imgUrl += '&resourcekey=' + resourceKey; 278 | } 279 | 280 | let params = { 281 | imgFileId: imgFileId, 282 | imgFileResourceKey: imgFileResourceKey, 283 | }; 284 | 285 | let clickAction = CardService.newAction() 286 | .setParameters(params) 287 | .setFunctionName('_onImageClick'); 288 | 289 | let imgWidget = CardService.newImage() 290 | .setImageUrl(imgUrl) 291 | .setOnClickAction(clickAction); 292 | 293 | imgSection.addWidget(imgWidget); 294 | } 295 | 296 | card.addSection(repoSection); 297 | card.addSection(imgSection); 298 | 299 | return card.build(); 300 | } 301 | 302 | /** 303 | * Internal function to fresh the images when a new repository is selected. 304 | * 305 | */ 306 | function _refreshImageRepoCard(event) { 307 | let formInputs = event.commonEventObject.formInputs; 308 | 309 | let up = PropertiesService.getUserProperties(); 310 | 311 | let selectedRepo = formInputs.selectedRepo.stringInputs.value[0]; 312 | 313 | up.setProperty(PROP_SELECTED_IMG_LIBRARY_REPO, selectedRepo);; 314 | 315 | return buildImgLibraryCard(CALL_CONTEXT.SLIDES); 316 | } 317 | 318 | /** 319 | * Internal function to insert the selected image into the selected/active 320 | * slide. 321 | * 322 | * @param {Object} event - Includes parameters to identify the selected image. 323 | */ 324 | function _onImageClick(event) { 325 | let imgFileId = event.parameters.imgFileId; 326 | let imgFileResourceKey = event.parameters.imgFileResourceKey; 327 | 328 | let slide = SlidesApp.getActivePresentation() 329 | .getSelection() 330 | .getCurrentPage(); 331 | 332 | let file; 333 | if (imgFileResourceKey) { 334 | file = DriveApp.getFileByIdAndResourceKey(imgFileId, imgFileResourceKey); 335 | } else { 336 | file = DriveApp.getFileById(imgFileId); 337 | } 338 | 339 | slide.insertImage(file.getBlob()); 340 | } 341 | 342 | /** 343 | * Internal function to extract the Drive Folder ID from a folder's URL. 344 | * If not present (not all folders will have one), returns empty string. 345 | * 346 | * @param {String} folderUrl 347 | * 348 | * @return {String} 349 | */ 350 | function _extractFolderIdFromUrl(folderUrl) { 351 | let folderId = ''; 352 | 353 | let regex = /https:\/\/drive\.google\.com\/drive\/folders\/(.+)/; 354 | 355 | // get rid of any params (i.e. '?resourcekey=') 356 | folderUrlSplit = folderUrl.split('?')[0]; 357 | let found = folderUrlSplit.match(regex); 358 | 359 | if (!found) { 360 | return ''; 361 | } 362 | 363 | folderId = found[1]; 364 | 365 | return folderId; 366 | } 367 | 368 | /** 369 | * Internal function to extract the Drive Folder Resource Key from a folder's 370 | * URL. If not present (not all folders will have one), returns empty string. 371 | * 372 | * @param {String} folderUrl 373 | * 374 | * @return {String} 375 | */ 376 | function _extractResourceKeyFromUrl(folderUrl) { 377 | let resourceKey = ''; 378 | 379 | let regex = /resourcekey=([^&]+)/; 380 | 381 | // get rid of any params (i.e. '?resourcekey=') 382 | let folderUrlSplit = folderUrl.split('?'); 383 | 384 | if (folderUrlSplit.length < 2) { 385 | return ''; 386 | } 387 | 388 | let splitPart = folderUrlSplit[1]; 389 | 390 | let found = splitPart.match(regex); 391 | 392 | if (!found) { 393 | return ''; 394 | } 395 | 396 | resourceKey = found[1]; 397 | 398 | return resourceKey; 399 | } -------------------------------------------------------------------------------- /integrationTypeAll.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to all integration types that the Odo Add-on 3 | * supports, mostly related to setting and saving the configurations for 4 | * those integration types. 5 | */ 6 | 7 | /** 8 | * Integration types that Odo can be configured to simulate. 9 | * @enum {string} 10 | */ 11 | const INTEGRATION_TYPE = { 12 | GENERIC_SERVICE: 'GENERIC_SERVICE', // see integrationTypeService.gs 13 | RECORDS_BASED: 'RECORDS_BASED', // see integrationTypeRecords.gs 14 | FILE_REPOSITORY: 'FILE_REPOSITORY', // see integrationFileRepository.gs 15 | IMAGE_LIBRARY: 'IMG_LIBRARY', // see integrationImageLibrary.gs 16 | }; 17 | 18 | /** 19 | * Hooks that each integration type must implement of the following format: 20 | * 21 | * [INTEGRATION_TYPE.XXX] { 22 | * // Printable string for this integration type (shown in configuration 23 | * // card) 24 | * printableString: , 25 | * 26 | * // Function the returns a struct with a default configuration 27 | * // for this integration type (to be used if user doesn't configure 28 | * // it themselves, as well to define default values on the integration 29 | * // configuration card. Stored in 'integrationData' field of 30 | * // the 'config' object. 31 | * // 32 | * // @return {Object} 33 | * defaultIntegrationConfig: , 34 | * 35 | * // Handler that builds a card with customization options. Shown 36 | * // when user selects 'Customize Integration' from configuration card. 37 | * // 38 | * // @return {CardService.Card} 39 | * buildConfigureIntegrationCard: , 40 | * 41 | * // Handler that saves results of integration configuration card (when 42 | * // user clicks "← Done"). Passed a formInputs objects with user's 43 | * // selections. Should return the struct/Object to be saved in the 44 | * // 'integrationData' field of the 'config' object. 45 | * // @param {Objects} formInputs 46 | * // 47 | * // @return {Object} 48 | * saveConfigureIntegrationSelections: , 49 | * 50 | * // Define context specific handlers. All handlers get passed 51 | * // the context (i.e. CALL_CONTEXT.DOCS) they were called from. 52 | * // 53 | * // Handler: 54 | * // @param {string} context 55 | * // 56 | * // @return {CardService.Card} 57 | * contextSpecificHandlers: { 58 | * [CALL_CONTEXT.XXX] : , 59 | * [CALL_CONTEXT.DEFAULT] : , // optional 60 | * } 61 | * } 62 | * 63 | */ 64 | const INTEGRATION_HOOKS = { 65 | [INTEGRATION_TYPE.RECORDS_BASED]: { 66 | printableString: 'Records Based', 67 | defaultIntegrationConfig: recordGetDefaultConfig, 68 | buildConfigureIntegrationCard: buildRecordsConfigureCard, 69 | saveConfigureIntegrationSelections: saveRecordsConfigureSelections, 70 | 71 | contextSpecificHandlers: { 72 | [CALL_CONTEXT.GMAIL_HOMEPAGE]: buildGmailHomepage, 73 | [CALL_CONTEXT.GMAIL_COMPOSE]: buildRecordCard, 74 | [CALL_CONTEXT.GMAIL_VIEW]: buildRecordCard, 75 | [CALL_CONTEXT.DOCS]: buildRecordCard, 76 | [CALL_CONTEXT.SHEETS]: buildRecordCard, 77 | }, 78 | }, 79 | 80 | [INTEGRATION_TYPE.GENERIC_SERVICE]: { 81 | printableString: 'Generic Service', 82 | defaultIntegrationConfig: serviceBasicGetDefaultConfig, 83 | buildConfigureIntegrationCard: buildServiceBasicConfigureCard, 84 | saveConfigureIntegrationSelections: saveServiceBasicConfigureSelections, 85 | 86 | // define context specific handlers. all handlers get passed 87 | // the context (i.e. CALL_CONTEXT.DOCS) they were called from. 88 | contextSpecificHandlers: { 89 | [CALL_CONTEXT.DEFAULT]: buildServiceBasicCard, 90 | }, 91 | }, 92 | 93 | [INTEGRATION_TYPE.FILE_REPOSITORY]: { 94 | printableString: 'File Repository', 95 | defaultIntegrationConfig: fileRepoGetDefaultConfig, 96 | buildConfigureIntegrationCard: buildFileRepoConfigureCard, 97 | saveConfigureIntegrationSelections: saveFileRepoConfigureSelections, 98 | 99 | // define context specific handlers. all handlers get passed 100 | // the context (i.e. CALL_CONTEXT.DOCS) they were called from. 101 | contextSpecificHandlers: { 102 | [CALL_CONTEXT.DRIVE] : buildFileRepoCard, 103 | }, 104 | }, 105 | 106 | [INTEGRATION_TYPE.IMAGE_LIBRARY]: { 107 | printableString: 'Image Library for Slides', 108 | defaultIntegrationConfig: imgLibraryGetDefaultConfig, 109 | buildConfigureIntegrationCard: buildImgLibraryConfigureCard, 110 | saveConfigureIntegrationSelections: saveImgLibraryConfigureSelections, 111 | 112 | // define context specific handlers. all handlers get passed 113 | // the context (i.e. CALL_CONTEXT.DOCS) they were called from. 114 | contextSpecificHandlers: { 115 | [CALL_CONTEXT.SLIDES] : buildImgLibraryCard, 116 | }, 117 | }, 118 | }; 119 | 120 | /** 121 | * Function that converts an Integration Type enum into a printable 122 | * string that can be shown to the user. 123 | * 124 | * @param {string} Record type (i.e. INTEGRATION_TYPE.RECORDS_BASED) 125 | * 126 | * @return {string} 127 | */ 128 | function integrationTypeToPrintableString(integrationType) { 129 | let integrationConfig = INTEGRATION_HOOKS[integrationType]; 130 | 131 | return integrationConfig.printableString; 132 | } 133 | 134 | /** 135 | * Function used to return the a formatted Card to be displayed. 136 | * The specific card returned will depend on the simulated integration type, 137 | * and the context it was called from. This function serves as a central 138 | * dispatch hub to generate most cards shown in the Add-on. 139 | * 140 | * @param {string} context Calling context (i.e. CALL_CONTEXT.GMAIL_MESSAGE) 141 | * 142 | * @return {string{CardService.Card} 143 | */ 144 | function buildIntegrationCard(context) { 145 | console.log('buildIntegrationCard: entering...'); 146 | 147 | let config = getConfig(); 148 | 149 | // if Odo yet to be configured, show splash screen 150 | if (!config || !config.saved) { 151 | return buildOdoWelcomeCard(); 152 | } else if (!config.welcomeSplashShown) { 153 | return buildCustomerToolWelcomeSplash(context); 154 | } 155 | 156 | console.log( 157 | 'buildIntegrationCard: context = ' + 158 | context + 159 | ', ' + 160 | 'integrationType = ' + 161 | config.integrationType 162 | ); 163 | 164 | let contextHandlers = 165 | INTEGRATION_HOOKS[config.integrationType].contextSpecificHandlers; 166 | 167 | if (contextHandlers.hasOwnProperty(context)) { 168 | return contextHandlers[context](context); 169 | } else if (contextHandlers.hasOwnProperty(CALL_CONTEXT.DEFAULT)) { 170 | return contextHandlers[CALL_CONTEXT.DEFAULT](context); 171 | } else { 172 | return buildContextNotSupportedCard(context); 173 | } 174 | } 175 | 176 | /** 177 | * Builds and returns a placeholder card to indicate that a particular 178 | * implementation has not yet been implemented. 179 | * 180 | * @return {CardService.Card} 181 | */ 182 | function buildIntegrationNotYetImplementedCard() { 183 | let card = CardService.newCardBuilder(); 184 | 185 | let brandedHeader = buildCustomerBrandedHeader(); 186 | card.setHeader(brandedHeader); 187 | 188 | let section = CardService.newCardSection(); 189 | let message = 'This integration has not yet been implemented.'; 190 | section.addWidget(CardService.newTextParagraph().setText(message)); 191 | 192 | card.addSection(section); 193 | 194 | return card; 195 | } 196 | 197 | /** 198 | * Builds and returns a placeholder card to indicate that a particular 199 | * implementation does not support any actions in the given context 200 | * 201 | * @param {string} context - The calling context (CALL_CONTEXT.XXX) 202 | * 203 | * @return {CardService.Card} 204 | */ 205 | function buildContextNotSupportedCard(context) { 206 | let card = CardService.newCardBuilder(); 207 | 208 | let brandedHeader = buildCustomerBrandedHeader(); 209 | card.setHeader(brandedHeader); 210 | 211 | let message = 'This integration does not support ' 212 | + `this context (${context}).`; 213 | 214 | let section = CardService.newCardSection(); 215 | section.addWidget(CardService.newTextParagraph().setText(message)); 216 | 217 | card.addSection(section); 218 | 219 | return card.build(); 220 | } 221 | 222 | /** 223 | * Builds and returns the card to customize the integration for the 224 | * particular integration type specified by the paramater integrationType. 225 | * As part of this, calls the 'buildConfigureIntegrationCard' function defined 226 | * for the integration. 227 | * 228 | * This function is called from config.gs, but can also be called by 229 | * specific integrations as a means to refresh their configuration card (see 230 | * integrationTypeRecords.gs for an example). 231 | * 232 | * @param {string} integrationType - The selected integration type 233 | * 234 | * @return {CardService.Card} 235 | */ 236 | function buildCustomizeIntegrationCard(integrationType) { 237 | // call the integration specific hook (function) to 238 | // generate the card to configure the specific integration 239 | let hooks = INTEGRATION_HOOKS[integrationType]; 240 | let card = hooks.buildConfigureIntegrationCard(); 241 | 242 | let subTitle = `${integrationTypeToPrintableString(integrationType)}`; 243 | let header = CardService.newCardHeader() 244 | .setTitle(`Customize Integration`) 245 | .setSubtitle(subTitle) 246 | .setImageStyle(CardService.ImageStyle.SQUARE) 247 | .setImageUrl(ODO_ICON); 248 | 249 | card.setHeader(header); 250 | 251 | // Set up params to pass to save handler when user clicks "Done" 252 | let params = { 253 | selectedIntegrationType: integrationType, 254 | }; 255 | 256 | let doneAction = CardService.newAction() 257 | .setFunctionName('saveOdoIntegrationCustomizationSelections') 258 | .setParameters(params); 259 | 260 | let footer = CardService.newFixedFooter(); 261 | footer.setPrimaryButton( 262 | CardService.newTextButton().setText('← Done').setOnClickAction(doneAction) 263 | ); 264 | card.setFixedFooter(footer); 265 | 266 | return card.build(); 267 | } 268 | 269 | /** 270 | * Saves the integration specific configurations when user clicks the 271 | * '← Done' button in the configuration card. Does this by calling the 272 | * 'saveConfigureIntegrationSelections' function defined for the selected 273 | * integration type. 274 | * 275 | * Note: The data returned from 'saveConfigureIntegrationsSelections' is 276 | * saved in the property PROP_STRINGIFIED_INTEGRATION_CUSTOMIZATIONS such that 277 | * it can be written to the 'integrationData' section of the config if/when the 278 | * user clicks 'Save' in the main configuration card. 279 | * 280 | * Returns ActionResponse that causes card stack to pop back to main 281 | * configuration card. 282 | * 283 | * @param {Object} event 284 | * 285 | * @return {CardService.ActionResponse} 286 | */ 287 | function saveOdoIntegrationCustomizationSelections(event) { 288 | let selectedIntegrationType = event.parameters.selectedIntegrationType; 289 | let formInputs = event.commonEventObject.formInputs; 290 | 291 | // pop this integration customization card to reveal the 292 | // main Odo configuration card. 293 | let action = CardService.newActionResponseBuilder() 294 | .setNavigation( 295 | CardService.newNavigation().popToNamedCard('mainConfigurationCard') 296 | ) 297 | .setStateChanged(false) 298 | .build(); 299 | 300 | // call the integration specific handler to save the selections 301 | let hooks = INTEGRATION_HOOKS[selectedIntegrationType]; 302 | 303 | if (!hooks.saveConfigureIntegrationSelections) { 304 | return action; 305 | } 306 | 307 | let integrationData = hooks.saveConfigureIntegrationSelections(formInputs); 308 | 309 | // save the configuration settings the user has selected when they click 310 | // 'Done'. These will be saved as the integrationData portion of the 'config' 311 | // structure if/when the user clicks 'Save' on the main configuraion card. 312 | let up = PropertiesService.getUserProperties(); 313 | let integrationDataStr = JSON.stringify(integrationData); 314 | 315 | up.setProperty( 316 | PROP_STRINGIFIED_INTEGRATION_CUSTOMIZATIONS, 317 | integrationDataStr 318 | ); 319 | 320 | return action; 321 | } 322 | 323 | /** 324 | * Builds and returns a card that shows a welcome splash message 325 | * on first use of the integration after it's been configured. 326 | * 327 | * @param {String} context 328 | */ 329 | function buildCustomerToolWelcomeSplash(context) { 330 | let config = getConfig(); 331 | let card = CardService.newCardBuilder(); 332 | 333 | let brandedHeader = buildCustomerBrandedHeader(); 334 | card.setHeader(brandedHeader); 335 | 336 | let section = CardService.newCardSection(); 337 | 338 | section.addWidget(CardService.newImage().setImageUrl(config.customerLogoUrl)); 339 | 340 | let welcomeSplashMessage = findAndReplaceMergeKeys( 341 | config.welcomeSplashMessage 342 | ); 343 | section.addWidget( 344 | CardService.newTextParagraph().setText(welcomeSplashMessage) 345 | ); 346 | 347 | let params = { 348 | context: context, 349 | }; 350 | let action = CardService.newAction() 351 | .setFunctionName('_refreshIntegrationCard') 352 | .setParameters(params); 353 | 354 | section.addWidget( 355 | CardService.newTextButton() 356 | .setText('Get Started') 357 | .setOnClickAction(action) 358 | ); 359 | 360 | card.addSection(section); 361 | 362 | return card.build(); 363 | } 364 | 365 | /** 366 | * Returns the integrationData stored in the configuration for the 367 | * given integration type passed. If the specific integration hasn't been 368 | * configured yet, then returns the defaults. 369 | * 370 | * @param {String} integrationType 371 | * 372 | * @return {Object} 373 | */ 374 | function getConfigIntegrationData(integrationType) { 375 | let integrationData; 376 | 377 | let config = getConfig(); 378 | let hooks = INTEGRATION_HOOKS[integrationType]; 379 | 380 | // if there is previously stored data for this integration type (i.e. user 381 | // clicked "Save" on "Configure Odo" card), then display it. 382 | // else, show the default values. 383 | if ( 384 | config.saved && 385 | config.integrationType === integrationType 386 | ) { 387 | integrationData = config.integrationData; 388 | } else { 389 | integrationData = hooks.defaultIntegrationConfig(); 390 | } 391 | 392 | return integrationData; 393 | } 394 | 395 | /** 396 | * Returns an ActionResponse that causes the current card to be updated 397 | * with the integration card for the specified context. The context is passed 398 | * in via an event parameter. 399 | * 400 | * @param {Object} event 401 | * 402 | * @return {CardService.ActionResponse} 403 | */ 404 | function _refreshIntegrationCard(event) { 405 | let context = event.parameters.context; 406 | let config = getConfig(); 407 | 408 | config.welcomeSplashShown = true; 409 | saveConfig(config); 410 | 411 | let card = buildIntegrationCard(context); 412 | 413 | let action = CardService.newActionResponseBuilder() 414 | .setNavigation(CardService.newNavigation().updateCard(card)) 415 | .setStateChanged(false) 416 | .build(); 417 | 418 | return action; 419 | } 420 | -------------------------------------------------------------------------------- /integrationTypeRecords.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Code related to "records" that the Odo Add-on 3 | * may show in various contexts if the chosen integration type is 4 | * INTEGRATION_TYPE.RECORDS_BASED. 5 | */ 6 | 7 | /** 8 | * Types of Records that can be operated on (for RECORDS_BASED integration 9 | * types). 10 | * @enum {string} 11 | */ 12 | const RECORD_TYPE = { 13 | CUSTOMER: 'CUSTOMER', 14 | EMPLOYEE: 'EMPLOYEE', 15 | ASSET: 'ASSET', 16 | GENERIC: 'CUSTOM', 17 | }; 18 | 19 | /** 20 | * Allowed data types for Record fields. These tell Odo how to format and 21 | * display the record fields. 22 | * @enum {string} 23 | */ 24 | const RECORD_FIELD_TYPE = { 25 | TEXT: 'TEXT', // just generic text 26 | EMAIL: 'EMAIL', // an email address 27 | PERSON_ID: 'PERSON_ID', // some form of ID for an individual (i.e. name) 28 | ORG_ID: 'ORD_ID', // some form of ID for an org (i.e. company name) 29 | DATE_EPOCH_MS: 'DATE_EPOCH_MS', // an absolute date, in ms since epoch 30 | DATE_OFFSET_DAYS: 'DATE_OFFSET_DAYS', // offset from present date in days 31 | FILE_URL: 'FILE_URL', // link to a file (i.e. pdf stored in Drive), 32 | FOLDER_URL: 'FOLDER_URL', // link to a folder (i.e. Drive folder) 33 | }; 34 | 35 | const PROP_SELECTED_RECORD_TYPE = 'PROP_SELECTED_RECORD_TYPE'; 36 | 37 | /** 38 | * Function that returns default configuration fields and values for 39 | * an integration type of INTEGRATION_TYPE.RECORDS_BASED, to be stored 40 | * as the 'integrationData' field in 'config'. Called when setting 41 | * up default configuration. 42 | * 43 | * @return {Object} 44 | */ 45 | function recordGetDefaultConfig() { 46 | let defaultRecordType = RECORD_TYPE.CUSTOMER; 47 | 48 | return { 49 | // type of record 50 | type: defaultRecordType, 51 | 52 | // data associated with specific record (varies by record type). 53 | recordFields: _recordGetDefaultFields(defaultRecordType), 54 | }; 55 | } 56 | 57 | /** 58 | * Function used to return the default record data based on recordType. 59 | * 60 | * @return {Object} 61 | */ 62 | function _recordGetDefaultFields(recordType) { 63 | let recordFields; 64 | 65 | switch (recordType) { 66 | case RECORD_TYPE.ASSET: 67 | recordFields = [ 68 | ['Asset ID', '{{selectedText || CG51112}}', RECORD_FIELD_TYPE.TEXT], 69 | ['Asset Type', 'Laptop', RECORD_FIELD_TYPE.TEXT], 70 | ['Description', 'Chromebook', RECORD_FIELD_TYPE.TEXT], 71 | [ 72 | 'Allocated To', 73 | '{{senderEmail || road.runner@cymbal.dev}}', 74 | RECORD_FIELD_TYPE.EMAIL, 75 | ], 76 | ['Asset File', DEFAULT_RECORD_FILE, RECORD_FIELD_TYPE.FILE_URL], 77 | ]; 78 | break; 79 | 80 | case RECORD_TYPE.EMPLOYEE: 81 | recordFields = [ 82 | [ 83 | 'Name', 84 | '{{senderName || Wile E. Coyote}}', 85 | RECORD_FIELD_TYPE.PERSON_ID, 86 | ], 87 | [ 88 | 'Email', 89 | '{{senderEmail || wile.e.coyote@acme.com}}', 90 | RECORD_FIELD_TYPE.EMAIL, 91 | ], 92 | ['Manager Email', 'road.runner@cymbal.dev', RECORD_FIELD_TYPE.EMAIL], 93 | ['Employee ID', '{{selectedText || EID54521}}', RECORD_FIELD_TYPE.TEXT], 94 | ['Company Start Date', 1641816000000, RECORD_FIELD_TYPE.DATE_EPOCH_MS], 95 | ['Contract File', DEFAULT_RECORD_FILE, RECORD_FIELD_TYPE.FILE_URL], 96 | ]; 97 | break; 98 | 99 | case RECORD_TYPE.CUSTOMER: 100 | default: 101 | recordFields = [ 102 | ['Customer Name', 'ACME Corp', RECORD_FIELD_TYPE.ORG_ID], 103 | ['Customer ID', '{{selectedText || C121GW}}', RECORD_FIELD_TYPE.TEXT], 104 | [ 105 | 'Contact Name', 106 | '{{senderName || Wile E. Coyote}}', 107 | RECORD_FIELD_TYPE.PERSON_ID, 108 | ], 109 | [ 110 | 'Contact Email', 111 | '{{senderEmail || wile.e.coyote@acme.com}}', 112 | RECORD_FIELD_TYPE.EMAIL, 113 | ], 114 | ['Last Emailed Date', -1, RECORD_FIELD_TYPE.DATE_OFFSET_DAYS], 115 | ['Last Email Subject', 'Order for Bird Trap', RECORD_FIELD_TYPE.TEXT], 116 | ['Contract File', DEFAULT_RECORD_FILE, RECORD_FIELD_TYPE.FILE_URL], 117 | ]; 118 | break; 119 | } 120 | 121 | return recordFields; 122 | } 123 | 124 | /** 125 | * Function used to return the record data as a formatted Card to be 126 | * displayed. Called from integrationTypeAll.gs as a context specific 127 | * handler for this integration. 128 | * 129 | * @param {string} Calling context (i.e. CALL_CONTEXT.GMAIL_VIEW) 130 | * 131 | * @return {CardService.Card} 132 | */ 133 | function buildRecordCard(context) { 134 | let config = getConfig(); 135 | 136 | let senderEmail = undefined; 137 | let selectedText = ''; 138 | 139 | let kvPairs = getAllMergeKeyPairs(); 140 | 141 | if (kvPairs.hasOwnProperty('{{senderEmail}}')) { 142 | senderEmail = kvPairs['{{senderEmail}}']; // only def for Gmail contexts 143 | } 144 | if (kvPairs.hasOwnProperty('{{selectedText}}')) { 145 | selectedText = kvPairs['{{selectedText}}']; // only def for Editors 146 | } 147 | 148 | if (!selectedText) { 149 | if (context === CALL_CONTEXT.DOCS) { 150 | return buildNoSelectionRecordCard(context); 151 | } else if (context === CALL_CONTEXT.SHEETS) { 152 | return buildNoSelectionRecordCard(context); 153 | } 154 | } else { 155 | if (context === CALL_CONTEXT.SHEETS && isFreshSheetLoad()) { 156 | return buildNoSelectionRecordCard(context); 157 | } 158 | } 159 | 160 | let recordType = config.integrationData.type; 161 | let recordFields = config.integrationData.recordFields; 162 | 163 | let card = CardService.newCardBuilder(); 164 | 165 | let recordTypeString = recordTypeToPrintableString(recordType); 166 | 167 | let titleText; 168 | if (context === CALL_CONTEXT.GMAIL_COMPOSE) { 169 | titleText = 'Attach File From ' + recordTypeString + ' Record'; 170 | } else { 171 | titleText = 'View ' + recordTypeString + ' Record'; 172 | } 173 | 174 | card.setHeader( 175 | CardService.newCardHeader().setImageUrl(RECORD_ICON).setTitle(titleText) 176 | ); 177 | 178 | let peekHeader = CardService.newCardHeader() 179 | .setTitle(titleText) 180 | .setSubtitle('Click here to view last record') 181 | .setImageUrl(RECORD_ICON); 182 | 183 | card.setPeekCardHeader(peekHeader); 184 | 185 | let attachmentFieldName = ''; 186 | let section = CardService.newCardSection(); 187 | 188 | // output the different sections of the record 189 | for (let i = 0; i < recordFields.length; i++) { 190 | let recordEntry = recordFields[i]; 191 | let fieldName = recordEntry[0]; 192 | let fieldValue = recordEntry[1]; 193 | let fieldType = recordEntry[2]; 194 | let startIcon = _getStartIconForRecordFieldType(fieldType); 195 | 196 | // replace any merge keys 197 | fieldValue = findAndReplaceMergeKeys(fieldValue, { boldValue: true }); 198 | 199 | let displayValue = fieldValue; 200 | 201 | if (fieldType === RECORD_FIELD_TYPE.DATE_EPOCH_MS) { 202 | let msSinceEpoch = fieldValue; 203 | let targetDate = new Date(msSinceEpoch); 204 | displayValue = Utilities.formatDate(targetDate, 'GMT', config.dateFormat); 205 | } else if (fieldType === RECORD_FIELD_TYPE.DATE_OFFSET_DAYS) { 206 | let today = new Date(); 207 | let daysOffset = Number(fieldValue); 208 | 209 | let targetDateMS = today.getTime() + 24 * 3600000 * daysOffset; 210 | 211 | let targetDate = new Date(targetDateMS); 212 | displayValue = Utilities.formatDate(targetDate, 'GMT', config.dateFormat); 213 | } 214 | 215 | let widget = CardService.newDecoratedText() 216 | .setTopLabel(fieldName) 217 | .setStartIcon(startIcon) 218 | .setText(displayValue); 219 | 220 | if ( 221 | fieldType === RECORD_FIELD_TYPE.FILE_URL || 222 | fieldType === RECORD_FIELD_TYPE.FOLDER_URL 223 | ) { 224 | widget.setOpenLink(CardService.newOpenLink().setUrl(fieldValue)); 225 | attachmentFieldName = fieldName; 226 | attachmentFileUrl = fieldValue; 227 | } 228 | 229 | section.addWidget(widget); 230 | } 231 | 232 | card.addSection(section); 233 | 234 | if (context === CALL_CONTEXT.GMAIL_COMPOSE) { 235 | let footer = CardService.newFixedFooter(); 236 | 237 | let params = { 238 | fileUrl: attachmentFileUrl, 239 | fileName: `${attachmentFieldName} for ${senderEmail}`, 240 | }; 241 | 242 | let buttonAction = CardService.newAction() 243 | .setFunctionName('_attachFileToGmailMessage') 244 | .setParameters(params); 245 | 246 | let buttonText = `ATTACH ${attachmentFieldName.toUpperCase()} TO EMAIL`; 247 | footer.setPrimaryButton( 248 | CardService.newTextButton() 249 | .setText(buttonText) 250 | .setOnClickAction(buttonAction) 251 | ); 252 | 253 | card.setFixedFooter(footer); 254 | } else if (context === CALL_CONTEXT.DOCS || context === CALL_CONTEXT.SHEETS) { 255 | let sections = buildRecordActionSections(context); 256 | for (var i = 0; i < sections.length; i++) { 257 | card.addSection(sections[i]); 258 | } 259 | } 260 | 261 | return card.build(); 262 | } 263 | 264 | /** 265 | * Returns card to show in an editors context when no document text 266 | * has yet been selected. 267 | * 268 | * @return {CardService.Card} 269 | */ 270 | function buildNoSelectionRecordCard(context) { 271 | let card = CardService.newCardBuilder(); 272 | 273 | let brandedHeader = buildCustomerBrandedHeader(); 274 | card.setHeader(brandedHeader); 275 | 276 | let opts = {}; 277 | 278 | if (context === CALL_CONTEXT.SHEETS) { 279 | opts.showGenerateReport = true; 280 | } 281 | 282 | let sections = buildRecordActionSections(context, opts); 283 | for (var i = 0; i < sections.length; i++) { 284 | card.addSection(sections[i]); 285 | } 286 | 287 | return card.build(); 288 | } 289 | 290 | /** 291 | * Returns one or more sections to be displayed that contain instructions 292 | * and/or buttons for the user to take action with (i.e. look up a record) 293 | * @return {[CardService.CardSection]} 294 | */ 295 | function buildRecordActionSections(context, opts) { 296 | let section = CardService.newCardSection(); 297 | let section2 = CardService.newCardSection(); 298 | 299 | let selectInstructionText; 300 | 301 | if (context === CALL_CONTEXT.DOCS) { 302 | selectInstructionText = 'select text in this document'; 303 | } else if (context === CALL_CONTEXT.SHEETS) { 304 | selectInstructionText = 'select a cell in this sheet'; 305 | } 306 | 307 | let instructions = 308 | 'To lookup a record, ' + 309 | `${selectInstructionText}` + 310 | ' related to a record and click the button below.'; 311 | 312 | section.addWidget(CardService.newTextParagraph().setText(instructions)); 313 | let buttonAction = CardService.newAction(); 314 | 315 | if (context === CALL_CONTEXT.DOCS) { 316 | buttonAction.setFunctionName('buildDocsKeyOnSelectedTextCard'); 317 | } else if (context === CALL_CONTEXT.SHEETS) { 318 | buttonAction.setFunctionName('buildSheetsKeyOnSelectedCellCard'); 319 | } 320 | 321 | let buttonText = 'Lookup Record'; 322 | let button = CardService.newTextButton() 323 | .setText(buttonText) 324 | .setOnClickAction(buttonAction); 325 | section.addWidget(button); 326 | 327 | returnSections = [section]; 328 | 329 | if (context === CALL_CONTEXT.SHEETS) { 330 | if (opts && opts.showGenerateReport) { 331 | let instructions = 332 | 'Or use the button below to ' + 'start a quick report:'; 333 | section2 = CardService.newCardSection(); 334 | section2.addWidget(CardService.newTextParagraph().setText(instructions)); 335 | 336 | let buttonAction2 = CardService.newAction(); 337 | buttonAction2.setFunctionName('buildSheetsReportGeneratorCard'); 338 | 339 | let buttonText2 = 'Start Report'; 340 | let button2 = CardService.newTextButton() 341 | .setText(buttonText2) 342 | .setOnClickAction(buttonAction2); 343 | section2.addWidget(button2); 344 | 345 | returnSections.push(section2); 346 | } 347 | } 348 | 349 | return returnSections; 350 | } 351 | 352 | /** 353 | * Generates a card from which a user can generate a report with data 354 | * written into a new sheet. 355 | * 356 | * @return {CardService.Card} 357 | */ 358 | function buildSheetsReportGeneratorCard() { 359 | let config = getConfig(); 360 | let recordType = config.integrationData.type; 361 | let recordTypeString = recordTypeToPrintableString(recordType); 362 | 363 | let card = CardService.newCardBuilder(); 364 | 365 | let brandedHeader = buildCustomerBrandedHeader(); 366 | card.setHeader(brandedHeader); 367 | 368 | let instructions = 369 | `Configure your ${recordTypeString} report using ` + 370 | 'the options below. Once ready, ' + 371 | 'click the button below.'; 372 | 373 | let section = CardService.newCardSection(); 374 | 375 | section.addWidget(CardService.newTextParagraph().setText(instructions)); 376 | 377 | let dateWidget1 = CardService.newDatePicker() 378 | .setTitle('Report Start Date') 379 | .setFieldName('startDate'); 380 | 381 | section.addWidget(dateWidget1); 382 | 383 | let dateWidget2 = CardService.newDatePicker() 384 | .setTitle('Report End Date') 385 | .setFieldName('endDate'); 386 | 387 | section.addWidget(dateWidget2); 388 | 389 | let buttonAction = CardService.newAction(); 390 | buttonAction.setFunctionName('createReportData'); 391 | 392 | let buttonText = `Generate ${recordTypeString} Report`; 393 | let button = CardService.newTextButton() 394 | .setText(buttonText) 395 | .setOnClickAction(buttonAction); 396 | section.addWidget(button); 397 | 398 | card.addSection(section); 399 | 400 | return card.build(); 401 | } 402 | 403 | /** 404 | * Writes report data into a new sheet in the active Spreadsheet. Returns an 405 | * ActionResponse to load the card that displays the record. 406 | * 407 | * @return {CardService.ActionResponse} 408 | */ 409 | function createReportData() { 410 | let ss = SpreadsheetApp.getActiveSpreadsheet(); 411 | let config = getConfig(); 412 | let sheet = ss.insertSheet(); 413 | 414 | recordFields = config.integrationData.recordFields; 415 | let headerRow = []; 416 | for (var i = 0; i < recordFields.length; i++) { 417 | headerRow.push(recordFields[i][0]); // field name 418 | } 419 | sheet.appendRow(headerRow); 420 | 421 | let numRows = 10; 422 | 423 | for (var i = 0; i < numRows; i++) { 424 | let row = []; 425 | for (var j = 0; j < recordFields.length; j++) { 426 | let type = recordFields[j][2]; 427 | let value = recordFields[j][1]; 428 | 429 | if (type === RECORD_FIELD_TYPE.DATE_EPOCH_MS) { 430 | let msSinceEpoch = fieldValue; 431 | let targetDate = new Date(msSinceEpoch); 432 | value = Utilities.formatDate(targetDate, 'GMT', config.dateFormat); 433 | } else if (type === RECORD_FIELD_TYPE.DATE_OFFSET_DAYS) { 434 | let today = new Date(); 435 | let daysOffset = Number(value); 436 | let targetDateMS = today.getTime() + 24 * 3600000 * daysOffset; 437 | let targetDate = new Date(targetDateMS); 438 | value = Utilities.formatDate(targetDate, 'GMT', config.dateFormat); 439 | } else if ( 440 | typeof value === 'string' && 441 | value.includes('{{selectedText') 442 | ) { 443 | value = getDefaultMergeValue(value); 444 | value += 'R' + (i + 101); 445 | } else { 446 | value = findAndReplaceMergeKeys(value); 447 | } 448 | 449 | row.push(value); 450 | } 451 | 452 | sheet.appendRow(row); 453 | } 454 | 455 | let card = buildRecordCard(CALL_CONTEXT.SHEETS); 456 | 457 | let actionResponse = CardService.newActionResponseBuilder() 458 | .setNavigation(CardService.newNavigation().updateCard(card)) 459 | .setStateChanged(false) 460 | .build(); 461 | 462 | return actionResponse; 463 | } 464 | 465 | /** 466 | * Creates and returns the card that gives the user options to configure 467 | * the Records integration. Called from integrationTypeAll.gs based on the 468 | * value of the 'buildConfigureIntegrationCard' parameter. 469 | * 470 | * @return {CardService.Card} 471 | */ 472 | function buildRecordsConfigureCard() { 473 | let config = getConfig(); 474 | let up = PropertiesService.getUserProperties(); 475 | 476 | // Reads the currently selected record type from the 477 | // PROP_SELECTED_RECORD_TYPE property. This is used to facilitate 478 | // refreshing the card when the user selectes a different record type 479 | // from the drop-down selector. 480 | let selectedRecordType = up.getProperty(PROP_SELECTED_RECORD_TYPE); 481 | 482 | if (!selectedRecordType) { 483 | if ( 484 | config.saved && 485 | config.integrationType === INTEGRATION_TYPE.RECORDS_BASED 486 | ) { 487 | selectedRecordType = config.integrationData.type; 488 | } else { 489 | selectedRecordType = RECORD_TYPE.CUSTOMER; // default 490 | } 491 | 492 | up.setProperty(PROP_SELECTED_RECORD_TYPE, selectedRecordType); 493 | } 494 | 495 | let card = CardService.newCardBuilder(); 496 | 497 | let recordTypeSection = CardService.newCardSection(); 498 | 499 | let params = { 500 | selectedIntegrationType: INTEGRATION_TYPE.RECORDS_BASED, 501 | }; 502 | let onChangeAction = CardService.newAction() 503 | .setFunctionName('_refreshRecordsConfigureCard') 504 | .setParameters(params); 505 | 506 | let selectRecordTypeWidget = CardService.newSelectionInput() 507 | .setFieldName('selectedRecordType') 508 | .setType(CardService.SelectionInputType.DROPDOWN) 509 | .setTitle('Record Type') 510 | .setOnChangeAction(onChangeAction); 511 | 512 | _addRecordTypeSelectionToDropdown( 513 | selectedRecordType, 514 | selectRecordTypeWidget, 515 | RECORD_TYPE.CUSTOMER 516 | ); 517 | _addRecordTypeSelectionToDropdown( 518 | selectedRecordType, 519 | selectRecordTypeWidget, 520 | RECORD_TYPE.ASSET 521 | ); 522 | _addRecordTypeSelectionToDropdown( 523 | selectedRecordType, 524 | selectRecordTypeWidget, 525 | RECORD_TYPE.EMPLOYEE 526 | ); 527 | 528 | recordTypeSection.addWidget(selectRecordTypeWidget); 529 | 530 | card.addSection(recordTypeSection); 531 | 532 | recordFieldsSection = _buildRecordFieldsSection(selectedRecordType); 533 | 534 | card.addSection(recordFieldsSection); 535 | 536 | return card; 537 | } 538 | 539 | /** 540 | * Function that causes the configuration card to be refreshed. Called when the 541 | * record type is selected/changes in the drop-down selector. 542 | * 543 | * @param {Object} event 544 | * 545 | * @return {CardService.ActionResponse} 546 | */ 547 | function _refreshRecordsConfigureCard(event) { 548 | // user selected a different record type from the drop-down. 549 | // store their new selection and then reload this card. 550 | let formInputs = event.commonEventObject.formInputs; 551 | let selectedIntegrationType = event.parameters.selectedIntegrationType; 552 | 553 | let up = PropertiesService.getUserProperties(); 554 | 555 | let selectedRecordType = formInputs.selectedRecordType.stringInputs.value[0]; 556 | 557 | up.setProperty(PROP_SELECTED_RECORD_TYPE, selectedRecordType); 558 | 559 | // DAA TODO: I don't like that here, inside the implementation of a specific 560 | // integration, we're reaching back out to 'buildCustomizeIntegrationCard', 561 | // which is part of the higher-level integration framework. It breaks the 562 | // clean separation that otherwise exists between specific integration 563 | // implentations and the main Odo framework. Perhaps think of a better way 564 | // to accomplish this, or at least clearly document how/why/when to use it. 565 | let refreshedCard = buildCustomizeIntegrationCard(selectedIntegrationType); 566 | let actionResponse = CardService.newActionResponseBuilder() 567 | .setNavigation(CardService.newNavigation().updateCard(refreshedCard)) 568 | .setStateChanged(false) 569 | .build(); 570 | 571 | return actionResponse; 572 | } 573 | 574 | /** 575 | * Function that gets called for this particular integration when user 576 | * clicks '← Done' button in integration configuration card. Saves the 577 | * selections and returns them as an object to be stored in the 578 | * 'integrationData' field of the config object if/when the user saves their 579 | * configurations. 580 | * 581 | * This is the handler that's defined as 'saveConfigureIntegrationSelections' 582 | * in integrationTypeAll.gs. 583 | * 584 | * @param {object} formInputs - Contains user selections 585 | * 586 | * @return {object} 587 | */ 588 | function saveRecordsConfigureSelections(formInputs) { 589 | let up = PropertiesService.getUserProperties(); 590 | 591 | let integrationData = {}; 592 | 593 | integrationData.type = formInputs.selectedRecordType.stringInputs.value[0]; 594 | 595 | integrationData.recordFields = []; 596 | 597 | for (key in formInputs) { 598 | if (!formInputs.hasOwnProperty(key)) { 599 | continue; 600 | } 601 | 602 | let fieldNameKey = key.split('fieldValueId---'); 603 | if (fieldNameKey.length !== 2) { 604 | continue; 605 | } 606 | 607 | let fieldName = fieldNameKey[1]; 608 | let typeKey = 'fieldTypeId---' + fieldName; 609 | 610 | let fieldValue = formInputs[key].stringInputs.value[0]; 611 | 612 | let fieldType = formInputs[typeKey].stringInputs.value[0]; 613 | 614 | integrationData.recordFields.push([fieldName, fieldValue, fieldType]); 615 | } 616 | 617 | // clear selected record type for next time 618 | up.deleteProperty(PROP_SELECTED_RECORD_TYPE); 619 | 620 | return integrationData; 621 | } 622 | 623 | /** 624 | * Adds the given record type to the drop-down widget passed. 625 | * 626 | * @param {text} selectedRecordType - Current selection to show as default 627 | * @param {CardService.SelectionInput} selectedRecordTypeWidget 628 | * @param {text} recordType - Record type to add to drop-down 629 | */ 630 | function _addRecordTypeSelectionToDropdown( 631 | selectedRecordType, 632 | selectedRecordTypeWidget, 633 | recordType 634 | ) { 635 | let selected = false; 636 | 637 | if (selectedRecordType === recordType) { 638 | selected = true; 639 | } 640 | 641 | selectedRecordTypeWidget.addItem( 642 | recordTypeToPrintableString(recordType), 643 | recordType, 644 | selected 645 | ); 646 | } 647 | 648 | /** 649 | * @param {text} selectedRecordType - Current selection for record type 650 | * 651 | * Builds and returns a CardSection containing the configurable selections 652 | * for the selected record type. 653 | * 654 | * @return {CardService.CardSection} 655 | */ 656 | function _buildRecordFieldsSection(selectedRecordType) { 657 | let config = getConfig(); 658 | let section = CardService.newCardSection(); 659 | let recordFields; 660 | 661 | // if there is previously stored data for this integration type, 662 | // display it. else, show the default values. 663 | if ( 664 | config.saved && 665 | config.integrationType === INTEGRATION_TYPE.RECORDS_BASED && 666 | config.integrationData.type === selectedRecordType 667 | ) { 668 | recordFields = config.integrationData.recordFields; 669 | } else { 670 | recordFields = _recordGetDefaultFields(selectedRecordType); 671 | } 672 | 673 | for (let i = 0; i < recordFields.length; i++) { 674 | let fieldArray = recordFields[i]; 675 | let fieldName = fieldArray[0]; 676 | let fieldValue = fieldArray[1]; 677 | let fieldType = fieldArray[2]; 678 | 679 | let fieldID = 'fieldValueId---' + fieldName; 680 | let fieldValueInput = CardService.newTextInput() 681 | .setFieldName(fieldID) 682 | .setTitle(fieldName) 683 | .setValue(fieldValue); 684 | 685 | fieldID = 'fieldTypeId---' + fieldName; 686 | let fieldTypeSelect = CardService.newSelectionInput() 687 | .setFieldName(fieldID) 688 | .setType(CardService.SelectionInputType.DROPDOWN) 689 | .setTitle('Field Type'); 690 | 691 | _populateRecordFieldTypeSelect(fieldTypeSelect, fieldType); 692 | 693 | section.addWidget(fieldValueInput); 694 | section.addWidget(fieldTypeSelect); 695 | } 696 | 697 | return section; 698 | } 699 | 700 | /** 701 | * Populated a given drop-down widget with the possible record field types 702 | * (i.e. RECORD_FIELD_TYPE.XXX) that a user can select when configuring their 703 | * record type. 704 | * 705 | * @param {CardService.SelectionInput} dropdownWidget 706 | * @param {text} selectedFieldType - current selection to show as default 707 | */ 708 | function _populateRecordFieldTypeSelect(dropdownWidget, selectedFieldType) { 709 | dropdownWidget.addItem( 710 | RECORD_FIELD_TYPE.TEXT, 711 | RECORD_FIELD_TYPE.TEXT, 712 | RECORD_FIELD_TYPE.TEXT === selectedFieldType 713 | ); 714 | dropdownWidget.addItem( 715 | RECORD_FIELD_TYPE.EMAIL, 716 | RECORD_FIELD_TYPE.EMAIL, 717 | RECORD_FIELD_TYPE.EMAIL === selectedFieldType 718 | ); 719 | dropdownWidget.addItem( 720 | RECORD_FIELD_TYPE.PERSON_ID, 721 | RECORD_FIELD_TYPE.PERSON_ID, 722 | RECORD_FIELD_TYPE.PERSON_ID === selectedFieldType 723 | ); 724 | dropdownWidget.addItem( 725 | RECORD_FIELD_TYPE.ORG_ID, 726 | RECORD_FIELD_TYPE.ORG_ID, 727 | RECORD_FIELD_TYPE.ORG_ID === selectedFieldType 728 | ); 729 | dropdownWidget.addItem( 730 | RECORD_FIELD_TYPE.DATE_EPOCH_MS, 731 | RECORD_FIELD_TYPE.DATE_EPOCH_MS, 732 | RECORD_FIELD_TYPE.DATE_EPOCH_MS === selectedFieldType 733 | ); 734 | dropdownWidget.addItem( 735 | RECORD_FIELD_TYPE.DATE_OFFSET_DAYS, 736 | RECORD_FIELD_TYPE.DATE_OFFSET_DAYS, 737 | RECORD_FIELD_TYPE.DATE_OFFSET_DAYS === selectedFieldType 738 | ); 739 | dropdownWidget.addItem( 740 | RECORD_FIELD_TYPE.FILE_URL, 741 | RECORD_FIELD_TYPE.FILE_URL, 742 | RECORD_FIELD_TYPE.FILE_URL === selectedFieldType 743 | ); 744 | dropdownWidget.addItem( 745 | RECORD_FIELD_TYPE.FOLDER_URL, 746 | RECORD_FIELD_TYPE.FOLDER_URL, 747 | RECORD_FIELD_TYPE.FOLDER_URL === selectedFieldType 748 | ); 749 | } 750 | 751 | /** 752 | * Private function that returns a CardService.IconImage based on field 753 | * type within a Record. 754 | * 755 | * @return {CardService.IconImage} 756 | */ 757 | function _getStartIconForRecordFieldType(fieldType) { 758 | let startIcon = CardService.newIconImage(); 759 | let startIconUrl = ''; 760 | 761 | switch (fieldType) { 762 | case RECORD_FIELD_TYPE.EMAIL: 763 | startIconUrl = EMAIL_ICON; 764 | break; 765 | case RECORD_FIELD_TYPE.PERSON_ID: 766 | startIconUrl = PERSON_ICON; 767 | break; 768 | case RECORD_FIELD_TYPE.ORG_ID: 769 | startIconUrl = ORG_ICON; 770 | break; 771 | case RECORD_FIELD_TYPE.DATE_EPOCH_MS: 772 | case RECORD_FIELD_TYPE.DATE_OFFSET_DAYS: 773 | startIconUrl = DATE_ICON; 774 | break; 775 | case RECORD_FIELD_TYPE.FILE_URL: 776 | startIconUrl = FILE_ICON; 777 | break; 778 | case RECORD_FIELD_TYPE.FOLDER_URL: 779 | startIconUrl = FOLDER_ICON; 780 | break; 781 | case RECORD_FIELD_TYPE.TEXT: 782 | default: 783 | startIconUrl = TEXT_ICON; 784 | break; 785 | } 786 | 787 | startIcon.setIconUrl(startIconUrl); 788 | 789 | return startIcon; 790 | } 791 | 792 | /** Function that converts a Record Type enum into a printable 793 | * string that can be shown to the user. 794 | * 795 | * @param {string} Record type (i.e. RECORD_TYPE.CUSTOMER) 796 | * 797 | * @return {string} 798 | */ 799 | function recordTypeToPrintableString(recordType) { 800 | let recordTypeString = ''; 801 | 802 | switch (recordType) { 803 | case RECORD_TYPE.CUSTOMER: 804 | recordTypeString = 'Customer'; 805 | break; 806 | case RECORD_TYPE.EMPLOYEE: 807 | recordTypeString = 'Employee'; 808 | break; 809 | case RECORD_TYPE.ASSET: 810 | recordTypeString = 'Asset'; 811 | break; 812 | } 813 | 814 | return recordTypeString; 815 | } 816 | 817 | /** 818 | * Action handler that inserts a file "attachment" into an email 819 | * when the record is being viewed in the Gmail compose context. 820 | * 821 | * @param {Object} Handler event 822 | * 823 | * @return {CardService.UpdateDraftActionResponse} 824 | */ 825 | function _attachFileToGmailMessage(event) { 826 | let fileUrl = event.parameters.fileUrl; 827 | let fileName = event.parameters.fileName; 828 | 829 | let textHtmlContent = 830 | '' + 831 | ` ` + 832 | `` + 833 | `${fileName}`; 834 | 835 | let response = CardService.newUpdateDraftActionResponseBuilder() 836 | .setUpdateDraftBodyAction( 837 | CardService.newUpdateDraftBodyAction() 838 | .addUpdateContent(textHtmlContent, CardService.ContentType.MUTABLE_HTML) 839 | .setUpdateType(CardService.UpdateDraftBodyType.IN_PLACE_INSERT) 840 | ) 841 | .build(); 842 | 843 | return response; 844 | } 845 | 846 | --------------------------------------------------------------------------------