├── .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 |
--------------------------------------------------------------------------------