Repository: googlesamples/apps-script Branch: main Commit: 8a31678d1d7e Files: 520 Total size: 1.2 MB Directory structure: gitextract_1q8uhr3u/ ├── .gemini/ │ ├── GEMINI.md │ ├── config.yaml │ └── settings.json ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE.md │ ├── linters/ │ │ ├── .htmlhintrc │ │ ├── .yaml-lint.yml │ │ └── sun_checks.xml │ ├── pull_request_template.md │ ├── scripts/ │ │ ├── biome-gs.ts │ │ ├── check-gs.ts │ │ └── clasp_push.sh │ ├── snippet-bot.yml │ ├── sync-repo-settings.yaml │ └── workflows/ │ ├── automation.yml │ ├── lint.yml │ ├── publish.yaml │ └── test.yml ├── .gitignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CONTRIBUTING.md ├── GEMINI.md ├── LICENSE ├── README.md ├── SECURITY.md ├── adminSDK/ │ ├── directory/ │ │ └── quickstart.gs │ ├── reports/ │ │ └── quickstart.gs │ └── reseller/ │ └── quickstart.gs ├── advanced/ │ ├── README.md │ ├── adminSDK.gs │ ├── adsense.gs │ ├── analytics.gs │ ├── analyticsAdmin.gs │ ├── analyticsData.gs │ ├── bigquery.gs │ ├── calendar.gs │ ├── chat.gs │ ├── classroom.gs │ ├── displayvideo.gs │ ├── docs.gs │ ├── doubleclick.gs │ ├── doubleclickbidmanager.gs │ ├── drive.gs │ ├── driveActivity.gs │ ├── driveLabels.gs │ ├── events.gs │ ├── gmail.gs │ ├── iot.gs │ ├── people.gs │ ├── sheets.gs │ ├── shoppingContent.gs │ ├── slides.gs │ ├── tagManager.gs │ ├── tasks.gs │ ├── test_adminSDK.gs │ ├── test_adsense.gs │ ├── test_analytics.gs │ ├── test_bigquery.gs │ ├── test_calendar.gs │ ├── test_classroom.gs │ ├── test_displayvideo.gs │ ├── test_docs.gs │ ├── test_doubleclick.gs │ ├── test_doubleclickbidmanager.gs │ ├── test_drive.gs │ ├── test_gmail.gs │ ├── test_people.gs │ ├── test_sheets.gs │ ├── test_shoppingContent.gs │ ├── test_slides.gs │ ├── test_tagManager.gs │ ├── test_tasks.gs │ ├── test_youtube.gs │ ├── test_youtubeAnalytics.gs │ ├── test_youtubeContentId.gs │ ├── youtube.gs │ ├── youtubeAnalytics.gs │ └── youtubeContentId.gs ├── ai/ │ ├── autosummarize/ │ │ ├── README.md │ │ ├── appsscript.json │ │ ├── gemini.js │ │ ├── main.js │ │ ├── sidebar.html │ │ └── summarize.js │ ├── custom-func-ai-agent/ │ │ ├── AiVertex.js │ │ ├── Code.js │ │ ├── README.md │ │ └── appsscript.json │ ├── custom-func-ai-studio/ │ │ ├── Code.js │ │ ├── README.md │ │ ├── appsscript.json │ │ └── gemini.js │ ├── custom_func_vertex/ │ │ ├── Code.js │ │ ├── README.md │ │ ├── aiVertex.js │ │ └── appsscript.json │ ├── devdocs-link-preview/ │ │ ├── Cards.js │ │ ├── Helpers.js │ │ ├── Main.js │ │ ├── README.md │ │ ├── Vertex.js │ │ └── appsscript.json │ ├── drive-rename/ │ │ ├── README.md │ │ ├── ai.js │ │ ├── appsscript.json │ │ ├── drive.js │ │ ├── main.js │ │ └── ui.js │ ├── email-classifier/ │ │ ├── Cards.gs │ │ ├── ClassifyEmail.gs │ │ ├── Code.gs │ │ ├── Constants.gs │ │ ├── DraftEmail.gs │ │ ├── Labels.gs │ │ ├── README.md │ │ ├── Sheet.gs │ │ └── appsscript.json │ ├── gmail-sentiment-analysis/ │ │ ├── Cards.gs │ │ ├── Code.gs │ │ ├── Gmail.gs │ │ ├── README.md │ │ ├── Vertex.gs │ │ └── appsscript.json │ └── standup-chat-app/ │ ├── README.md │ ├── appsscript.json │ ├── db.js │ ├── gemini.js │ ├── main.js │ └── memoize.js ├── apps-script/ │ └── execute/ │ └── target.js ├── biome.json ├── calendar/ │ └── quickstart/ │ └── quickstart.gs ├── chat/ │ ├── advanced-service/ │ │ ├── AppAuthenticationUtils.gs │ │ ├── Main.gs │ │ ├── README.md │ │ └── appsscript.json │ └── quickstart/ │ ├── Code.gs │ ├── README.md │ └── appsscript.json ├── classroom/ │ ├── quickstart/ │ │ └── quickstart.gs │ └── snippets/ │ ├── addAlias.gs │ ├── courseUpdate.gs │ ├── createAlias.gs │ ├── createCourse.gs │ ├── getCourse.gs │ ├── listCourses.gs │ ├── patchCourse.gs │ └── test_classroom_snippets.gs ├── data-studio/ │ ├── appsscript.json │ ├── appsscript2.json │ ├── auth.gs │ ├── build.gs │ ├── caas.gs │ ├── data-source.gs │ ├── errors.gs │ ├── links.gs │ ├── manifest.gs │ └── semantics.gs ├── docs/ │ ├── README.md │ ├── cursorInspector/ │ │ ├── README.md │ │ ├── cursorInspector.gs │ │ ├── sidebar.css.html │ │ ├── sidebar.html │ │ └── sidebar.js.html │ ├── dialog2sidebar/ │ │ ├── Code.gs │ │ ├── Dialog.html │ │ ├── Intercom.js.html │ │ ├── README.md │ │ └── Sidebar.html │ ├── quickstart/ │ │ └── quickstart.gs │ └── translate/ │ ├── README.md │ ├── sidebar.html │ └── translate.gs ├── drive/ │ ├── activity/ │ │ └── quickstart.gs │ ├── activity-v2/ │ │ └── quickstart.gs │ └── quickstart/ │ └── quickstart.gs ├── forms/ │ ├── README.md │ └── notifications/ │ ├── README.md │ ├── about.html │ ├── authorizationEmail.html │ ├── creatorNotification.html │ ├── notification.gs │ ├── respondentNotification.html │ └── sidebar.html ├── forms-api/ │ ├── demos/ │ │ └── AppsScriptFormsAPIWebApp/ │ │ ├── Code.gs │ │ ├── FormsAPI.gs │ │ ├── Main.html │ │ ├── README.md │ │ └── appsscript.json │ └── snippets/ │ ├── README.md │ └── retrieve_all_responses.gs ├── gmail/ │ ├── README.md │ ├── add-ons/ │ │ ├── appsscript.json │ │ └── quickstart.gs │ ├── inlineimage/ │ │ └── inlineimage.gs │ ├── markup/ │ │ ├── Code.gs │ │ └── mail_template.html │ ├── quickstart/ │ │ └── quickstart.gs │ └── sendingEmails/ │ └── sendingEmails.gs ├── gmail-sentiment-analysis/ │ ├── .clasp.json │ ├── Cards.gs │ ├── Code.gs │ ├── Gmail.gs │ ├── README.md │ ├── Vertex.gs │ └── appsscript.json ├── mashups/ │ ├── sheets2calendar.gs │ ├── sheets2chat.gs │ ├── sheets2contacts.gs │ ├── sheets2docs.gs │ ├── sheets2drive.gs │ ├── sheets2forms.gs │ ├── sheets2gmail.gs │ ├── sheets2maps.gs │ ├── sheets2slides.gs │ └── sheets2translate.gs ├── package.json ├── people/ │ └── quickstart/ │ └── quickstart.gs ├── picker/ │ ├── README.md │ ├── appsscript.json │ ├── code.gs │ └── dialog.html ├── pnpm-workspace.yaml ├── service/ │ ├── jdbc.gs │ ├── propertyService.gs │ ├── test_jdbc.gs │ └── test_propertyServices.gs ├── sheets/ │ ├── README.md │ ├── api/ │ │ ├── helpers.gs │ │ ├── spreadsheet_snippets.gs │ │ └── test_spreadsheet_snippets.gs │ ├── customFunctions/ │ │ ├── btc.gs │ │ └── customFunctions.gs │ ├── dateAddAndSubtract/ │ │ ├── README.md │ │ ├── dateAddAndSubtract.gs │ │ └── moment.gs │ ├── forms/ │ │ └── forms.gs │ ├── maps/ │ │ └── maps.gs │ ├── next18/ │ │ ├── .claspignore │ │ ├── Constants.gs │ │ ├── Invoice.gs │ │ ├── LinkDialog.html │ │ ├── README.md │ │ ├── Salesforce.gs │ │ └── appsscript.json │ ├── quickstart/ │ │ └── quickstart.gs │ └── removingDuplicates/ │ └── removingDuplicates.gs ├── slides/ │ ├── README.md │ ├── SpeakerNotesScript/ │ │ ├── README.md │ │ ├── appscript.json │ │ └── scriptGen.gs │ ├── api/ │ │ ├── Helpers.gs │ │ ├── Snippets.gs │ │ └── Tests.gs │ ├── imageSlides/ │ │ ├── add_image/ │ │ │ └── add_image.gs │ │ ├── add_image_slide/ │ │ │ └── add_image_slide.gs │ │ ├── create/ │ │ │ └── create.gs │ │ ├── full/ │ │ │ └── full.gs │ │ └── main/ │ │ └── main.gs │ ├── progress/ │ │ └── progress.gs │ ├── quickstart/ │ │ └── quickstart.gs │ ├── selection/ │ │ └── selection.gs │ ├── style/ │ │ ├── style.gs │ │ └── test_style.gs │ └── translate/ │ ├── sidebar.html │ └── translate.gs ├── solutions/ │ ├── add-on/ │ │ ├── book-smartchip/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ └── share-macro/ │ │ ├── .clasp.json │ │ ├── Code.js │ │ ├── README.md │ │ ├── UI.js │ │ └── appsscript.json │ ├── automations/ │ │ ├── agenda-maker/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── aggregate-document-content/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── Menu.js │ │ │ ├── README.md │ │ │ ├── Setup.js │ │ │ ├── Utilities.js │ │ │ └── appsscript.json │ │ ├── bracket-maker/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── calendar-timesheet/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── Page.html │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── content-signup/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── course-feedback-response/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── employee-certificate/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── equipment-requests/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ ├── appsscript.json │ │ │ ├── new-equipment-request.html │ │ │ └── request-complete.html │ │ ├── event-session-signup/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── feedback-sentiment-analysis/ │ │ │ ├── .clasp.json │ │ │ ├── README.md │ │ │ ├── appsscript.json │ │ │ └── code.js │ │ ├── folder-creation/ │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appscript.json │ │ ├── generate-pdfs/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── Menu.js │ │ │ ├── README.md │ │ │ ├── Utilities.js │ │ │ └── appsscript.json │ │ ├── import-csv-sheets/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ ├── SampleData.js │ │ │ ├── SetupSample.js │ │ │ ├── Utilities.js │ │ │ └── appsscript.json │ │ ├── mail-merge/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── news-sentiment/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── offsite-activity-signup/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── tax-loss-harvest-alerts/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── timesheets/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── upload-files/ │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ ├── Setup.js │ │ │ └── appsscript.json │ │ ├── vacation-calendar/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ └── youtube-tracker/ │ │ ├── .clasp.json │ │ ├── Code.js │ │ ├── README.md │ │ ├── appsscript.json │ │ └── email.html │ ├── custom-functions/ │ │ ├── calculate-driving-distance/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ ├── summarize-sheets-data/ │ │ │ ├── .clasp.json │ │ │ ├── Code.js │ │ │ ├── README.md │ │ │ └── appsscript.json │ │ └── tier-pricing/ │ │ ├── .clasp.json │ │ ├── Code.js │ │ ├── README.md │ │ └── appsscript.json │ ├── editor-add-on/ │ │ └── clean-sheet/ │ │ ├── .clasp.json │ │ ├── Code.js │ │ ├── Menu.js │ │ ├── README.md │ │ └── appsscript.json │ ├── ooo-assistant/ │ │ ├── .clasp.json │ │ ├── Chat.gs │ │ ├── Common.gs │ │ ├── README.md │ │ └── appsscript.json │ └── webhook-chat-app/ │ ├── README.md │ ├── thread-reply.gs │ └── webhook.gs ├── tasks/ │ ├── quickstart/ │ │ └── quickstart.gs │ └── simpleTasks/ │ ├── README.md │ ├── appsscript.json │ ├── javascript.html │ ├── page.html │ ├── simpleTasks.gs │ └── stylesheet.html ├── templates/ │ ├── README.md │ ├── custom-functions/ │ │ ├── Code.gs │ │ └── README.md │ ├── docs-addon/ │ │ ├── Code.gs │ │ ├── Dialog.html │ │ ├── DialogJavaScript.html │ │ ├── README.md │ │ ├── Sidebar.html │ │ ├── SidebarJavaScript.html │ │ └── Stylesheet.html │ ├── forms-addon/ │ │ ├── Code.gs │ │ ├── Dialog.html │ │ ├── DialogJavaScript.html │ │ ├── README.md │ │ ├── Sidebar.html │ │ ├── SidebarJavaScript.html │ │ └── Stylesheet.html │ ├── sheets-addon/ │ │ ├── Code.gs │ │ ├── Dialog.html │ │ ├── DialogJavaScript.html │ │ ├── README.md │ │ ├── Sidebar.html │ │ ├── SidebarJavaScript.html │ │ └── Stylesheet.html │ ├── sheets-import/ │ │ ├── APICode.gs │ │ ├── Auth.gs │ │ ├── AuthCallbackView.html │ │ ├── AuthorizationEmail.html │ │ ├── Configurations.gs │ │ ├── JavaScript.html │ │ ├── README.md │ │ ├── Server.gs │ │ ├── Sidebar.html │ │ ├── Stylesheet.html │ │ ├── Utilities.gs │ │ └── intercom.js.html │ ├── standalone/ │ │ └── helloWorld.gs │ └── web-app/ │ ├── Code.gs │ ├── Index.html │ ├── JavaScript.html │ ├── README.md │ └── Stylesheet.html ├── triggers/ │ ├── form/ │ │ ├── AuthorizationEmail.html │ │ └── Code.gs │ ├── test_triggers.gs │ └── triggers.gs ├── tsconfig.json ├── ui/ │ ├── communication/ │ │ ├── basic/ │ │ │ ├── code.gs │ │ │ └── index.html │ │ ├── failure/ │ │ │ ├── code.gs │ │ │ └── index.html │ │ ├── private/ │ │ │ ├── code.gs │ │ │ └── index.html │ │ ├── runner.gs │ │ └── success/ │ │ ├── code.gs │ │ └── index.html │ ├── dialogs/ │ │ ├── alert/ │ │ │ └── alert.gs │ │ ├── custom_dialog/ │ │ │ ├── Page.html │ │ │ └── custom_dialog.gs │ │ ├── custom_sidebar/ │ │ │ ├── Page.html │ │ │ └── custom_sidebar.gs │ │ ├── menus.gs │ │ └── prompt/ │ │ └── prompt.gs │ ├── forms/ │ │ ├── code.gs │ │ └── index.html │ ├── html/ │ │ ├── printing_scriptlet.html │ │ ├── scriptlet.html │ │ └── standard_scriptlet.html │ ├── sidebar/ │ │ ├── code.gs │ │ └── index.html │ ├── user/ │ │ ├── code.gs │ │ └── index.html │ └── webapp/ │ ├── code.gs │ └── index.html ├── utils/ │ ├── logging.gs │ └── test_logging.gs └── wasm/ ├── README.md ├── hello-world/ │ ├── .clasp.json │ ├── .gitattributes │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ ├── build.js │ ├── package.json │ ├── polyfill.js │ └── src/ │ ├── appsscript.json │ ├── lib.rs │ ├── main.js │ ├── test.js │ └── wasm.js ├── image-add-on/ │ ├── .clasp.json │ ├── .gitattributes │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ ├── build.js │ ├── package.json │ ├── polyfill.js │ └── src/ │ ├── add-on.js │ ├── appsscript.json │ ├── lib.rs │ ├── main.js │ ├── test.js │ └── wasm.js └── python/ ├── .clasp.json ├── .gitattributes ├── .gitignore ├── Cargo.toml ├── README.md ├── build.js ├── package.json ├── polyfill.js └── src/ ├── appsscript.json ├── lib.rs ├── main.js ├── test.js └── wasm.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gemini/GEMINI.md ================================================ # Overview This codebase is part of the Google Workspace GitHub organization, https://github.com/googleworkspace. ## Style Guide Use open source best practices for code style and formatting with a preference for Google's style guides. ## Tools - Verify against Google Workspace documentation with the `workspace-developer` MCP server tools. - Use `gh` for GitHub interactions. ================================================ FILE: .gemini/config.yaml ================================================ # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Config for the Gemini Pull Request Review Bot. # https://github.com/marketplace/gemini-code-assist have_fun: false code_review: disable: false comment_severity_threshold: "HIGH" max_review_comments: -1 pull_request_opened: help: false summary: true code_review: true ignore_patterns: [] ================================================ FILE: .gemini/settings.json ================================================ { "mcpServers": { "workspace-developer": { "httpUrl": "https://workspace-developer.goog/mcp", "trust": true } }, "tools": { "allowed": [ "run_shell_command(pnpm install)", "run_shell_command(pnpm format)", "run_shell_command(pnpm lint)", "run_shell_command(pnpm check)", "run_shell_command(pnpm test)" ] } } ================================================ FILE: .github/CODEOWNERS ================================================ # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners .github/ @googleworkspace/workspace-devrel-dpe ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ # Summary TODO ## Expected Behavior Sample URL: Description: ## Actual Behavior ## Steps to Reproduce the Problem 1. 1. 1. ================================================ FILE: .github/linters/.htmlhintrc ================================================ { "tagname-lowercase": true, "attr-lowercase": true, "attr-value-double-quotes": true, "attr-value-not-empty": false, "attr-no-duplication": true, "doctype-first": false, "tag-pair": true, "tag-self-close": false, "spec-char-escape": false, "id-unique": true, "src-not-empty": true, "title-require": false, "alt-require": true, "doctype-html5": true, "id-class-value": false, "style-disabled": false, "inline-style-disabled": false, "inline-script-disabled": false, "space-tab-mixed-disabled": "space", "id-class-ad-disabled": false, "href-abs-or-rel": false, "attr-unsafe-chars": true, "head-script-disabled": false } ================================================ FILE: .github/linters/.yaml-lint.yml ================================================ # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. --- ########################################### # These are the rules used for # # linting all the yaml files in the stack # # NOTE: # # You can disable line with: # # # yamllint disable-line # ########################################### rules: braces: level: warning min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: 1 max-spaces-inside-empty: 5 brackets: level: warning min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: 1 max-spaces-inside-empty: 5 colons: level: warning max-spaces-before: 0 max-spaces-after: 1 commas: level: warning max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: disable comments-indentation: disable document-end: disable document-start: level: warning present: true empty-lines: level: warning max: 2 max-start: 0 max-end: 0 hyphens: level: warning max-spaces-after: 1 indentation: level: warning spaces: consistent indent-sequences: true check-multi-line-strings: false key-duplicates: enable line-length: level: warning max: 120 allow-non-breakable-words: true allow-non-breakable-inline-mappings: true new-line-at-end-of-file: disable new-lines: type: unix trailing-spaces: disable ================================================ FILE: .github/linters/sun_checks.xml ================================================ ================================================ FILE: .github/pull_request_template.md ================================================ # Description Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Is it been tested? - [ ] Development testing done ## Checklist - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have performed a peer-reviewed with team member(s) - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] Any dependent changes have been merged and published in downstream modules ================================================ FILE: .github/scripts/biome-gs.ts ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { exec } from "node:child_process"; import { readdirSync, renameSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; const execAsync = promisify(exec); async function findGsFiles( dir: string, fileList: string[] = [], ): Promise { const files = readdirSync(dir); for (const file of files) { const filePath = join(dir, file); if ( file === "node_modules" || file === ".git" || file === "dist" || file === "target" || file === "pkg" ) { continue; } const stat = statSync(filePath); if (stat.isDirectory()) { await findGsFiles(filePath, fileList); } else if (file.endsWith(".gs") && file !== "moment.gs") { fileList.push(filePath); } } return fileList; } async function main() { const command = process.argv[2]; // 'lint' or 'format' if (command !== "lint" && command !== "format") { console.error("Usage: tsx biome-gs.ts [lint|format]"); process.exit(1); } const rootDir = resolve("."); const gsFiles = await findGsFiles(rootDir); const renamedFiles: { oldPath: string; newPath: string }[] = []; const restoreFiles = () => { for (const { oldPath, newPath } of renamedFiles) { try { renameSync(newPath, oldPath); } catch (e) { console.error(`Failed to restore ${newPath} to ${oldPath}:`, e); } } renamedFiles.length = 0; }; process.on("SIGINT", () => { restoreFiles(); process.exit(1); }); process.on("SIGTERM", () => { restoreFiles(); process.exit(1); }); process.on("exit", restoreFiles); try { // 1. Rename .gs to .gs.js for (const gsFile of gsFiles) { const jsFile = `${gsFile}.js`; renameSync(gsFile, jsFile); renamedFiles.push({ oldPath: gsFile, newPath: jsFile }); } // 2. Run Biome const biomeArgs = command === "format" ? "check --write ." : "check ."; console.log(`Running biome ${biomeArgs}...`); try { const { stdout, stderr } = await execAsync( `pnpm exec biome ${biomeArgs}`, { cwd: rootDir }, ); if (stdout) console.log(stdout.replace(/\.gs\.js/g, ".gs")); if (stderr) console.error(stderr.replace(/\.gs\.js/g, ".gs")); } catch (e: unknown) { const err = e as { stdout?: string; stderr?: string }; if (err.stdout) console.log(err.stdout.replace(/\.gs\.js/g, ".gs")); if (err.stderr) console.error(err.stderr.replace(/\.gs\.js/g, ".gs")); // Don't exit yet, we need to restore files } } catch (err) { console.error("An error occurred:", err); } finally { restoreFiles(); // Remove listeners to avoid double-running or issues on exit process.removeAllListeners("exit"); process.removeAllListeners("SIGINT"); process.removeAllListeners("SIGTERM"); } } main().catch((err) => { console.error(err); process.exit(1); }); ================================================ FILE: .github/scripts/check-gs.ts ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /// import { exec } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; const execAsync = promisify(exec); const TEMP_ROOT = ".tsc_check"; interface Project { files: string[]; name: string; path: string; } interface CheckResult { name: string; success: boolean; output: string; } // Helper to recursively find all files with a specific extension function findFiles( dir: string, extension: string, fileList: string[] = [], ): string[] { const files = readdirSync(dir); for (const file of files) { if (file.endsWith(".js")) continue; const filePath = join(dir, file); const stat = statSync(filePath); if (stat.isDirectory()) { if (file !== "node_modules" && file !== ".git" && file !== TEMP_ROOT) { findFiles(filePath, extension, fileList); } } else if (file.endsWith(extension)) { fileList.push(filePath); } } return fileList; } // Find all directories containing appsscript.json function findProjectRoots(rootDir: string): string[] { return findFiles(rootDir, "appsscript.json").map((f) => dirname(f)); } function createProjects( rootDir: string, projectRoots: string[], allGsFiles: string[], ): Project[] { // Holds files that belong to a formal Apps Script project (defined by the presence of appsscript.json). const projectGroups = new Map(); // Holds "orphan" files that do not belong to any defined Apps Script project (no appsscript.json found). const looseGroups = new Map(); // Initialize project groups for (const p of projectRoots) { projectGroups.set(p, []); } for (const file of allGsFiles) { let assigned = false; let currentDir = dirname(file); while (currentDir.startsWith(rootDir) && currentDir !== rootDir) { if (projectGroups.has(currentDir)) { projectGroups.get(currentDir)?.push(file); assigned = true; break; } currentDir = dirname(currentDir); } if (!assigned) { const dir = dirname(file); if (!looseGroups.has(dir)) { looseGroups.set(dir, []); } looseGroups.get(dir)?.push(file); } } const projects: Project[] = []; projectGroups.forEach((files, dir) => { if (files.length > 0) { projects.push({ files, name: `Project: ${relative(rootDir, dir)}`, path: relative(rootDir, dir), }); } }); looseGroups.forEach((files, dir) => { if (files.length > 0) { projects.push({ files, name: `Loose Project: ${relative(rootDir, dir)}`, path: relative(rootDir, dir), }); } }); return projects; } async function checkProject( project: Project, rootDir: string, ): Promise { const projectNameSafe = project.name.replace(/[^a-zA-Z0-9]/g, "_"); const projectTempDir = join(TEMP_ROOT, projectNameSafe); // Synchronous setup is fine as it's fast and avoids race conditions on mkdir if we were sharing dirs (we aren't) mkdirSync(projectTempDir, { recursive: true }); for (const file of project.files) { const fileRelPath = relative(rootDir, file); const destPath = join(projectTempDir, fileRelPath.replace(/\.gs$/, ".js")); const destDir = dirname(destPath); mkdirSync(destDir, { recursive: true }); copyFileSync(file, destPath); } const tsConfig = { extends: "../../tsconfig.json", compilerOptions: { noEmit: true, allowJs: true, checkJs: true, typeRoots: [resolve(rootDir, "node_modules/@types")], }, include: ["**/*.js"], }; writeFileSync( join(projectTempDir, "tsconfig.json"), JSON.stringify(tsConfig, null, 2), ); try { await execAsync(`tsc -p \"${projectTempDir}\"`, { cwd: rootDir }); return { name: project.name, success: true, output: "" }; } catch (e) { const err = e as { stdout?: string; stderr?: string }; const rawOutput = (err.stdout ?? "") + (err.stderr || ""); const rewritten = rawOutput .split("\n") .map((line: string) => { if (line.includes(projectTempDir)) { let newLine = line.split(projectTempDir + sep).pop(); if (!newLine) { return line; } newLine = newLine.replace(/\.js(:|\()/g, ".gs$1"); return newLine; } return line; }) .join("\n"); return { name: project.name, success: false, output: rewritten }; } } async function main() { try { const rootDir = resolve("."); const args = process.argv.slice(2); const searchArg = args.find((arg) => arg !== "--"); // 1. Discovery const projectRoots = findProjectRoots(rootDir); const allGsFiles = findFiles(rootDir, ".gs"); // 2. Grouping const projects = createProjects(rootDir, projectRoots, allGsFiles); // 3. Filtering const projectsToCheck = projects.filter((p) => { return !searchArg || p.path.startsWith(searchArg); }); if (projectsToCheck.length === 0) { console.log("No projects found matching the search path."); return; } // 4. Setup if (existsSync(TEMP_ROOT)) { rmSync(TEMP_ROOT, { recursive: true, force: true }); } mkdirSync(TEMP_ROOT); console.log(`Checking ${projectsToCheck.length} projects in parallel...`); // 5. Parallel Execution const results = await Promise.all( projectsToCheck.map((p) => checkProject(p, rootDir)), ); // 6. Reporting let hasError = false; for (const result of results) { if (!result.success) { hasError = true; console.log(`\n--- Failed: ${result.name} ---`); console.log(result.output); } } if (hasError) { console.error("\nOne or more checks failed."); process.exit(1); } else { console.log("\nAll checks passed."); } } catch (err) { console.error("Unexpected error:", err); process.exit(1); } finally { if (existsSync(TEMP_ROOT)) { rmSync(TEMP_ROOT, { recursive: true, force: true }); } } } main(); ================================================ FILE: .github/scripts/clasp_push.sh ================================================ #! /bin/bash # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. export LC_ALL=C.UTF-8 export LANG=C.UTF-8 function contains_changes() { [[ "${*:2}" = "" ]] && return 0 for f in "${@:2}"; do case $(realpath "$f")/ in $(realpath "$1")/*) return 0;; esac done return 1 } changed_files=$(echo "${@:1}" | xargs realpath | xargs -I {} dirname {}| sort -u | uniq) dirs=() IFS=$'\n' read -r -d '' -a dirs < <( find . -name '.clasp.json' -exec dirname '{}' \; | sort -u | xargs realpath ) exit_code=0 for dir in "${dirs[@]}"; do pushd "${dir}" > /dev/null || exit contains_changes "$dir" "${changed_files[@]}" || continue echo "Publishing ${dir}" clasp push -f status=$? if [ $status -ne 0 ]; then exit_code=$status fi popd > /dev/null || exit done if [ $exit_code -ne 0 ]; then echo "Script push failed." fi exit $exit_code ================================================ FILE: .github/snippet-bot.yml ================================================ # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ================================================ FILE: .github/sync-repo-settings.yaml ================================================ # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # .github/sync-repo-settings.yaml # See https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings for app options. rebaseMergeAllowed: true squashMergeAllowed: true mergeCommitAllowed: false deleteBranchOnMerge: true branchProtectionRules: - pattern: main isAdminEnforced: false requiresStrictStatusChecks: false requiredStatusCheckContexts: # .github/workflows/test.yml with a job called "test" - "test" # .github/workflows/lint.yml with a job called "lint" - "lint" # Google bots below - "cla/google" - "snippet-bot check" - "header-check" - "conventionalcommits.org" requiredApprovingReviewCount: 1 requiresCodeOwnerReviews: true permissionRules: - team: workspace-devrel-dpe permission: admin - team: workspace-devrel permission: push ================================================ FILE: .github/workflows/automation.yml ================================================ # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. --- name: Automation on: [ push, pull_request, workflow_dispatch ] jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request' }} env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GOOGLEWORKSPACE_BOT_TOKEN}} steps: - name: approve run: gh pr review --approve "$PR_URL" - name: merge run: gh pr merge --auto --squash --delete-branch "$PR_URL" default-branch-migration: # this job helps with migrating the default branch to main # it pushes main to master if master exists and main is the default branch # it pushes master to main if master is the default branch runs-on: ubuntu-latest if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} steps: - uses: actions/checkout@v2 with: fetch-depth: 0 # required otherwise GitHub blocks infinite loops in pushes originating in an action token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} - name: Set env run: | # set DEFAULT BRANCH echo "DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')" >> "$GITHUB_ENV"; # set HAS_MASTER_BRANCH if [ -n "$(git ls-remote --heads origin master)" ]; then echo "HAS_MASTER_BRANCH=true" >> "$GITHUB_ENV" else echo "HAS_MASTER_BRANCH=false" >> "$GITHUB_ENV" fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: configure git run: | git config --global user.name 'googleworkspace-bot' git config --global user.email 'googleworkspace-bot@google.com' - if: ${{ env.DEFAULT_BRANCH == 'main' && env.HAS_MASTER_BRANCH == 'true' }} name: Update master branch from main run: | git checkout -B master git reset --hard origin/main git push origin master - if: ${{ env.DEFAULT_BRANCH == 'master'}} name: Update main branch from master run: | git checkout -B main git reset --hard origin/master git push origin main ================================================ FILE: .github/workflows/lint.yml ================================================ # Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. --- name: Lint on: push: branches: - main pull_request: jobs: lint: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}-${{ github.head_ref || github.ref }} cancel-in-progress: true runs-on: ubuntu-latest steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: cache: "pnpm" - run: pnpm i - run: pnpm lint ================================================ FILE: .github/workflows/publish.yaml ================================================ # Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. --- name: Publish Apps Script on: workflow_dispatch: push: branches: - main jobs: publish: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}-${{ github.head_ref || github.ref }} cancel-in-progress: true runs-on: ubuntu-latest steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: cache: "pnpm" - run: pnpm i - name: Get changed files id: changed-files uses: tj-actions/changed-files@v23.1 - name: Write test credentials run: | echo "${CLASP_CREDENTIALS}" > "${HOME}/.clasprc.json" env: CLASP_CREDENTIALS: ${{secrets.CLASP_CREDENTIALS}} - run: pnpm install -g @google/clasp - run: ./.github/scripts/clasp_push.sh ${{ steps.changed-files.outputs.all_changed_files }} ================================================ FILE: .github/workflows/test.yml ================================================ # Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. name: Test on: push: branches: - main pull_request: jobs: test: # temporarily use matrix until all are passing strategy: fail-fast: false matrix: folder: - adminSDK - advanced - ai - calendar - chat - classroom - data-studio - docs - drive - forms - forms-api - gmail - gmail-sentiment-analysis - mashups - people - picker - service - sheets - slides - solutions - tasks - templates - triggers - ui - utils concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}-${{ github.head_ref || github.ref }}-${{ matrix.folder }} cancel-in-progress: true runs-on: ubuntu-latest steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: cache: "pnpm" - run: pnpm i - run: pnpm check ${{ matrix.folder }} ================================================ FILE: .gitignore ================================================ .DS_Store node_modules .gradle **/dist **/node_modules **/target **/.tsc_check ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["google-workspace.google-workspace-developer-tools"] } ================================================ FILE: .vscode/settings.json ================================================ { "files.associations": { "*.gs": "javascript" } } ================================================ FILE: CONTRIBUTING.md ================================================ # How to become a contributor and submit your own code ## Contributor License Agreements We'd love to accept your sample apps and patches! Before we can take them, we have to jump a couple of legal hurdles. Please fill out either the individual or corporate Contributor License Agreement (CLA). * If you are an individual writing original source code and you're sure you own the intellectual property, then you'll need to sign an [individual CLA](https://developers.google.com/open-source/cla/individual). * If you work for a company that wants to allow you to contribute your work, then you'll need to sign a [corporate CLA](https://developers.google.com/open-source/cla/corporate). Follow either of the two links above to access the appropriate CLA and instructions for how to sign and return it. Once we receive it, we'll be able to accept your pull requests. ## Contributing A Patch 1. Submit an issue describing your proposed change to the repository in question. 1. The repository owner will respond to your issue promptly. 1. If your proposed change is accepted, and you haven't already done so, sign a Contributor License Agreement (see details above). 1. Fork the desired repository, develop and test your code changes. 1. Ensure that your code adheres to the existing style in the sample to which you are contributing. 1. Ensure that your code has an appropriate set of unit tests which all pass. 1. Run `pnpm check` to ensure there are no type errors or syntax issues in your `.gs` files. 1. Submit a pull request! ## Style Samples in this repository follow the [JavaScript Semi-Standard Style](https://github.com/Flet/semistandard). ================================================ FILE: GEMINI.md ================================================ # Apps Script Sample Development Guide This guide outlines best practices for developing Google Apps Script projects, focusing on type safety and modern JavaScript features. ## Important * For new sample directories, ensure the top-level folder is included in the [`test.yaml`](.github/workflows/test.yaml) GitHub workflow's matrix configuration. * Do not move or delete snippet tags: `[END apps_script_... ]` or `[END apps_script_... ]`. * Keep code within snippet tags self-contained. Avoid depending on helper functions defined outside the snippet tags if the snippet is intended to be copied and pasted. * Avoid function name collisions (e.g., multiple `onOpen` or `main` functions) by placing separate samples in their own directories or files. Do not append suffixes like `_2`, `_3` to function names. For variables, replace collisions with a more descriptive name. ## Tools Lint and format code using [Biome](https://biomejs.dev/). ```bash pnpm lint pnpm format ``` ## Apps Script Code Best Practices Apps Script supports the V8 runtime, which enables modern ECMAScript syntax. Using these features makes your code cleaner, more readable, and less error-prone. ### `let` and `const` Use `let` and `const` instead of `var` for block-scoped variables. * **`const`**: Use for values that should not be reassigned. * **`let`**: Use for values that will change. ```javascript const PI = 3.14; let count = 0; if (true) { let local = "I exist only in this block"; } // local is not accessible here ``` ### Arrow Functions Use arrow functions for concise function expressions, especially for callbacks. ```javascript const numbers = [1, 2, 3]; const squares = numbers.map(x => x * x); // [1, 4, 9] ``` ### Destructuring Unpack values from arrays or properties from objects into distinct variables. ```javascript const user = { name: "Alice", age: 30 }; const { name, age } = user; const coords = [10, 20]; const [x, y] = coords; ``` ### Template Literals Use template literals for string interpolation and multi-line strings. ```javascript const name = "World"; const greeting = `Hello, ${name}!`; const multiLine = ` This is a multi-line string. `; ``` ### Default Parameters Specify default values for function parameters. ```javascript function greet(name = "Guest") { console.log(`Hello, ${name}!`); } greet(); // "Hello, Guest!" ``` ### Prefer `for...of` for Iteration While `forEach` is convenient, `for...of` loops generally offer better performance and more control (e.g., `break`, `continue`) in Apps Script, especially when dealing with large arrays. ```javascript const numbers = [1, 2, 3]; // Using forEach (less performant for large arrays) numbers.forEach(num => { console.log(num); }); // Using for...of (preferred) for (const num of numbers) { console.log(num); } ``` ## Apps Script V8 Runtime It's important to understand that the Apps Script V8 runtime is not a standard Node.js or browser environment. This can lead to compatibility issues when incorporating third-party libraries or adapting code examples from other JavaScript environments. ### Unavailable APIs The following standard JavaScript APIs are **NOT** available in the Apps Script V8 runtime: * **Timers**: `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` * **Streams**: `ReadableStream`, `WritableStream`, `TextEncoder`, `TextDecoder` * **Web APIs**: `fetch`, `FormData`, `File`, `Blob`, `URL`, `URLSearchParams`, `DOMException`, `atob`, `btoa` * **Crypto**: `crypto`, `SubtleCrypto` * **Global Objects**: `window`, `navigator`, `performance`, `process` (Node.js) Instead of the unavailable APIs, you can use the following Apps Script APIs as alternatives: * **Timers**: Use [`Utilities.sleep(milliseconds)`](https://developers.google.com/apps-script/reference/utilities/utilities#sleepmilliseconds) for synchronous pauses. Asynchronous timers are not supported. * **Fetch**: Use [`UrlFetchApp.fetch(url, params)`](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app) to make HTTP(S) requests. * **atob**: Use [`Utilities.base64Decode()`](https://developers.google.com/apps-script/reference/utilities/utilities#base64decodeencoded) to decode Base64-encoded strings. * **btoa**: Use [`Utilities.base64Encode()`](https://developers.google.com/apps-script/reference/utilities/utilities#base64encodedata) to encode strings in Base64. * **Crypto**: Use [`Utilities`](https://developers.google.com/apps-script/reference/utilities/utilities) for cryptographic functions like [`computeDigest()`](https://developers.google.com/apps-script/reference/utilities/utilities#computedigestalgorithm,-value), [`computeHmacSha256Signature()`](https://developers.google.com/apps-script/reference/utilities/utilities#computehmacsha256signaturevalue,-key), and [`computeRsaSha256Signature()`](https://developers.google.com/apps-script/reference/utilities/utilities#computersasha256signaturevalue,-key). For some APIs, other workarounds might exist. For example, you might be able to use a polyfill for `TextEncoder`. ### Asynchronous Limitations The V8 runtime supports `async` and `await` syntax and the `Promise` object. However, the Apps Script runtime environment is fundamentally synchronous. * **Microtasks (Supported)**: The runtime processes the microtask queue (where `Promise.then()` callbacks and `await` resolutions occur) after the current call stack clears. * **Macrotasks (Not Supported)**: Apps Script does not have a standard event loop for macrotasks. Functions like `setTimeout()` and `setInterval()` are not available. * **WebAssembly Exception**: The WebAssembly API is the only built-in feature that operates in a non-blocking manner within the runtime, allowing for specific asynchronous compilation patterns (WebAssembly.instantiate). All I/O operations, such as [`UrlFetchApp.fetch()`](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app), are blocking. To achieve parallel network requests, use [`UrlFetchApp.fetchAll()`](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app#fetchallrequests). ### Class Limitations The V8 runtime has specific limitations regarding modern ES6+ class features: * **Private Fields**: Private class fields (for example, `#field`) are not supported and cause parsing errors. Consider using closures or `WeakMap` for true encapsulation. * **Static Fields**: Direct static field declarations within the class body (for example, `static count = 0;`) are not supported. Assign static properties to the class after its definition (for example, `MyClass.count = 0;`). ### Module Limitations * **ES6 Modules**: The V8 runtime does not support ES6 modules (`import` / `export`). To use libraries, you must either use the [ Apps Script library mechanism](https://developers.google.com/apps-script/guides/libraries) or bundle your code and its dependencies into a single script file. ([Issue Tracker](https://issuetracker.google.com/issues/134627726)) * **File Execution Order**: All script files in your project are executed in a global scope. It's best to avoid top-level code with side effects and ensure functions and classes are defined before being used across files. Explicitly order your files in the editor if dependencies exist between them. ## Type Checking with JSDoc This project uses a type checker to validate `.gs` files for errors. Since `.gs` files are technically JavaScript, we use JSDoc comments to provide type information. This ensures your code is type-safe and well-documented. ### Running Checks You can run the type checker from the root of the repository. **Check all projects:** ```bash pnpm run check ``` **Check a specific path:** To check only projects within a specific directory (e.g., `solutions/automations`), pass the path as an argument: ```bash pnpm run check solutions/automations ``` ### Core Concepts #### 1. Basic Types Use `@param` and `@return` to define function inputs and outputs. ```javascript /** * Adds two numbers. * @param {number} a The first number. * @param {number} b The second number. * @return {number} The sum. */ function add(a, b) { return a + b; } ``` #### 2. Apps Script Types You can reference global Apps Script types directly. ```javascript /** * Gets the active sheet name. * @return {string} The name of the sheet. */ function getSheetName() { // Types like SpreadsheetApp, Sheet, Range are available globally const sheet = SpreadsheetApp.getActiveSheet(); return sheet.getName(); } ``` #### 3. Optional Parameters Use `[]` or `=` to denote optional parameters. ```javascript /** * @param {string} name The name. * @param {number=} age Optional age. */ function greet(name, age) { if (age) { ... } } ``` ### Advanced Patterns #### 1. Custom Objects (@typedef) For complex objects, define a type using `@typedef`. ```javascript /** * @typedef {Object} UserConfig * @property {string} username The user's name. * @property {boolean} isAdmin Whether the user is an admin. * @property {number} [retryCount] Optional retry attempts. */ /** * Processes a user configuration. * @param {UserConfig} config The configuration object. */ function processUser(config) { console.log(config.username); } ``` #### 2. Type Casting Sometimes the type checker cannot infer the type correctly. Use inline `@type` to cast. ```javascript const data = JSON.parse(jsonString); /** @type {UserConfig} */ const config = data; ``` #### 3. Arrays and Generics Specify array contents clearly. ```javascript /** * @param {string[]} names An array of strings. * @return {Array} An array of numbers. */ function lengths(names) { return names.map(n => n.length); } ``` #### 4. Handling `null` and `undefined` Be explicit if a value can be null. ```javascript /** * @param {string|null} id The ID, or null if not found. */ function find(id) { ... } ``` ### Common Issues & Fixes - **TypeScript**: DO NOT REFERENCE GoogleAppsScript in JSDocs. Instead use a locally defined type definition and link to the appropriate reference documenation page if possible. - **"Property 'x' does not exist on type 'Object'"**: This usually means you are accessing a property on a generic object. Define a `@typedef` for that object structure. - **Implicit 'any'**: If you see "Parameter 'x' implicitly has an 'any' type", it means you forgot a JSDoc `@param` tag. Add it to fix the error. - **Advanced Services**: To fix errors with these globals, check for existence. This helps TypeScript narrow the type and prevents runtime errors if the service is not enabled. ```js if (!AdminDirectory) { console.log('AdminDirectory Advanced Service must be enabled.'); return; } ``` - **Optional Properties**: Use optional chaining (`?.`) when accessing properties that might be undefined in API responses. This is often the case when when using `fields` to limit the response. ```js // Safe access console.log(user.name?.fullName); ``` - **Error Handling**: Avoid wrapping code in `try/catch` blocks if you are only logging the error message. Let the runtime handle the error reporting for cleaner sample code. ```js // Avoid this try { AdminDirectory.Users.list(); } catch (err) { console.log(err.message); } // Prefer this AdminDirectory.Users.list(); ``` ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Google Apps Script Samples Various sample code and projects for the Google Apps Script platform, a JavaScript platform in the cloud. Learn more at [developers.google.com](https://developers.google.com/apps-script). ## Google APIs ### AdminSDK - [Manage domains and apps](adminSDK)

### Advanced Services - [Access Google APIs via Advanced Google services](advanced/)

### Calendar - [List upcoming events](calendar/quickstart) - [Create a vacation calendar](solutions/automations/vacation-calendar/Code.js) ### Classroom - [Manage Google Classroom](classroom/quickstart)

### Data Studio - [Build a connector](data-studio/build.gs) - [Authentication and Authorization](data-studio/auth.gs) ### Docs - [Cursor inspector add-on](docs/cursorInspector) - [Translate add-on](docs/translate) ### Drive - [Manage Google Drive files and folders](drive/quickstart) - [View Google Drive activity](drive/activity) ### Forms - [Notification add-on](forms)

### Gmail - [Sending email](gmail/sendingEmails) - [Mailmerge: Merge a template email with content](gmail/mailmerge) ### People - [Listing Connections](people/quickstart)

### Sheets - [Managing Responses for Google Forms](sheets) - [Menus and Custom Functions](sheets) ### Slides - [Translate Slides Add-on](slides/translate) - [Progress Bars add-on](slides/progress) ### Tasks - [List Tasks](tasks/quickstart) - [Simple Tasks Web App](tasks/simpleTasks) ### Templates - Build off a working framework for new Apps Script projects.

### Triggers - Call an Apps Script function such as `onOpen`, `onEdit`, or `onInstall` in an add-on - Create a [time-driven trigger](https://developers.google.com/apps-script/guides/triggers/installable#time_driven_triggers) ## Codelabs Codelab tutorials combine detailed explanation, coding exercises, and documented best practices to help engineers get up to speed with key Google technologies. Here's a list of Apps Script codelabs: - [Apps Script Intro](http://g.co/codelabs/apps-script-intro) - [Apps Script CLI – clasp](http://g.co/codelabs/clasp) - [BigQuery + Sheets + Slides](http://g.co/codelabs/bigquery-sheets-slides) - [Docs Add-on + Cloud Natural Language API](http://g.co/codelabs/nlp-docs) - [Gmail Add-ons](http://g.co/codelabs/gmail-add-ons) - [Google Chat Apps](https://developers.google.com/codelabs/chat-apps-script) ## Clone using the `clasp` command-line tool Learn how to clone, pull, and push Apps Script projects on the command-line using [clasp](https://developers.google.com/apps-script/guides/clasp). ## Lint Run ESLint over this whole repository with: ```shell pnpm lint ``` This command will fix simple errors. ## Type Checking Run the TypeScript-based check over the repository with: ```shell pnpm check ``` This command validates `.gs` files by temporarily converting them to `.js` and running `tsc`. It checks for syntax errors and type issues using JSDoc annotations. ================================================ FILE: SECURITY.md ================================================ # Report a security issue To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz). We use [https://g.co/vulnz](https://g.co/vulnz) for our intake, and do coordination and disclosure here on GitHub (including using GitHub Security Advisory). The Google Security Team will respond within 5 working days of your report on [https://g.co/vulnz](https://g.co/vulnz). ================================================ FILE: adminSDK/directory/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START admin_sdk_directory_quickstart] /** * Lists users in a Google Workspace domain. * @see https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list */ function listUsers() { const optionalArgs = { customer: "my_customer", maxResults: 10, orderBy: "email", }; if (!AdminDirectory || !AdminDirectory.Users) { throw new Error("Enable the AdminDirectory Advanced Service."); } const response = AdminDirectory.Users.list(optionalArgs); const users = response.users; if (!users || users.length === 0) { console.log("No users found."); return; } // Print the list of user's full name and email console.log("Users:"); for (const user of users) { if (user.primaryEmail) { if (user.name?.fullName) { console.log("%s (%s)", user.primaryEmail, user.name.fullName); } else { console.log("%s", user.primaryEmail); } } } } // [END admin_sdk_directory_quickstart] ================================================ FILE: adminSDK/reports/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START admin_sdk_reports_quickstart] /** * List login events for a Google Workspace domain. * @see https://developers.google.com/admin-sdk/reports/reference/rest/v1/activities/list */ function listLogins() { const userKey = "all"; const applicationName = "login"; const optionalArgs = { maxResults: 10, }; if (!AdminReports || !AdminReports.Activities) { throw new Error("Enable the AdminReports Advanced Service."); } const response = AdminReports.Activities.list( userKey, applicationName, optionalArgs, ); const activities = response.items; if (!activities || activities.length === 0) { console.log("No logins found."); return; } // Print login events console.log("Logins:"); for (const activity of activities) { if ( activity.id?.time && activity.actor?.email && activity.events?.[0]?.name ) { console.log( "%s: %s (%s)", activity.id.time, activity.actor.email, activity.events[0].name, ); } } } // [END admin_sdk_reports_quickstart] ================================================ FILE: adminSDK/reseller/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START admin_sdk_reseller_quickstart] /** * List Admin SDK reseller. * @see https://developers.google.com/admin-sdk/reseller/reference/rest/v1/subscriptions/list */ function listSubscriptions() { const optionalArgs = { maxResults: 10, }; if (!AdminReseller || !AdminReseller.Subscriptions) { throw new Error("Enable the AdminReseller Advanced Service."); } const response = AdminReseller.Subscriptions.list(optionalArgs); const subscriptions = response.subscriptions; if (!subscriptions || subscriptions.length === 0) { console.log("No subscriptions found."); return; } console.log("Subscriptions:"); for (const subscription of subscriptions) { if (subscription.customerId && subscription.skuId) { if (subscription.plan?.planName) { console.log( "%s (%s, %s)", subscription.customerId, subscription.skuId, subscription.plan.planName, ); } else { console.log("%s (%s)", subscription.customerId, subscription.skuId); } } } } // [END admin_sdk_reseller_quickstart] ================================================ FILE: advanced/README.md ================================================ # Advanced Services Samples This directory contains samples for using Apps Script Advanced Services. > Note: These services must be [enabled](https://developers.google.com/apps-script/guides/services/advanced#enabling_advanced_services) before running these samples. Learn more at [developers.google.com](https://developers.google.com/apps-script/guides/services/advanced). ================================================ FILE: advanced/adminSDK.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_admin_sdk_list_all_users] /** * Lists all the users in a domain sorted by first name. * @see https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list */ function listAllUsers() { let pageToken; let page; do { page = AdminDirectory.Users.list({ domain: "example.com", orderBy: "givenName", maxResults: 100, pageToken: pageToken, }); const users = page.users; if (!users) { console.log("No users found."); return; } // Print the user's full name and email. for (const user of users) { console.log("%s (%s)", user.name.fullName, user.primaryEmail); } pageToken = page.nextPageToken; } while (pageToken); } // [END apps_script_admin_sdk_list_all_users] // [START apps_script_admin_sdk_get_users] /** * Get a user by their email address and logs all of their data as a JSON string. * @see https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/get */ function getUser() { // TODO (developer) - Replace userEmail value with yours const userEmail = "liz@example.com"; try { const user = AdminDirectory.Users.get(userEmail); console.log("User data:\n %s", JSON.stringify(user, null, 2)); } catch (err) { // TODO (developer)- Handle exception from the API console.log("Failed with error %s", err.message); } } // [END apps_script_admin_sdk_get_users] // [START apps_script_admin_sdk_add_user] /** * Adds a new user to the domain, including only the required information. For * the full list of user fields, see the API's reference documentation: * @see https://developers.google.com/admin-sdk/directory/v1/reference/users/insert */ function addUser() { let user = { // TODO (developer) - Replace primaryEmail value with yours primaryEmail: "liz@example.com", name: { givenName: "Elizabeth", familyName: "Smith", }, // Generate a random password string. password: Math.random().toString(36), }; try { user = AdminDirectory.Users.insert(user); console.log("User %s created with ID %s.", user.primaryEmail, user.id); } catch (err) { // TODO (developer)- Handle exception from the API console.log("Failed with error %s", err.message); } } // [END apps_script_admin_sdk_add_user] // [START apps_script_admin_sdk_create_alias] /** * Creates an alias (nickname) for a user. * @see https://developers.google.com/admin-sdk/directory/reference/rest/v1/users.aliases/insert */ function createAlias() { // TODO (developer) - Replace userEmail value with yours const userEmail = "liz@example.com"; let alias = { alias: "chica@example.com", }; try { alias = AdminDirectory.Users.Aliases.insert(alias, userEmail); console.log("Created alias %s for user %s.", alias.alias, userEmail); } catch (err) { // TODO (developer)- Handle exception from the API console.log("Failed with error %s", err.message); } } // [END apps_script_admin_sdk_create_alias] // [START apps_script_admin_sdk_list_all_groups] /** * Lists all the groups in the domain. * @see https://developers.google.com/admin-sdk/directory/reference/rest/v1/groups/list */ function listAllGroups() { let pageToken; let page; do { page = AdminDirectory.Groups.list({ domain: "example.com", maxResults: 100, pageToken: pageToken, }); const groups = page.groups; if (!groups) { console.log("No groups found."); return; } // Print group name and email. for (const group of groups) { console.log("%s (%s)", group.name, group.email); } pageToken = page.nextPageToken; } while (pageToken); } // [END apps_script_admin_sdk_list_all_groups] // [START apps_script_admin_sdk_add_group_member] /** * Adds a user to an existing group in the domain. * @see https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/insert */ function addGroupMember() { // TODO (developer) - Replace userEmail value with yours const userEmail = "liz@example.com"; // TODO (developer) - Replace groupEmail value with yours const groupEmail = "bookclub@example.com"; const member = { email: userEmail, role: "MEMBER", }; try { AdminDirectory.Members.insert(member, groupEmail); console.log( "User %s added as a member of group %s.", userEmail, groupEmail, ); } catch (err) { // TODO (developer)- Handle exception from the API console.log("Failed with error %s", err.message); } } // [END apps_script_admin_sdk_add_group_member] // [START apps_script_admin_sdk_migrate] /** * Gets three RFC822 formatted messages from the each of the latest three * threads in the user's Gmail inbox, creates a blob from the email content * (including attachments), and inserts it in a Google Group in the domain. */ function migrateMessages() { // TODO (developer) - Replace groupId value with yours const groupId = "exampleGroup@example.com"; const messagesToMigrate = getRecentMessagesContent(); for (const messageContent of messagesToMigrate) { const contentBlob = Utilities.newBlob(messageContent, "message/rfc822"); AdminGroupsMigration.Archive.insert(groupId, contentBlob); } } /** * Gets a list of recent messages' content from the user's Gmail account. * By default, fetches 3 messages from the latest 3 threads. * * @return {Array} the messages' content. */ function getRecentMessagesContent() { const NUM_THREADS = 3; const NUM_MESSAGES = 3; const threads = GmailApp.getInboxThreads(0, NUM_THREADS); const messages = GmailApp.getMessagesForThreads(threads); const messagesContent = []; for (let i = 0; i < messages.length; i++) { for (let j = 0; j < NUM_MESSAGES; j++) { const message = messages[i][j]; if (message) { messagesContent.push(message.getRawContent()); } } } return messagesContent; } // [END apps_script_admin_sdk_migrate] // [START apps_script_admin_sdk_get_group_setting] /** * Gets a group's settings and logs them to the console. */ function getGroupSettings() { // TODO (developer) - Replace groupId value with yours const groupId = "exampleGroup@example.com"; try { const group = AdminGroupsSettings.Groups.get(groupId); console.log(JSON.stringify(group, null, 2)); } catch (err) { // TODO (developer)- Handle exception from the API console.log("Failed with error %s", err.message); } } // [END apps_script_admin_sdk_get_group_setting] // [START apps_script_admin_sdk_update_group_setting] /** * Updates group's settings. Here, the description is modified, but various * other settings can be changed in the same way. * @see https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups/patch */ function updateGroupSettings() { const groupId = "exampleGroup@example.com"; try { const group = AdminGroupsSettings.newGroups(); group.description = "Newly changed group description"; AdminGroupsSettings.Groups.patch(group, groupId); } catch (err) { // TODO (developer)- Handle exception from the API console.log("Failed with error %s", err.message); } } // [END apps_script_admin_sdk_update_group_setting] // [START apps_script_admin_sdk_get_license_assignments] /** * Logs the license assignments, including the product ID and the sku ID, for * the users in the domain. Notice the use of page tokens to access the full * list of results. */ function getLicenseAssignments() { const productId = "Google-Apps"; const customerId = "example.com"; let assignments = []; let pageToken = null; do { const response = AdminLicenseManager.LicenseAssignments.listForProduct( productId, customerId, { maxResults: 500, pageToken: pageToken, }, ); assignments = assignments.concat(response.items); pageToken = response.nextPageToken; } while (pageToken); // Print the productId and skuId for (const assignment of assignments) { console.log( "userId: %s, productId: %s, skuId: %s", assignment.userId, assignment.productId, assignment.skuId, ); } } // [END apps_script_admin_sdk_get_license_assignments] // [START apps_script_admin_sdk_insert_license_assignment] /** * Insert a license assignment for a user, for a given product ID and sku ID * combination. * For more details follow the link * https://developers.google.com/admin-sdk/licensing/reference/rest/v1/licenseAssignments/insert */ function insertLicenseAssignment() { const productId = "Google-Apps"; const skuId = "Google-Vault"; const userId = "marty@hoverboard.net"; try { const results = AdminLicenseManager.LicenseAssignments.insert( { userId: userId }, productId, skuId, ); console.log(results); } catch (e) { // TODO (developer) - Handle exception. console.log("Failed with an error %s ", e.message); } } // [END apps_script_admin_sdk_insert_license_assignment] // [START apps_script_admin_sdk_generate_login_activity_report] /** * Generates a login activity report for the last week as a spreadsheet. The * report includes the time, user, and login result. * @see https://developers.google.com/admin-sdk/reports/reference/rest/v1/activities/list */ function generateLoginActivityReport() { const now = new Date(); const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const startTime = oneWeekAgo.toISOString(); const endTime = now.toISOString(); const rows = []; let pageToken; let page; do { page = AdminReports.Activities.list("all", "login", { startTime: startTime, endTime: endTime, maxResults: 500, pageToken: pageToken, }); const items = page.items; if (items) { for (const item of items) { const row = [ new Date(item.id.time), item.actor.email, item.events[0].name, ]; rows.push(row); } } pageToken = page.nextPageToken; } while (pageToken); if (rows.length === 0) { console.log("No results returned."); return; } const spreadsheet = SpreadsheetApp.create("Google Workspace Login Report"); const sheet = spreadsheet.getActiveSheet(); // Append the headers. const headers = ["Time", "User", "Login Result"]; sheet.appendRow(headers); // Append the results. sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); console.log("Report spreadsheet created: %s", spreadsheet.getUrl()); } // [END apps_script_admin_sdk_generate_login_activity_report] // [START apps_script_admin_sdk_generate_user_usage_report] /** * Generates a user usage report for this day last week as a spreadsheet. The * report includes the date, user, last login time, number of emails received, * and number of drive files created. * @see https://developers.google.com/admin-sdk/reports/reference/rest/v1/userUsageReport/get */ function generateUserUsageReport() { const today = new Date(); const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); const timezone = Session.getScriptTimeZone(); const date = Utilities.formatDate(oneWeekAgo, timezone, "yyyy-MM-dd"); const parameters = [ "accounts:last_login_time", "gmail:num_emails_received", "drive:num_items_created", ]; const rows = []; let pageToken; let page; do { page = AdminReports.UserUsageReport.get("all", date, { parameters: parameters.join(","), maxResults: 500, pageToken: pageToken, }); if (page.warnings) { for (const warning of page.warnings) { console.log(warning.message); } } const reports = page.usageReports; if (reports) { for (const report of reports) { const parameterValues = getParameterValues(report.parameters); const row = [ report.date, report.entity.userEmail, parameterValues["accounts:last_login_time"], parameterValues["gmail:num_emails_received"], parameterValues["drive:num_items_created"], ]; rows.push(row); } } pageToken = page.nextPageToken; } while (pageToken); if (rows.length === 0) { console.log("No results returned."); return; } const spreadsheet = SpreadsheetApp.create( "Google Workspace User Usage Report", ); const sheet = spreadsheet.getActiveSheet(); // Append the headers. const headers = [ "Date", "User", "Last Login", "Num Emails Received", "Num Drive Files Created", ]; sheet.appendRow(headers); // Append the results. sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); console.log("Report spreadsheet created: %s", spreadsheet.getUrl()); } /** * Gets a map of parameter names to values from an array of parameter objects. * @param {Array} parameters An array of parameter objects. * @return {Object} A map from parameter names to their values. */ function getParameterValues(parameters) { return parameters.reduce((result, parameter) => { const name = parameter.name; let value; if (parameter.intValue !== undefined) { value = parameter.intValue; } else if (parameter.stringValue !== undefined) { value = parameter.stringValue; } else if (parameter.datetimeValue !== undefined) { value = new Date(parameter.datetimeValue); } else if (parameter.boolValue !== undefined) { value = parameter.boolValue; } result[name] = value; return result; }, {}); } // [END apps_script_admin_sdk_generate_user_usage_report] // [START apps_script_admin_sdk_get_subscriptions] /** * Logs the list of subscriptions, including the customer ID, date created, plan * name, and the sku ID. Notice the use of page tokens to access the full list * of results. * @see https://developers.google.com/admin-sdk/reseller/reference/rest/v1/subscriptions/list */ function getSubscriptions() { let result; let pageToken; do { result = AdminReseller.Subscriptions.list({ pageToken: pageToken, }); for (const sub of result.subscriptions) { const creationDate = new Date(); creationDate.setUTCSeconds(sub.creationTime); console.log( "customer ID: %s, date created: %s, plan name: %s, sku id: %s", sub.customerId, creationDate.toDateString(), sub.plan.planName, sub.skuId, ); } pageToken = result.nextPageToken; } while (pageToken); } // [END apps_script_admin_sdk_get_subscriptions] ================================================ FILE: advanced/adsense.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_adsense_list_accounts] /** * Lists available AdSense accounts. */ function listAccounts() { let pageToken; do { const response = AdSense.Accounts.list({ pageToken: pageToken }); if (!response.accounts) { console.log("No accounts found."); return; } for (const account of response.accounts) { console.log( 'Found account with resource name "%s" and display name "%s".', account.name, account.displayName, ); } pageToken = response.nextPageToken; } while (pageToken); } // [END apps_script_adsense_list_accounts] // [START apps_script_adsense_list_ad_clients] /** * Logs available Ad clients for an account. * * @param {string} accountName The resource name of the account that owns the * collection of ad clients. */ function listAdClients(accountName) { let pageToken; do { const response = AdSense.Accounts.Adclients.list(accountName, { pageToken: pageToken, }); if (!response.adClients) { console.log("No ad clients found for this account."); return; } for (const adClient of response.adClients) { console.log( 'Found ad client for product "%s" with resource name "%s".', adClient.productCode, adClient.name, ); console.log( "Reporting dimension ID: %s", adClient.reportingDimensionId ?? "None", ); } pageToken = response.nextPageToken; } while (pageToken); } // [END apps_script_adsense_list_ad_clients] // [START apps_script_adsense_list_ad_units] /** * Lists ad units. * @param {string} adClientName The resource name of the ad client that owns the collection * of ad units. */ function listAdUnits(adClientName) { let pageToken; do { const response = AdSense.Accounts.Adclients.Adunits.list(adClientName, { pageSize: 50, pageToken: pageToken, }); if (!response.adUnits) { console.log("No ad units found for this ad client."); return; } for (const adUnit of response.adUnits) { console.log( 'Found ad unit with resource name "%s" and display name "%s".', adUnit.name, adUnit.displayName, ); } pageToken = response.nextPageToken; } while (pageToken); } // [END apps_script_adsense_list_ad_units] // [START apps_script_adsense_generate_report] /** * Generates a spreadsheet report for a specific ad client in an account. * @param {string} accountName The resource name of the account. * @param {string} adClientReportingDimensionId The reporting dimension ID * of the ad client. */ function generateReport(accountName, adClientReportingDimensionId) { // Prepare report. const today = new Date(); const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); const report = AdSense.Accounts.Reports.generate(accountName, { // Specify the desired ad client using a filter. filters: [ `AD_CLIENT_ID==${escapeFilterParameter(adClientReportingDimensionId)}`, ], metrics: [ "PAGE_VIEWS", "AD_REQUESTS", "AD_REQUESTS_COVERAGE", "CLICKS", "AD_REQUESTS_CTR", "COST_PER_CLICK", "AD_REQUESTS_RPM", "ESTIMATED_EARNINGS", ], dimensions: ["DATE"], ...dateToJson("startDate", oneWeekAgo), ...dateToJson("endDate", today), // Sort by ascending date. orderBy: ["+DATE"], }); if (!report.rows) { console.log("No rows returned."); return; } const spreadsheet = SpreadsheetApp.create("AdSense Report"); const sheet = spreadsheet.getActiveSheet(); // Append the headers. sheet.appendRow(report.headers.map((header) => header.name)); // Append the results. sheet .getRange(2, 1, report.rows.length, report.headers.length) .setValues(report.rows.map((row) => row.cells.map((cell) => cell.value))); console.log("Report spreadsheet created: %s", spreadsheet.getUrl()); } /** * Escape special characters for a parameter being used in a filter. * @param {string} parameter The parameter to be escaped. * @return {string} The escaped parameter. */ function escapeFilterParameter(parameter) { return parameter.replace("\\", "\\\\").replace(",", "\\,"); } /** * Returns the JSON representation of a Date object (as a google.type.Date). * * @param {string} paramName the name of the date parameter * @param {Date} value the date * @return {object} formatted date */ function dateToJson(paramName, value) { return { [`${paramName}.year`]: value.getFullYear(), [`${paramName}.month`]: value.getMonth() + 1, [`${paramName}.day`]: value.getDate(), }; } // [END apps_script_adsense_generate_report] ================================================ FILE: advanced/analytics.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_analytics_accounts] /** * Lists Analytics accounts. */ function listAccounts() { try { const accounts = Analytics.Management.Accounts.list(); if (!accounts.items || !accounts.items.length) { console.log("No accounts found."); return; } for (let i = 0; i < accounts.items.length; i++) { const account = accounts.items[i]; console.log('Account: name "%s", id "%s".', account.name, account.id); // List web properties in the account. listWebProperties(account.id); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Lists web properites for an Analytics account. * @param {string} accountId The account ID. */ function listWebProperties(accountId) { try { const webProperties = Analytics.Management.Webproperties.list(accountId); if (!webProperties.items || !webProperties.items.length) { console.log("\tNo web properties found."); return; } for (let i = 0; i < webProperties.items.length; i++) { const webProperty = webProperties.items[i]; console.log( '\tWeb Property: name "%s", id "%s".', webProperty.name, webProperty.id, ); // List profiles in the web property. listProfiles(accountId, webProperty.id); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Logs a list of Analytics accounts profiles. * @param {string} accountId The Analytics account ID * @param {string} webPropertyId The web property ID */ function listProfiles(accountId, webPropertyId) { // Note: If you experience "Quota Error: User Rate Limit Exceeded" errors // due to the number of accounts or profiles you have, you may be able to // avoid it by adding a Utilities.sleep(1000) statement here. try { const profiles = Analytics.Management.Profiles.list( accountId, webPropertyId, ); if (!profiles.items || !profiles.items.length) { console.log("\t\tNo web properties found."); return; } for (let i = 0; i < profiles.items.length; i++) { const profile = profiles.items[i]; console.log('\t\tProfile: name "%s", id "%s".', profile.name, profile.id); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_analytics_accounts] // [START apps_script_analytics_reports] /** * Runs a report of an Analytics profile ID. Creates a sheet with the report. * @param {string} profileId The profile ID. */ function runReport(profileId) { const today = new Date(); const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); const startDate = Utilities.formatDate( oneWeekAgo, Session.getScriptTimeZone(), "yyyy-MM-dd", ); const endDate = Utilities.formatDate( today, Session.getScriptTimeZone(), "yyyy-MM-dd", ); const tableId = `ga:${profileId}`; const metric = "ga:visits"; const options = { dimensions: "ga:source,ga:keyword", sort: "-ga:visits,ga:source", filters: "ga:medium==organic", "max-results": 25, }; const report = Analytics.Data.Ga.get( tableId, startDate, endDate, metric, options, ); if (!report.rows) { console.log("No rows returned."); return; } const spreadsheet = SpreadsheetApp.create("Google Analytics Report"); const sheet = spreadsheet.getActiveSheet(); // Append the headers. const headers = report.columnHeaders.map((columnHeader) => { return columnHeader.name; }); sheet.appendRow(headers); // Append the results. sheet .getRange(2, 1, report.rows.length, headers.length) .setValues(report.rows); console.log("Report spreadsheet created: %s", spreadsheet.getUrl()); } // [END apps_script_analytics_reports] ================================================ FILE: advanced/analyticsAdmin.gs ================================================ /** * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_analyticsadmin] /** * Logs the Google Analytics accounts accessible by the current user. */ function listAccounts() { try { accounts = AnalyticsAdmin.Accounts.list(); if (!accounts.items || !accounts.items.length) { console.log("No accounts found."); return; } for (let i = 0; i < accounts.items.length; i++) { const account = accounts.items[i]; console.log( 'Account: name "%s", displayName "%s".', account.name, account.displayName, ); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_analyticsadmin] ================================================ FILE: advanced/analyticsData.gs ================================================ /** * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_analyticsdata] /** * Runs a report of a Google Analytics 4 property ID. Creates a sheet with the * report. */ function runReport() { /** * TODO(developer): Uncomment this variable and replace with your * Google Analytics 4 property ID before running the sample. */ const propertyId = "YOUR-GA4-PROPERTY-ID"; try { const metric = AnalyticsData.newMetric(); metric.name = "activeUsers"; const dimension = AnalyticsData.newDimension(); dimension.name = "city"; const dateRange = AnalyticsData.newDateRange(); dateRange.startDate = "2020-03-31"; dateRange.endDate = "today"; const request = AnalyticsData.newRunReportRequest(); request.dimensions = [dimension]; request.metrics = [metric]; request.dateRanges = dateRange; const report = AnalyticsData.Properties.runReport( request, `properties/${propertyId}`, ); if (!report.rows) { console.log("No rows returned."); return; } const spreadsheet = SpreadsheetApp.create("Google Analytics Report"); const sheet = spreadsheet.getActiveSheet(); // Append the headers. const dimensionHeaders = report.dimensionHeaders.map((dimensionHeader) => { return dimensionHeader.name; }); const metricHeaders = report.metricHeaders.map((metricHeader) => { return metricHeader.name; }); const headers = [...dimensionHeaders, ...metricHeaders]; sheet.appendRow(headers); // Append the results. const rows = report.rows.map((row) => { const dimensionValues = row.dimensionValues.map((dimensionValue) => { return dimensionValue.value; }); const metricValues = row.metricValues.map((metricValues) => { return metricValues.value; }); return [...dimensionValues, ...metricValues]; }); sheet.getRange(2, 1, report.rows.length, headers.length).setValues(rows); console.log("Report spreadsheet created: %s", spreadsheet.getUrl()); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_analyticsdata] ================================================ FILE: advanced/bigquery.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_bigquery_run_query] /** * Runs a BigQuery query and logs the results in a spreadsheet. */ function runQuery() { // Replace this value with the project ID listed in the Google // Cloud Platform project. const projectId = "XXXXXXXX"; const request = { // TODO (developer) - Replace query with yours query: "SELECT refresh_date AS Day, term AS Top_Term, rank " + "FROM `bigquery-public-data.google_trends.top_terms` " + "WHERE rank = 1 " + "AND refresh_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 2 WEEK) " + "GROUP BY Day, Top_Term, rank " + "ORDER BY Day DESC;", useLegacySql: false, }; let queryResults = BigQuery.Jobs.query(request, projectId); const jobId = queryResults.jobReference.jobId; // Check on status of the Query Job. let sleepTimeMs = 500; while (!queryResults.jobComplete) { Utilities.sleep(sleepTimeMs); sleepTimeMs *= 2; queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId); } // Get all the rows of results. let rows = queryResults.rows; while (queryResults.pageToken) { queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, { pageToken: queryResults.pageToken, }); rows = rows.concat(queryResults.rows); } if (!rows) { console.log("No rows returned."); return; } const spreadsheet = SpreadsheetApp.create("BigQuery Results"); const sheet = spreadsheet.getActiveSheet(); // Append the headers. const headers = queryResults.schema.fields.map((field) => field.name); sheet.appendRow(headers); // Append the results. const data = new Array(rows.length); for (let i = 0; i < rows.length; i++) { const cols = rows[i].f; data[i] = new Array(cols.length); for (let j = 0; j < cols.length; j++) { data[i][j] = cols[j].v; } } sheet.getRange(2, 1, rows.length, headers.length).setValues(data); console.log("Results spreadsheet created: %s", spreadsheet.getUrl()); } // [END apps_script_bigquery_run_query] // [START apps_script_bigquery_load_csv] /** * Loads a CSV into BigQuery */ function loadCsv() { // Replace this value with the project ID listed in the Google // Cloud Platform project. const projectId = "XXXXXXXX"; // Create a dataset in the BigQuery UI (https://bigquery.cloud.google.com) // and enter its ID below. const datasetId = "YYYYYYYY"; // Sample CSV file of Google Trends data conforming to the schema below. // https://docs.google.com/file/d/0BwzA1Orbvy5WMXFLaTR1Z1p2UDg/edit const csvFileId = "0BwzA1Orbvy5WMXFLaTR1Z1p2UDg"; // Create the table. const tableId = `pets_${new Date().getTime()}`; let table = { tableReference: { projectId: projectId, datasetId: datasetId, tableId: tableId, }, schema: { fields: [ { name: "week", type: "STRING" }, { name: "cat", type: "INTEGER" }, { name: "dog", type: "INTEGER" }, { name: "bird", type: "INTEGER" }, ], }, }; try { table = BigQuery.Tables.insert(table, projectId, datasetId); console.log("Table created: %s", table.id); } catch (err) { console.log("unable to create table"); } // Load CSV data from Drive and convert to the correct format for upload. const file = DriveApp.getFileById(csvFileId); const data = file.getBlob().setContentType("application/octet-stream"); // Create the data upload job. const job = { configuration: { load: { destinationTable: { projectId: projectId, datasetId: datasetId, tableId: tableId, }, skipLeadingRows: 1, }, }, }; try { const jobResult = BigQuery.Jobs.insert(job, projectId, data); console.log(`Load job started. Status: ${jobResult.status.state}`); } catch (err) { console.log("unable to insert job"); } } // [END apps_script_bigquery_load_csv] ================================================ FILE: advanced/calendar.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START calendar_list_calendars] /** * Lists the calendars shown in the user's calendar list. * @see https://developers.google.com/calendar/api/v3/reference/calendarList/list */ function listCalendars() { let calendars; let pageToken; do { calendars = Calendar.CalendarList.list({ maxResults: 100, pageToken: pageToken, }); if (!calendars.items || calendars.items.length === 0) { console.log("No calendars found."); return; } // Print the calendar id and calendar summary for (const calendar of calendars.items) { console.log("%s (ID: %s)", calendar.summary, calendar.id); } pageToken = calendars.nextPageToken; } while (pageToken); } // [END calendar_list_calendars] // [START calendar_create_event] /** * Creates an event in the user's default calendar. * @see https://developers.google.com/calendar/api/v3/reference/events/insert */ function createEvent() { const calendarId = "primary"; const start = getRelativeDate(1, 12); const end = getRelativeDate(1, 13); // event details for creating event. let event = { summary: "Lunch Meeting", location: "The Deli", description: "To discuss our plans for the presentation next week.", start: { dateTime: start.toISOString(), }, end: { dateTime: end.toISOString(), }, attendees: [ { email: "gduser1@workspacesample.dev" }, { email: "gduser2@workspacesample.dev" }, ], // Red background. Use Calendar.Colors.get() for the full list. colorId: 11, }; try { // call method to insert/create new event in provided calandar event = Calendar.Events.insert(event, calendarId); console.log(`Event ID: ${event.id}`); } catch (err) { console.log("Failed with error %s", err.message); } } /** * Helper function to get a new Date object relative to the current date. * @param {number} daysOffset The number of days in the future for the new date. * @param {number} hour The hour of the day for the new date, in the time zone * of the script. * @return {Date} The new date. */ function getRelativeDate(daysOffset, hour) { const date = new Date(); date.setDate(date.getDate() + daysOffset); date.setHours(hour); date.setMinutes(0); date.setSeconds(0); date.setMilliseconds(0); return date; } // [END calendar_create_event] // [START calendar_list_events] /** * Lists the next 10 upcoming events in the user's default calendar. * @see https://developers.google.com/calendar/api/v3/reference/events/list */ function listNext10Events() { const calendarId = "primary"; const now = new Date(); const events = Calendar.Events.list(calendarId, { timeMin: now.toISOString(), singleEvents: true, orderBy: "startTime", maxResults: 10, }); if (!events.items || events.items.length === 0) { console.log("No events found."); return; } for (const event of events.items) { if (event.start.date) { // All-day event. const start = new Date(event.start.date); console.log("%s (%s)", event.summary, start.toLocaleDateString()); continue; } const start = new Date(event.start.dateTime); console.log("%s (%s)", event.summary, start.toLocaleString()); } } // [END calendar_list_events] // [START calendar_log_synced_events] /** * Retrieve and log events from the given calendar that have been modified * since the last sync. If the sync token is missing or invalid, log all * events from up to a month ago (a full sync). * * @param {string} calendarId The ID of the calender to retrieve events from. * @param {boolean} fullSync If true, throw out any existing sync token and * perform a full sync; if false, use the existing sync token if possible. */ function logSyncedEvents(calendarId, fullSync) { const properties = PropertiesService.getUserProperties(); const options = { maxResults: 100, }; const syncToken = properties.getProperty("syncToken"); if (syncToken && !fullSync) { options.syncToken = syncToken; } else { // Sync events up to thirty days in the past. options.timeMin = getRelativeDate(-30, 0).toISOString(); } // Retrieve events one page at a time. let events; let pageToken; do { try { options.pageToken = pageToken; events = Calendar.Events.list(calendarId, options); } catch (e) { // Check to see if the sync token was invalidated by the server; // if so, perform a full sync instead. if ( e.message === "Sync token is no longer valid, a full sync is required." ) { properties.deleteProperty("syncToken"); logSyncedEvents(calendarId, true); return; } throw new Error(e.message); } if (events.items && events.items.length === 0) { console.log("No events found."); return; } for (const event of events.items) { if (event.status === "cancelled") { console.log("Event id %s was cancelled.", event.id); return; } if (event.start.date) { const start = new Date(event.start.date); console.log("%s (%s)", event.summary, start.toLocaleDateString()); return; } // Events that don't last all day; they have defined start times. const start = new Date(event.start.dateTime); console.log("%s (%s)", event.summary, start.toLocaleString()); } pageToken = events.nextPageToken; } while (pageToken); properties.setProperty("syncToken", events.nextSyncToken); } // [END calendar_log_synced_events] // [START calendar_conditional_update] /** * Creates an event in the user's default calendar, waits 30 seconds, then * attempts to update the event's location, on the condition that the event * has not been changed since it was created. If the event is changed during * the 30-second wait, then the subsequent update will throw a 'Precondition * Failed' error. * * The conditional update is accomplished by setting the 'If-Match' header * to the etag of the new event when it was created. */ function conditionalUpdate() { const calendarId = "primary"; const start = getRelativeDate(1, 12); const end = getRelativeDate(1, 13); let event = { summary: "Lunch Meeting", location: "The Deli", description: "To discuss our plans for the presentation next week.", start: { dateTime: start.toISOString(), }, end: { dateTime: end.toISOString(), }, attendees: [ { email: "gduser1@workspacesample.dev" }, { email: "gduser2@workspacesample.dev" }, ], // Red background. Use Calendar.Colors.get() for the full list. colorId: 11, }; event = Calendar.Events.insert(event, calendarId); console.log(`Event ID: ${event.getId()}`); // Wait 30 seconds to see if the event has been updated outside this script. Utilities.sleep(30 * 1000); // Try to update the event, on the condition that the event state has not // changed since the event was created. event.location = "The Coffee Shop"; try { event = Calendar.Events.update( event, calendarId, event.id, {}, { "If-Match": event.etag }, ); console.log(`Successfully updated event: ${event.id}`); } catch (e) { console.log(`Fetch threw an exception: ${e}`); } } // [END calendar_conditional_update] // [START calendar_conditional_fetch] /** * Creates an event in the user's default calendar, then re-fetches the event * every second, on the condition that the event has changed since the last * fetch. * * The conditional fetch is accomplished by setting the 'If-None-Match' header * to the etag of the last known state of the event. */ function conditionalFetch() { const calendarId = "primary"; const start = getRelativeDate(1, 12); const end = getRelativeDate(1, 13); let event = { summary: "Lunch Meeting", location: "The Deli", description: "To discuss our plans for the presentation next week.", start: { dateTime: start.toISOString(), }, end: { dateTime: end.toISOString(), }, attendees: [ { email: "gduser1@workspacesample.dev" }, { email: "gduser2@workspacesample.dev" }, ], // Red background. Use Calendar.Colors.get() for the full list. colorId: 11, }; try { // insert event event = Calendar.Events.insert(event, calendarId); console.log(`Event ID: ${event.getId()}`); // Re-fetch the event each second, but only get a result if it has changed. for (let i = 0; i < 30; i++) { Utilities.sleep(1000); event = Calendar.Events.get( calendarId, event.id, {}, { "If-None-Match": event.etag }, ); console.log(`New event description: ${event.start.dateTime}`); } } catch (e) { console.log(`Fetch threw an exception: ${e}`); } } // [END calendar_conditional_fetch] ================================================ FILE: advanced/chat.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START chat_post_message_with_user_credentials] /** * Posts a new message to the specified space on behalf of the user. * @param {string} spaceName The resource name of the space. */ function postMessageWithUserCredentials(spaceName) { try { const message = { text: "Hello world!" }; Chat.Spaces.Messages.create(message, spaceName); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to create message with error %s", err.message); } } // [END chat_post_message_with_user_credentials] // [START chat_post_message_with_app_credentials] /** * Posts a new message to the specified space on behalf of the app. * @param {string} spaceName The resource name of the space. */ function postMessageWithAppCredentials(spaceName) { try { // See https://developers.google.com/chat/api/guides/auth/service-accounts // for details on how to obtain a service account OAuth token. const appToken = getToken_(); const message = { text: "Hello world!" }; Chat.Spaces.Messages.create( message, spaceName, {}, // Authenticate with the service account token. { Authorization: `Bearer ${appToken}` }, ); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to create message with error %s", err.message); } } // [END chat_post_message_with_app_credentials] // [START chat_get_space] /** * Gets information about a Chat space. * @param {string} spaceName The resource name of the space. */ function getSpace(spaceName) { try { const space = Chat.Spaces.get(spaceName); console.log("Space display name: %s", space.displayName); console.log("Space type: %s", space.spaceType); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to get space with error %s", err.message); } } // [END chat_get_space] // [START chat_create_space] /** * Creates a new Chat space. */ function createSpace() { try { const space = { displayName: "New Space", spaceType: "SPACE" }; Chat.Spaces.create(space); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to create space with error %s", err.message); } } // [END chat_create_space] // [START chat_list_memberships] /** * Lists all the members of a Chat space. * @param {string} spaceName The resource name of the space. */ function listMemberships(spaceName) { let response; let pageToken = null; try { do { response = Chat.Spaces.Members.list(spaceName, { pageSize: 10, pageToken: pageToken, }); if (!response.memberships || response.memberships.length === 0) { pageToken = response.nextPageToken; continue; } for (const membership of response.memberships) { console.log( "Member: %s, Role: %s", membership.member.displayName, membership.role, ); } pageToken = response.nextPageToken; } while (pageToken); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } // [END chat_list_memberships] ================================================ FILE: advanced/classroom.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_classroom_list_courses] /** * Lists 10 course names and IDs. */ function listCourses() { /** * @see https://developers.google.com/classroom/reference/rest/v1/courses/list */ const optionalArgs = { pageSize: 10, // Use other query parameters here if needed. }; try { const response = Classroom.Courses.list(optionalArgs); const courses = response.courses; if (!courses || courses.length === 0) { console.log("No courses found."); return; } // Print the course names and IDs of the available courses. for (const course in courses) { console.log("%s (%s)", courses[course].name, courses[course].id); } } catch (err) { // TODO (developer)- Handle Courses.list() exception from Classroom API console.log("Failed with error %s", err.message); } } // [END apps_script_classroom_list_courses] ================================================ FILE: advanced/displayvideo.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_dv360_list_partners] /** * Logs all of the partners available in the account. */ function listPartners() { // Retrieve the list of available partners try { const partners = DisplayVideo.Partners.list(); if (partners.partners) { // Print out the ID and name of each for (let i = 0; i < partners.partners.length; i++) { const partner = partners.partners[i]; console.log( 'Found partner with ID %s and name "%s".', partner.partnerId, partner.displayName, ); } } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_dv360_list_partners] // [START apps_script_dv360_list_active_campaigns] /** * Logs names and ID's of all active campaigns. * Note the use of paging tokens to retrieve the whole list. */ function listActiveCampaigns() { const advertiserId = "1234567"; // Replace with your advertiser ID. let result; let pageToken; try { do { result = DisplayVideo.Advertisers.Campaigns.list(advertiserId, { filter: 'entityStatus="ENTITY_STATUS_ACTIVE"', pageToken: pageToken, }); if (result.campaigns) { for (let i = 0; i < result.campaigns.length; i++) { const campaign = result.campaigns[i]; console.log( 'Found campaign with ID %s and name "%s".', campaign.campaignId, campaign.displayName, ); } } pageToken = result.nextPageToken; } while (pageToken); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_dv360_list_active_campaigns] // [START apps_script_dv360_update_line_item_name] /** * Updates the display name of a line item */ function updateLineItemName() { const advertiserId = "1234567"; // Replace with your advertiser ID. const lineItemId = "123456789"; //Replace with your line item ID. const updateMask = "displayName"; const lineItemDef = { displayName: "New Line Item Name (updated from Apps Script!)", }; try { const lineItem = DisplayVideo.Advertisers.LineItems.patch( lineItemDef, advertiserId, lineItemId, { updateMask: updateMask }, ); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_dv360_update_line_item_name] ================================================ FILE: advanced/docs.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START docs_create_document] /** * Create a new document. * @see https://developers.google.com/docs/api/reference/rest/v1/documents/create * @return {string} documentId */ function createDocument() { // Create document with title const document = Docs.Documents.create({ title: "My New Document" }); console.log(`Created document with ID: ${document.documentId}`); return document.documentId; } // [END docs_create_document] // [START docs_find_and_replace_text] /** * Performs "replace all". * @param {string} documentId The document to perform the replace text operations on. * @param {Object} findTextToReplacementMap A map from the "find text" to the "replace text". * @return {Object} replies * @see https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate */ function findAndReplace(documentId, findTextToReplacementMap) { const requests = []; for (const findText in findTextToReplacementMap) { const replaceText = findTextToReplacementMap[findText]; // Replace all text across all tabs. const replaceAllTextRequest = { replaceAllText: { containsText: { text: findText, matchCase: true, }, replaceText: replaceText, }, }; // Replace all text across specific tabs. const _replaceAllTextWithTabsCriteria = { replaceAllText: { ...replaceAllTextRequest.replaceAllText, tabsCriteria: { tabIds: [TAB_ID_1, TAB_ID_2, TAB_ID_3], }, }, }; requests.push(replaceAllTextRequest); } const response = Docs.Documents.batchUpdate( { requests: requests }, documentId, ); const replies = response.replies; for (const [index] of replies.entries()) { const numReplacements = replies[index].replaceAllText.occurrencesChanged || 0; console.log( "Request %s performed %s replacements.", index, numReplacements, ); } return replies; } // [END docs_find_and_replace_text] // [START docs_insert_and_style_text] /** * Insert text at the beginning of the first tab in the document and then style * the inserted text. * @param {string} documentId The document the text is inserted into. * @param {string} text The text to insert into the document. * @return {Object} replies * @see https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate */ function insertAndStyleText(documentId, text) { const requests = [ { insertText: { location: { index: 1, // A tab can be specified using its ID. When omitted, the request is // applied to the first tab. // tabId: TAB_ID }, text: text, }, }, { updateTextStyle: { range: { startIndex: 1, endIndex: text.length + 1, }, textStyle: { fontSize: { magnitude: 12, unit: "PT", }, weightedFontFamily: { fontFamily: "Calibri", }, }, fields: "weightedFontFamily, fontSize", }, }, ]; const response = Docs.Documents.batchUpdate( { requests: requests }, documentId, ); return response.replies; } // [END docs_insert_and_style_text] // [START docs_read_first_paragraph] /** * Read the first paragraph of the first tab in a document. * @param {string} documentId The ID of the document to read. * @return {Object} paragraphText * @see https://developers.google.com/docs/api/reference/rest/v1/documents/get */ function readFirstParagraph(documentId) { // Get the document using document ID const document = Docs.Documents.get(documentId, { includeTabsContent: true, }); const firstTab = document.tabs[0]; const bodyElements = firstTab.documentTab.body.content; for (let i = 0; i < bodyElements.length; i++) { const structuralElement = bodyElements[i]; // Print the first paragraph text present in document if (structuralElement.paragraph) { const paragraphElements = structuralElement.paragraph.elements; let paragraphText = ""; for (let j = 0; j < paragraphElements.length; j++) { const paragraphElement = paragraphElements[j]; if (paragraphElement.textRun !== null) { paragraphText += paragraphElement.textRun.content; } } console.log(paragraphText); return paragraphText; } } } // [END docs_read_first_paragraph] ================================================ FILE: advanced/doubleclick.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_doubleclick_list_user_profiles] /** * Logs all of the user profiles available in the account. */ function listUserProfiles() { // Retrieve the list of available user profiles try { const profiles = DoubleClickCampaigns.UserProfiles.list(); if (profiles.items) { // Print out the user ID and name of each for (let i = 0; i < profiles.items.length; i++) { const profile = profiles.items[i]; console.log( 'Found profile with ID %s and name "%s".', profile.profileId, profile.userName, ); } } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_doubleclick_list_user_profiles] // [START apps_script_doubleclick_list_active_campaigns] /** * Logs names and ID's of all active campaigns. * Note the use of paging tokens to retrieve the whole list. */ function listActiveCampaigns() { const profileId = "1234567"; // Replace with your profile ID. const fields = "nextPageToken,campaigns(id,name)"; let result; let pageToken; try { do { result = DoubleClickCampaigns.Campaigns.list(profileId, { archived: false, fields: fields, pageToken: pageToken, }); if (result.campaigns) { for (let i = 0; i < result.campaigns.length; i++) { const campaign = result.campaigns[i]; console.log( 'Found campaign with ID %s and name "%s".', campaign.id, campaign.name, ); } } pageToken = result.nextPageToken; } while (pageToken); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_doubleclick_list_active_campaigns] // [START apps_script_doubleclick_create_advertiser_and_campaign] /** * Creates a new advertiser, and creates a new campaign with that advertiser. * The campaign is set to last for one month. */ function createAdvertiserAndCampaign() { const profileId = "1234567"; // Replace with your profile ID. const advertiser = { name: "Example Advertiser", status: "APPROVED", }; try { const advertiserId = DoubleClickCampaigns.Advertisers.insert( advertiser, profileId, ).id; const landingPage = { advertiserId: advertiserId, archived: false, name: "Example landing page", url: "https://www.google.com", }; const landingPageId = DoubleClickCampaigns.AdvertiserLandingPages.insert( landingPage, profileId, ).id; const campaignStart = new Date(); // End campaign after 1 month. const campaignEnd = new Date(); campaignEnd.setMonth(campaignEnd.getMonth() + 1); const campaign = { advertiserId: advertiserId, defaultLandingPageId: landingPageId, name: "Example campaign", startDate: Utilities.formatDate(campaignStart, "GMT", "yyyy-MM-dd"), endDate: Utilities.formatDate(campaignEnd, "GMT", "yyyy-MM-dd"), }; DoubleClickCampaigns.Campaigns.insert(campaign, profileId); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_doubleclick_create_advertiser_and_campaign] ================================================ FILE: advanced/doubleclickbidmanager.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_dcbm_list_queries] /** * Logs all of the queries available in the account. */ function listQueries() { // Retrieve the list of available queries try { const queries = DoubleClickBidManager.Queries.list(); if (queries.queries) { // Print out the ID and name of each for (let i = 0; i < queries.queries.length; i++) { const query = queries.queries[i]; console.log( 'Found query with ID %s and name "%s".', query.queryId, query.metadata.title, ); } } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_dcbm_list_queries] // [START apps_script_dcbm_create_and_run_query] /** * Create and run a new DBM Query */ function createAndRunQuery() { let result; let execution; //We leave the default date range blank for the report run to //use the value defined during query creation const defaultDateRange = {}; const partnerId = "1234567"; //Replace with your Partner ID const query = { metadata: { title: "Apps Script Example Report", dataRange: { range: "YEAR_TO_DATE", }, format: "CSV", }, params: { type: "STANDARD", groupBys: [ "FILTER_PARTNER", "FILTER_PARTNER_NAME", "FILTER_ADVERTISER", "FILTER_ADVERTISER_NAME", ], filters: [{ type: "FILTER_PARTNER", value: partnerId }], metrics: ["METRIC_IMPRESSIONS"], }, schedule: { frequency: "ONE_TIME", }, }; try { result = DoubleClickBidManager.Queries.create(query); if (result.queryId) { console.log( 'Created query with ID %s and name "%s".', result.queryId, result.metadata.title, ); execution = DoubleClickBidManager.Queries.run( defaultDateRange, result.queryId, ); if (execution.key) { console.log( 'Created query report with query ID %s and report ID "%s".', execution.key.queryId, execution.key.reportId, ); } } } catch (e) { // TODO (Developer) - Handle exception console.log(e); console.log("Failed with error: %s", e.error); } } // [END apps_script_dcbm_create_and_run_query] // [START apps_script_dcbm_fetch_report] /** * Fetches a report file */ function fetchReport() { const queryId = "1234567"; // Replace with your query ID. const orderBy = "key.reportId desc"; try { const report = DoubleClickBidManager.Queries.Reports.list(queryId, { orderBy: orderBy, }); if (report.reports) { const firstReport = report.reports[0]; if (firstReport.metadata.status.state === "DONE") { const reportFile = UrlFetchApp.fetch( firstReport.metadata.googleCloudStoragePath, ); console.log("Printing report content to log..."); console.log(reportFile.getContentText()); } else { console.log( "Report status is %s, and is not available for download", firstReport.metadata.status.state, ); } } } catch (e) { // TODO (Developer) - Handle exception console.log(e); console.log("Failed with error: %s", e.error); } } // [END apps_script_dcbm_fetch_report] ================================================ FILE: advanced/drive.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START drive_upload_file] /** * Uploads a new file to the user's Drive. */ function uploadFile() { try { // Makes a request to fetch a URL. const image = UrlFetchApp.fetch("http://goo.gl/nd7zjB").getBlob(); let file = { name: "google_logo.png", mimeType: "image/png", }; // Create a file in the user's Drive. file = Drive.Files.create(file, image, { fields: "id,size" }); console.log("ID: %s, File size (bytes): %s", file.id, file.size); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to upload file with error %s", err.message); } } // [END drive_upload_file] // [START drive_list_root_folders] /** * Lists the top-level folders in the user's Drive. */ function listRootFolders() { const query = '"root" in parents and trashed = false and ' + 'mimeType = "application/vnd.google-apps.folder"'; let folders; let pageToken = null; do { try { folders = Drive.Files.list({ q: query, pageSize: 100, pageToken: pageToken, }); if (!folders.files || folders.files.length === 0) { console.log("All folders found."); return; } for (let i = 0; i < folders.files.length; i++) { const folder = folders.files[i]; console.log("%s (ID: %s)", folder.name, folder.id); } pageToken = folders.nextPageToken; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } while (pageToken); } // [END drive_list_root_folders] // [START drive_add_custom_property] /** * Adds a custom app property to a file. Unlike Apps Script's DocumentProperties, * Drive's custom file properties can be accessed outside of Apps Script and * by other applications; however, appProperties are only visible to the script. * @param {string} fileId The ID of the file to add the app property to. */ function addAppProperty(fileId) { try { let file = { appProperties: { department: "Sales", }, }; // Updates a file to add an app property. file = Drive.Files.update(file, fileId, null, { fields: "id,appProperties", }); console.log( "ID: %s, appProperties: %s", file.id, JSON.stringify(file.appProperties, null, 2), ); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } // [END drive_add_custom_property] // [START drive_list_revisions] /** * Lists the revisions of a given file. * @param {string} fileId The ID of the file to list revisions for. */ function listRevisions(fileId) { let revisions; let pageToken = null; do { try { revisions = Drive.Revisions.list(fileId, { fields: "revisions(modifiedTime,size),nextPageToken", }); if (!revisions.revisions || revisions.revisions.length === 0) { console.log("All revisions found."); return; } for (let i = 0; i < revisions.revisions.length; i++) { const revision = revisions.revisions[i]; const date = new Date(revision.modifiedTime); console.log( "Date: %s, File size (bytes): %s", date.toLocaleString(), revision.size, ); } pageToken = revisions.nextPageToken; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } while (pageToken); } // [END drive_list_revisions] ================================================ FILE: advanced/driveActivity.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_drive_activity_get_users_activity] /** * Gets a file's activity and logs the list of * unique users that performed the activity. */ function getUsersActivity() { const fileId = "YOUR_FILE_ID_HERE"; let pageToken; const users = {}; do { const result = AppsActivity.Activities.list({ "drive.fileId": fileId, source: "drive.google.com", pageToken: pageToken, }); const activities = result.activities; for (let i = 0; i < activities.length; i++) { const events = activities[i].singleEvents; for (let j = 0; j < events.length; j++) { const event = events[j]; users[event.user.name] = true; } } pageToken = result.nextPageToken; } while (pageToken); console.log(Object.keys(users)); } // [END apps_script_drive_activity_get_users_activity] ================================================ FILE: advanced/driveLabels.gs ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_drive_labels_list_labels] /** * List labels available to the user. */ function listLabels() { let pageToken = null; let labels = []; do { try { const response = DriveLabels.Labels.list({ publishedOnly: true, pageToken: pageToken, }); pageToken = response.nextPageToken; labels = labels.concat(response.labels); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to list labels with error %s", err.message); } } while (pageToken != null); console.log("Found %d labels", labels.length); } // [END apps_script_drive_labels_list_labels] // [START apps_script_drive_labels_get_label] /** * Get a label by name. * @param {string} labelName The label name. */ function getLabel(labelName) { try { const label = DriveLabels.Labels.get(labelName, { view: "LABEL_VIEW_FULL", }); const title = label.properties.title; const fieldsLength = label.fields.length; console.log( `Fetched label with title: '${title}' and ${fieldsLength} fields.`, ); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to get label with error %s", err.message); } } // [END apps_script_drive_labels_get_label] // [START apps_script_drive_labels_list_labels_on_drive_item] /** * List Labels on a Drive Item * Fetches a Drive Item and prints all applied values along with their to their * human-readable names. * * @param {string} fileId The Drive File ID */ function listLabelsOnDriveItem(fileId) { try { const appliedLabels = Drive.Files.listLabels(fileId); console.log( "%d label(s) are applied to this file", appliedLabels.labels.length, ); for (const appliedLabel of appliedLabels.labels) { // Resource name of the label at the applied revision. const labelName = `labels/${appliedLabel.id}@${appliedLabel.revisionId}`; console.log("Fetching Label: %s", labelName); const label = DriveLabels.Labels.get(labelName, { view: "LABEL_VIEW_FULL", }); console.log("Label Title: %s", label.properties.title); for (const fieldId of Object.keys(appliedLabel.fields)) { const fieldValue = appliedLabel.fields[fieldId]; const field = label.fields.find((f) => f.id === fieldId); console.log( `Field ID: ${field.id}, Display Name: ${field.properties.displayName}`, ); switch (fieldValue.valueType) { case "text": console.log("Text: %s", fieldValue.text[0]); break; case "integer": console.log("Integer: %d", fieldValue.integer[0]); break; case "dateString": console.log("Date: %s", fieldValue.dateString[0]); break; case "user": { const user = fieldValue.user .map((user) => { return `${user.emailAddress}: ${user.displayName}`; }) .join(", "); console.log(`User: ${user}`); break; } case "selection": { const choices = fieldValue.selection.map((choiceId) => { return field.selectionOptions.choices.find( (choice) => choice.id === choiceId, ); }); const selection = choices .map((choice) => { return `${choice.id}: ${choice.properties.displayName}`; }) .join(", "); console.log(`Selection: ${selection}`); break; } default: console.log("Unknown: %s", fieldValue.valueType); console.log(fieldValue.value); } } } } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } // [END apps_script_drive_labels_list_labels_on_drive_item] ================================================ FILE: advanced/events.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START events_create_subscription] /** * Creates a subscription to receive events about a Google Workspace resource. * For a list of supported resources and event types, see the * [Google Workspace Events API Overview](https://developers.google.com/workspace/events#supported-events). * For additional information, see the * [subscriptions.create](https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/create) * method reference. * @param {!string} targetResource The full resource name of the Google Workspace resource to subscribe to. * @param {!string|!Array} eventTypes The types of events to receive about the resource. * @param {!string} pubsubTopic The resource name of the Pub/Sub topic that receives events from the subscription. */ function createSubscription(targetResource, eventTypes, pubsubTopic) { try { const operation = WorkspaceEvents.Subscriptions.create({ targetResource: targetResource, eventTypes: eventTypes, notificationEndpoint: { pubsubTopic: pubsubTopic, }, }); console.log(operation); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to create subscription with error %s", err.message); } } // [END events_create_subscription] // [START events_list_subscriptions] /** * Lists subscriptions created by the calling app filtered by one or more event types and optionally by a target resource. * For additional information, see the * [subscriptions.list](https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/list) * method reference. * @param {!string} filter The query filter. */ function listSubscriptions(filter) { try { const response = WorkspaceEvents.Subscriptions.list({ filter }); console.log(response); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to list subscriptions with error %s", err.message); } } // [END events_list_subscriptions] // [START events_get_subscription] /** * Gets details about a subscription. * For additional information, see the * [subscriptions.get](https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/get) * method reference. * @param {!string} name The resource name of the subscription. */ function getSubscription(name) { try { const subscription = WorkspaceEvents.Subscriptions.get(name); console.log(subscription); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to get subscription with error %s", err.message); } } // [END events_get_subscription] // [START events_patch_subscription] /** * Updates an existing subscription. * This can be used to renew a subscription that is about to expire. * For additional information, see the * [subscriptions.patch](https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/patch) * method reference. * @param {!string} name The resource name of the subscription. */ function patchSubscription(name) { try { const operation = WorkspaceEvents.Subscriptions.patch( { // Setting the TTL to 0 seconds extends the subscription to its maximum expiration time. ttl: "0s", }, name, ); console.log(operation); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to update subscription with error %s", err.message); } } // [END events_patch_subscription] // [START events_reactivate_subscription] /** * Reactivates a suspended subscription. * Before reactivating, you must resolve any errors with the subscription. * For additional information, see the * [subscriptions.reactivate](https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/reactivate) * method reference. * @param {!string} name The resource name of the subscription. */ function reactivateSubscription(name) { try { const operation = WorkspaceEvents.Subscriptions.reactivate({}, name); console.log(operation); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to reactivate subscription with error %s", err.message); } } // [END events_reactivate_subscription] // [START events_delete_subscription] /** * Deletes a subscription. * For additional information, see the * [subscriptions.delete](https://developers.google.com/workspace/events/reference/rest/v1/subscriptions/delete) * method reference. * @param {!string} name The resource name of the subscription. */ function deleteSubscription(name) { try { const operation = WorkspaceEvents.Subscriptions.remove(name); console.log(operation); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to delete subscription with error %s", err.message); } } // [END events_delete_subscription] // [START events_get_operation] /** * Gets details about an operation returned by one of the methods on the subscription * resource of the Google Workspace Events API. * For additional information, see the * [operations.get](https://developers.google.com/workspace/events/reference/rest/v1/operations/get) * method reference. * @param {!string} name The resource name of the operation. */ function getOperation(name) { try { const operation = WorkspaceEvents.Operations.get(name); console.log(operation); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to get operation with error %s", err.message); } } // [END events_get_operation] ================================================ FILE: advanced/gmail.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START gmail_label] /** * Lists the user's labels, including name, type, * ID and visibility information. */ function listLabelInfo() { try { const response = Gmail.Users.Labels.list("me"); for (let i = 0; i < response.labels.length; i++) { const label = response.labels[i]; console.log(JSON.stringify(label)); } } catch (err) { console.log(err); } } // [END gmail_label] // [START gmail_inbox_snippets] /** * Lists, for each thread in the user's Inbox, a * snippet associated with that thread. */ function listInboxSnippets() { try { let pageToken; do { const threadList = Gmail.Users.Threads.list("me", { q: "label:inbox", pageToken: pageToken, }); if (threadList.threads && threadList.threads.length > 0) { for (const thread of threadList.threads) { console.log(`Snippet: ${thread.snippet}`); } } pageToken = threadList.nextPageToken; } while (pageToken); } catch (err) { console.log(err); } } // [END gmail_inbox_snippets] // [START gmail_history] /** * Gets a history record ID associated with the most * recently sent message, then logs all the message IDs * that have changed since that message was sent. */ function logRecentHistory() { try { // Get the history ID associated with the most recent // sent message. const sent = Gmail.Users.Threads.list("me", { q: "label:sent", maxResults: 1, }); if (!sent.threads || !sent.threads[0]) { console.log("No sent threads found."); return; } const historyId = sent.threads[0].historyId; // Log the ID of each message changed since the most // recent message was sent. let pageToken; const changed = []; do { const recordList = Gmail.Users.History.list("me", { startHistoryId: historyId, pageToken: pageToken, }); const history = recordList.history; if (history && history.length > 0) { for (const record of history) { for (const message of record.messages) { if (changed.indexOf(message.id) === -1) { changed.push(message.id); } } } } pageToken = recordList.nextPageToken; } while (pageToken); for (const id of changed) { console.log("Message Changed: %s", id); } } catch (err) { console.log(err); } } // [END gmail_history] // [START gmail_raw] /** * Logs the raw message content for the most recent message in gmail. */ function getRawMessage() { try { const messageId = Gmail.Users.Messages.list("me").messages[0].id; console.log(messageId); const message = Gmail.Users.Messages.get("me", messageId, { format: "raw", }); // Get raw content as base64url encoded string. const encodedMessage = Utilities.base64Encode(message.raw); console.log(encodedMessage); } catch (err) { console.log(err); } } // [END gmail_raw] // [START gmail_list_messages] /** * Lists unread messages in the user's inbox using the advanced Gmail service. */ function listMessages() { // The special value 'me' indicates the authenticated user. const userId = "me"; // Define optional parameters for the request. const options = { maxResults: 10, // Limit the number of messages returned. q: "is:unread", // Search for unread messages. }; try { // Call the Gmail.Users.Messages.list method. const response = Gmail.Users.Messages.list(userId, options); const messages = response.messages; console.log("Unread Messages:"); for (const message of messages) { console.log(`- Message ID: ${message.id}`); } } catch (err) { // Log any errors to the Apps Script execution log. console.log(`Failed with error: ${err.message}`); } } // [END gmail_list_messages] ================================================ FILE: advanced/iot.gs ================================================ /** * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_iot_list_registries] /** * Lists the registries for the configured project and region. */ function listRegistries() { const projectId = "your-project-id"; const cloudRegion = "us-central1"; const parent = `projects/${projectId}/locations/${cloudRegion}`; const response = CloudIoT.Projects.Locations.Registries.list(parent); console.log(response); if (response.deviceRegistries) { for (const registry of response.deviceRegistries) { console.log(registry.id); } } } // [END apps_script_iot_list_registries] // [START apps_script_iot_create_registry] /** * Creates a registry. */ function createRegistry() { const cloudRegion = "us-central1"; const name = "your-registry-name"; const projectId = "your-project-id"; const topic = "your-pubsub-topic"; const pubsubTopic = `projects/${projectId}/topics/${topic}`; const registry = { eventNotificationConfigs: [ { // From - https://console.cloud.google.com/cloudpubsub pubsubTopicName: pubsubTopic, }, ], id: name, }; const parent = `projects/${projectId}/locations/${cloudRegion}`; const response = CloudIoT.Projects.Locations.Registries.create( registry, parent, ); console.log(`Created registry: ${response.id}`); } // [END apps_script_iot_create_registry] // [START apps_script_iot_get_registry] /** * Describes a registry. */ function getRegistry() { const cloudRegion = "us-central1"; const name = "your-registry-name"; const projectId = "your-project-id"; const parent = `projects/${projectId}/locations/${cloudRegion}`; const registryName = `${parent}/registries/${name}`; const response = CloudIoT.Projects.Locations.Registries.get(registryName); console.log(`Retrieved registry: ${response.id}`); } // [END apps_script_iot_get_registry] // [START apps_script_iot_delete_registry] /** * Deletes a registry. */ function deleteRegistry() { const cloudRegion = "us-central1"; const name = "your-registry-name"; const projectId = "your-project-id"; const parent = `projects/${projectId}/locations/${cloudRegion}`; const registryName = `${parent}/registries/${name}`; const response = CloudIoT.Projects.Locations.Registries.remove(registryName); // Successfully removed registry if exception was not thrown. console.log(`Deleted registry: ${name}`); } // [END apps_script_iot_delete_registry] // [START apps_script_iot_list_devices] /** * Lists the devices in the given registry. */ function listDevicesForRegistry() { const cloudRegion = "us-central1"; const name = "your-registry-name"; const projectId = "your-project-id"; const parent = `projects/${projectId}/locations/${cloudRegion}`; const registryName = `${parent}/registries/${name}`; const response = CloudIoT.Projects.Locations.Registries.Devices.list(registryName); console.log("Registry contains the following devices: "); if (response.devices) { for (const device of response.devices) { console.log(`\t${device.id}`); } } } // [END apps_script_iot_list_devices] // [START apps_script_iot_create_unauth_device] /** * Creates a device without credentials. */ function createDevice() { const cloudRegion = "us-central1"; const name = "your-device-name"; const projectId = "your-project-id"; const registry = "your-registry-name"; console.log(`Creating device: ${name} in Registry: ${registry}`); const parent = `projects/${projectId}/locations/${cloudRegion}/registries/${registry}`; const device = { id: name, gatewayConfig: { gatewayType: "NON_GATEWAY", gatewayAuthMethod: "ASSOCIATION_ONLY", }, }; const response = CloudIoT.Projects.Locations.Registries.Devices.create( device, parent, ); console.log(`Created device:${response.name}`); } // [END apps_script_iot_create_unauth_device] // [START apps_script_iot_create_rsa_device] /** * Creates a device with RSA credentials. */ function createRsaDevice() { // Create the RSA public/private keypair with the following OpenSSL command: // openssl req -x509 -newkey rsa:2048 -days 3650 -keyout rsa_private.pem \ // -nodes -out rsa_cert.pem -subj "/CN=unused" // // **NOTE** Be sure to insert the newline charaters in the string constant. const cert = "-----BEGIN CERTIFICATE-----\n" + "your-PUBLIC-certificate-b64-bytes\n" + "...\n" + "more-PUBLIC-certificate-b64-bytes==\n" + "-----END CERTIFICATE-----\n"; const cloudRegion = "us-central1"; const name = "your-device-name"; const projectId = "your-project-id"; const registry = "your-registry-name"; const parent = `projects/${projectId}/locations/${cloudRegion}/registries/${registry}`; const device = { id: name, gatewayConfig: { gatewayType: "NON_GATEWAY", gatewayAuthMethod: "ASSOCIATION_ONLY", }, credentials: [ { publicKey: { format: "RSA_X509_PEM", key: cert, }, }, ], }; const response = CloudIoT.Projects.Locations.Registries.Devices.create( device, parent, ); console.log(`Created device:${response.name}`); } // [END apps_script_iot_create_rsa_device] // [START apps_script_iot_delete_device] /** * Deletes a device from the given registry. */ function deleteDevice() { const cloudRegion = "us-central1"; const name = "your-device-name"; const projectId = "your-project-id"; const registry = "your-registry-name"; const parent = `projects/${projectId}/locations/${cloudRegion}/registries/${registry}`; const deviceName = `${parent}/devices/${name}`; const response = CloudIoT.Projects.Locations.Registries.Devices.remove(deviceName); // If no exception thrown, device was successfully removed console.log(`Successfully deleted device: ${deviceName}`); } // [END apps_script_iot_delete_device] ================================================ FILE: advanced/people.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START people_get_connections] /** * Gets a list of people in the user's contacts. * @see https://developers.google.com/people/api/rest/v1/people.connections/list */ function getConnections() { try { // Get the list of connections/contacts of user's profile const people = People.People.Connections.list("people/me", { personFields: "names,emailAddresses", }); // Print the connections/contacts console.log("Connections: %s", JSON.stringify(people, null, 2)); } catch (err) { // TODO (developers) - Handle exception here console.log("Failed to get the connection with an error %s", err.message); } } // [END people_get_connections] // [START people_get_self_profile] /** * Gets the own user's profile. * @see https://developers.google.com/people/api/rest/v1/people/getBatchGet */ function getSelf() { try { // Get own user's profile using People.getBatchGet() method const people = People.People.getBatchGet({ resourceNames: ["people/me"], personFields: "names,emailAddresses", // Use other query parameter here if needed }); console.log("Myself: %s", JSON.stringify(people, null, 2)); } catch (err) { // TODO (developer) -Handle exception console.log("Failed to get own profile with an error %s", err.message); } } // [END people_get_self_profile] // [START people_get_account] /** * Gets the person information for any Google Account. * @param {string} accountId The account ID. * @see https://developers.google.com/people/api/rest/v1/people/get */ function getAccount(accountId) { try { // Get the Account details using account ID. const people = People.People.get(`people/${accountId}`, { personFields: "names,emailAddresses", }); // Print the profile details of Account. console.log("Public Profile: %s", JSON.stringify(people, null, 2)); } catch (err) { // TODO (developer) - Handle exception console.log("Failed to get account with an error %s", err.message); } } // [END people_get_account] // [START people_get_group] /** * Gets a contact group with the given name * @param {string} name The group name. * @see https://developers.google.com/people/api/rest/v1/contactGroups/list */ function getContactGroup(name) { try { const people = People.ContactGroups.list(); // Finds the contact group for the person where the name matches. const group = people.contactGroups.find((group) => group.name === name); // Prints the contact group console.log("Group: %s", JSON.stringify(group, null, 2)); } catch (err) { // TODO (developers) - Handle exception console.log( "Failed to get the contact group with an error %s", err.message, ); } } // [END people_get_group] // [START people_get_contact_by_email] /** * Gets a contact by the email address. * @param {string} email The email address. * @see https://developers.google.com/people/api/rest/v1/people.connections/list */ function getContactByEmail(email) { try { // Gets the person with that email address by iterating over all contacts. const people = People.People.Connections.list("people/me", { personFields: "names,emailAddresses", }); const contact = people.connections.find((connection) => { return connection.emailAddresses.some( (emailAddress) => emailAddress.value === email, ); }); // Prints the contact. console.log("Contact: %s", JSON.stringify(contact, null, 2)); } catch (err) { // TODO (developers) - Handle exception console.log("Failed to get the connection with an error %s", err.message); } } // [END people_get_contact_by_email] // [START people_get_full_name] /** * Gets the full name (given name and last name) of the contact as a string. * @see https://developers.google.com/people/api/rest/v1/people/get */ function getFullName() { try { // Gets the person by specifying resource name/account ID // in the first parameter of People.People.get. // This example gets the person for the user running the script. const people = People.People.get("people/me", { personFields: "names" }); // Prints the full name (given name + family name) console.log(`${people.names[0].givenName} ${people.names[0].familyName}`); } catch (err) { // TODO (developers) - Handle exception console.log("Failed to get the connection with an error %s", err.message); } } // [END people_get_full_name] // [START people_get_phone_numbers] /** * Gets all the phone numbers for this contact. * @see https://developers.google.com/people/api/rest/v1/people/get */ function getPhoneNumbers() { try { // Gets the person by specifying resource name/account ID // in the first parameter of People.People.get. // This example gets the person for the user running the script. const people = People.People.get("people/me", { personFields: "phoneNumbers", }); // Prints the phone numbers. console.log(people.phoneNumbers); } catch (err) { // TODO (developers) - Handle exception console.log("Failed to get the connection with an error %s", err.message); } } // [END people_get_phone_numbers] // [START people_get_single_phone_number] /** * Gets a phone number by type, such as work or home. * @see https://developers.google.com/people/api/rest/v1/people/get */ function getPhone() { try { // Gets the person by specifying resource name/account ID // in the first parameter of People.People.get. // This example gets the person for the user running the script. const people = People.People.get("people/me", { personFields: "phoneNumbers", }); // Gets phone number by type, such as home or work. const phoneNumber = people.phoneNumbers.find( (phone) => phone.type === "home", ).value; // Prints the phone numbers. console.log(phoneNumber); } catch (err) { // TODO (developers) - Handle exception console.log("Failed to get the connection with an error %s", err.message); } } // [END people_get_single_phone_number] ================================================ FILE: advanced/sheets.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // TODO (developer)- Replace the spreadsheet ID and sheet ID with yours values. const yourspreadsheetId = "1YdrrmXSjpi4Tz-UuQ0eUKtdzQuvpzRLMoPEz3niTTVU"; const yourpivotSourceDataSheetId = 635809130; const yourdestinationSheetId = 83410180; // [START sheets_read_range] /** * Read a range (A1:D5) of data values. Logs the values. * @param {string} spreadsheetId The spreadsheet ID to read from. * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get */ function readRange(spreadsheetId = yourspreadsheetId) { try { const response = Sheets.Spreadsheets.Values.get( spreadsheetId, "Sheet1!A1:D5", ); if (response.values) { console.log(response.values); return; } console.log("Failed to get range of values from spreadsheet"); } catch (e) { // TODO (developer) - Handle exception console.log("Failed with error %s", e.message); } } // [END sheets_read_range] // [START sheets_write_range] /** * Write to multiple, disjoint data ranges. * @param {string} spreadsheetId The spreadsheet ID to write to. * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate */ function writeToMultipleRanges(spreadsheetId = yourspreadsheetId) { // Specify some values to write to the sheet. const columnAValues = [["Item", "Wheel", "Door", "Engine"]]; const rowValues = [ ["Cost", "Stocked", "Ship Date"], ["$20.50", "4", "3/1/2016"], ]; const request = { valueInputOption: "USER_ENTERED", data: [ { range: "Sheet1!A1:A4", majorDimension: "COLUMNS", values: columnAValues, }, { range: "Sheet1!B1:D2", majorDimension: "ROWS", values: rowValues, }, ], }; try { const response = Sheets.Spreadsheets.Values.batchUpdate( request, spreadsheetId, ); if (response) { console.log(response); return; } console.log("response null"); } catch (e) { // TODO (developer) - Handle exception console.log("Failed with error %s", e.message); } } // [END sheets_write_range] // [START sheets_add_new_sheet] /** * Add a new sheet with some properties. * @param {string} spreadsheetId The spreadsheet ID. * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/batchUpdate */ function addSheet(spreadsheetId = yourspreadsheetId) { const requests = [ { addSheet: { properties: { title: "Deposits", gridProperties: { rowCount: 20, columnCount: 12, }, tabColor: { red: 1.0, green: 0.3, blue: 0.4, }, }, }, }, ]; try { const response = Sheets.Spreadsheets.batchUpdate( { requests: requests }, spreadsheetId, ); console.log( `Created sheet with ID: ${response.replies[0].addSheet.properties.sheetId}`, ); } catch (e) { // TODO (developer) - Handle exception console.log("Failed with error %s", e.message); } } // [END sheets_add_new_sheet] // [START sheets_add_pivot_table] /** * Add a pivot table. * @param {string} spreadsheetId The spreadsheet ID to add the pivot table to. * @param {string} pivotSourceDataSheetId The sheet ID to get the data from. * @param {string} destinationSheetId The sheet ID to add the pivot table to. * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/batchUpdate */ function addPivotTable( spreadsheetId = yourspreadsheetId, pivotSourceDataSheetId = yourpivotSourceDataSheetId, destinationSheetId = yourdestinationSheetId, ) { const requests = [ { updateCells: { rows: { values: [ { pivotTable: { source: { sheetId: pivotSourceDataSheetId, startRowIndex: 0, startColumnIndex: 0, endRowIndex: 20, endColumnIndex: 7, }, rows: [ { sourceColumnOffset: 0, showTotals: true, sortOrder: "ASCENDING", valueBucket: { buckets: [ { stringValue: "West", }, ], }, }, { sourceColumnOffset: 1, showTotals: true, sortOrder: "DESCENDING", valueBucket: {}, }, ], columns: [ { sourceColumnOffset: 4, sortOrder: "ASCENDING", showTotals: true, valueBucket: {}, }, ], values: [ { summarizeFunction: "SUM", sourceColumnOffset: 3, }, ], valueLayout: "HORIZONTAL", }, }, ], }, start: { sheetId: destinationSheetId, rowIndex: 49, columnIndex: 0, }, fields: "pivotTable", }, }, ]; try { const response = Sheets.Spreadsheets.batchUpdate( { requests: requests }, spreadsheetId, ); // The Pivot table will appear anchored to cell A50 of the destination sheet. } catch (e) { // TODO (developer) - Handle exception console.log("Failed with error %s", e.message); } } // [END sheets_add_pivot_table] ================================================ FILE: advanced/shoppingContent.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_shopping_product_insert] /** * Inserts a product into the products list. Logs the API response. */ function productInsert() { const merchantId = 123456; // Replace this with your Merchant Center ID. // Create a product resource and insert it const productResource = { offerId: "book123", title: "A Tale of Two Cities", description: "A classic novel about the French Revolution", link: "http://my-book-shop.com/tale-of-two-cities.html", imageLink: "http://my-book-shop.com/tale-of-two-cities.jpg", contentLanguage: "en", targetCountry: "US", channel: "online", availability: "in stock", condition: "new", googleProductCategory: "Media > Books", productType: "Media > Books", gtin: "9780007350896", price: { value: "2.50", currency: "USD", }, shipping: [ { country: "US", service: "Standard shipping", price: { value: "0.99", currency: "USD", }, }, ], shippingWeight: { value: "2", unit: "pounds", }, }; try { response = ShoppingContent.Products.insert(productResource, merchantId); // RESTful insert returns the JSON object as a response. console.log(response); } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_shopping_product_insert] // [START apps_script_shopping_product_list] /** * Lists the products for a given merchant. */ function productList() { const merchantId = 123456; // Replace this with your Merchant Center ID. let pageToken; let pageNum = 1; const maxResults = 10; try { do { const products = ShoppingContent.Products.list(merchantId, { pageToken: pageToken, maxResults: maxResults, }); console.log(`Page ${pageNum}`); if (products.resources) { for (let i = 0; i < products.resources.length; i++) { console.log(`Item [${i}] ==> ${products.resources[i]}`); } } else { console.log(`No more products in account ${merchantId}`); } pageToken = products.nextPageToken; pageNum++; } while (pageToken); } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_shopping_product_list] // [START apps_script_shopping_product_batch_insert] /** * Batch updates products. Logs the response. * @param {object} productResource1 The first product resource. * @param {object} productResource2 The second product resource. * @param {object} productResource3 The third product resource. */ function custombatch(productResource1, productResource2, productResource3) { const merchantId = 123456; // Replace this with your Merchant Center ID. custombatchResource = { entries: [ { batchId: 1, merchantId: merchantId, method: "insert", productId: "book124", product: productResource1, }, { batchId: 2, merchantId: merchantId, method: "insert", productId: "book125", product: productResource2, }, { batchId: 3, merchantId: merchantId, method: "insert", productId: "book126", product: productResource3, }, ], }; try { const response = ShoppingContent.Products.custombatch(custombatchResource); console.log(response); } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_shopping_product_batch_insert] // [START apps_script_shopping_account_info] /** * Updates content account tax information. * Logs the API response. */ function updateAccountTax() { // Replace this with your Merchant Center ID. const merchantId = 123456; // Replace this with the account that you are updating taxes for. const accountId = 123456; try { const accounttax = ShoppingContent.Accounttax.get(merchantId, accountId); console.log(accounttax); const taxInfo = { accountId: accountId, rules: [ { useGlobalRate: true, locationId: 21135, shippingTaxed: true, country: "US", }, { ratePercent: 3, locationId: 21136, country: "US", }, { ratePercent: 2, locationId: 21160, shippingTaxed: true, country: "US", }, ], }; console.log( ShoppingContent.Accounttax.update(taxInfo, merchantId, accountId), ); } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_shopping_account_info] ================================================ FILE: advanced/slides.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_slides_create_presentation] /** * Create a new presentation. * @return {string} presentation Id. * @see https://developers.google.com/slides/api/reference/rest/v1/presentations/create */ function createPresentation() { try { const presentation = Slides.Presentations.create({ title: "MyNewPresentation", }); console.log(`Created presentation with ID: ${presentation.presentationId}`); return presentation.presentationId; } catch (e) { // TODO (developer) - Handle exception console.log("Failed with error %s", e.message); } } // [END apps_script_slides_create_presentation] // [START apps_script_slides_create_slide] /** * Create a new slide. * @param {string} presentationId The presentation to add the slide to. * @return {Object} slide * @see https://developers.google.com/slides/api/reference/rest/v1/presentations/batchUpdate */ function createSlide(presentationId) { // You can specify the ID to use for the slide, as long as it's unique. const pageId = Utilities.getUuid(); const requests = [ { createSlide: { objectId: pageId, insertionIndex: 1, slideLayoutReference: { predefinedLayout: "TITLE_AND_TWO_COLUMNS", }, }, }, ]; try { const slide = Slides.Presentations.batchUpdate( { requests: requests }, presentationId, ); console.log( `Created Slide with ID: ${slide.replies[0].createSlide.objectId}`, ); return slide; } catch (e) { // TODO (developer) - Handle Exception console.log("Failed with error %s", e.message); } } // [END apps_script_slides_create_slide] // [START apps_script_slides_read_page] /** * Read page element IDs. * @param {string} presentationId The presentation to read from. * @param {string} pageId The page to read from. * @return {Object} response * @see https://developers.google.com/slides/api/reference/rest/v1/presentations.pages/get */ function readPageElementIds(presentationId, pageId) { // You can use a field mask to limit the data the API retrieves // in a get request, or what fields are updated in an batchUpdate. try { const response = Slides.Presentations.Pages.get(presentationId, pageId, { fields: "pageElements.objectId", }); console.log(response); return response; } catch (e) { // TODO (developer) - Handle Exception console.log("Failed with error %s", e.message); } } // [END apps_script_slides_read_page] // [START apps_script_slides_add_text_box] /** * Add a new text box with text to a page. * @param {string} presentationId The presentation ID. * @param {string} pageId The page ID. * @return {Object} response * @see https://developers.google.com/slides/api/reference/rest/v1/presentations/batchUpdate */ function addTextBox(presentationId, pageId) { // You can specify the ID to use for elements you create, // as long as the ID is unique. const pageElementId = Utilities.getUuid(); const requests = [ { createShape: { objectId: pageElementId, shapeType: "TEXT_BOX", elementProperties: { pageObjectId: pageId, size: { width: { magnitude: 150, unit: "PT", }, height: { magnitude: 50, unit: "PT", }, }, transform: { scaleX: 1, scaleY: 1, translateX: 200, translateY: 100, unit: "PT", }, }, }, }, { insertText: { objectId: pageElementId, text: "My Added Text Box", insertionIndex: 0, }, }, ]; try { const response = Slides.Presentations.batchUpdate( { requests: requests }, presentationId, ); console.log( `Created Textbox with ID: ${response.replies[0].createShape.objectId}`, ); return response; } catch (e) { // TODO (developer) - Handle Exception console.log("Failed with error %s", e.message); } } // [END apps_script_slides_add_text_box] // [START apps_script_slides_format_shape_text] /** * Format the text in a shape. * @param {string} presentationId The presentation ID. * @param {string} shapeId The shape ID. * @return {Object} replies * @see https://developers.google.com/slides/api/reference/rest/v1/presentations/batchUpdate */ function formatShapeText(presentationId, shapeId) { const requests = [ { updateTextStyle: { objectId: shapeId, fields: "foregroundColor,bold,italic,fontFamily,fontSize,underline", style: { foregroundColor: { opaqueColor: { themeColor: "ACCENT5", }, }, bold: true, italic: true, underline: true, fontFamily: "Corsiva", fontSize: { magnitude: 18, unit: "PT", }, }, textRange: { type: "ALL", }, }, }, ]; try { const response = Slides.Presentations.batchUpdate( { requests: requests }, presentationId, ); return response.replies; } catch (e) { // TODO (developer) - Handle Exception console.log("Failed with error %s", e.message); } } // [END apps_script_slides_format_shape_text] // [START apps_script_slides_save_thumbnail] /** * Saves a thumbnail image of the current Google Slide presentation in Google Drive. * Logs the image URL. * @param {number} i The zero-based slide index. 0 is the first slide. * @example saveThumbnailImage(0) * @see https://developers.google.com/slides/api/reference/rest/v1/presentations.pages/getThumbnail */ function saveThumbnailImage(i) { try { const presentation = SlidesApp.getActivePresentation(); // Get the thumbnail of specified page const thumbnail = Slides.Presentations.Pages.getThumbnail( presentation.getId(), presentation.getSlides()[i].getObjectId(), ); // fetch the URL to the thumbnail image. const response = UrlFetchApp.fetch(thumbnail.contentUrl); const image = response.getBlob(); // Creates a file in the root of the user's Drive from a given Blob of arbitrary data. const file = DriveApp.createFile(image); console.log(file.getUrl()); } catch (e) { // TODO (developer) - Handle Exception console.log("Failed with error %s", e.message); } } // [END apps_script_slides_save_thumbnail] ================================================ FILE: advanced/tagManager.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_tag_manager_create_version] /** * Creates a container version for a particular account * with the input accountPath. * @param {string} accountPath The account path. * @return {string} The tag manager container version. */ function createContainerVersion(accountPath) { const date = new Date(); // Creates a container in the account, using the current timestamp to make // sure the container is unique. try { const container = TagManager.Accounts.Containers.create( { name: `appscript tagmanager container ${date.getTime()}`, usageContext: ["WEB"], }, accountPath, ); const containerPath = container.path; // Creates a workspace in the container to track entity changes. const workspace = TagManager.Accounts.Containers.Workspaces.create( { name: "appscript workspace", description: "appscript workspace" }, containerPath, ); const workspacePath = workspace.path; // Creates a random value variable. const variable = TagManager.Accounts.Containers.Workspaces.Variables.create( { name: "apps script variable", type: "r" }, workspacePath, ); // Creates a trigger that fires on any page view. const trigger = TagManager.Accounts.Containers.Workspaces.Triggers.create( { name: "apps script trigger", type: "PAGEVIEW" }, workspacePath, ); // Creates a arbitary pixel that fires the tag on all page views. const tag = TagManager.Accounts.Containers.Workspaces.Tags.create( { name: "apps script tag", type: "img", liveOnly: false, parameter: [ { type: "boolean", key: "useCacheBuster", value: "true" }, { type: "template", key: "cacheBusterQueryParam", value: "gtmcb", }, { type: "template", key: "url", value: "//example.com" }, ], firingTriggerId: [trigger.triggerId], }, workspacePath, ); // Creates a container version with the variabe, trigger, and tag. const version = TagManager.Accounts.Containers.Workspaces.create_version( { name: "apps script version" }, workspacePath, ).containerVersion; console.log(version); return version; } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_tag_manager_create_version] // [START apps_script_tag_manager_publish_version] /** * Publishes a container version publically to the world and creates a quick * preview of the current container draft. * @param {object} version The container version. */ function publishVersionAndQuickPreviewDraft(version) { try { const pathParts = version.path.split("/"); const containerPath = pathParts.slice(0, 4).join("/"); // Publish the input container version. TagManager.Accounts.Containers.Versions.publish(version.path); const workspace = TagManager.Accounts.Containers.Workspaces.create( { name: "appscript workspace", description: "appscript workspace" }, containerPath, ); const workspaceId = workspace.path; // Quick previews the current container draft. const quickPreview = TagManager.Accounts.Containers.Workspaces.quick_preview(workspace.path); console.log(quickPreview); } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_tag_manager_publish_version] // [START apps_script_tag_manager_create_user_environment] /** * Creates and reauthorizes a user environment in a container that points * to a container version passed in as an argument. * @param {object} version The container version object. */ function createAndReauthorizeUserEnvironment(version) { try { // Creates a container version. const pathParts = version.path.split("/"); const containerPath = pathParts.slice(0, 4).join("/"); // Creates a user environment that points to a container version. const environment = TagManager.Accounts.Containers.Environments.create( { name: "test_environment", type: "user", containerVersionId: version.containerVersionId, }, containerPath, ); console.log(`Original user environment: ${environment}`); // Reauthorizes the user environment that points to a container version. TagManager.Accounts.Containers.Environments.reauthorize( {}, environment.path, ); console.log(`Reauthorized user environment: ${environment}`); } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_tag_manager_create_user_environment] // [START apps_script_tag_manager_log] /** * Logs all emails and container access permission within an account. * @param {string} accountPath The account path. */ function logAllAccountUserPermissionsWithContainerAccess(accountPath) { try { const userPermissions = TagManager.Accounts.User_permissions.list(accountPath).userPermission; for (let i = 0; i < userPermissions.length; i++) { const userPermission = userPermissions[i]; if ("emailAddress" in userPermission) { const containerAccesses = userPermission.containerAccess; for (let j = 0; j < containerAccesses.length; j++) { const containerAccess = containerAccesses[j]; console.log( `emailAddress:${userPermission.emailAddress} containerId:${containerAccess.containerId} containerAccess:${containerAccess.permission}`, ); } } } } catch (e) { // TODO (Developer) - Handle exceptions console.log("Failed with error: $s", e.error); } } // [END apps_script_tag_manager_log] ================================================ FILE: advanced/tasks.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START tasks_lists_task_lists] /** * Lists the titles and IDs of tasksList. * @see https://developers.google.com/tasks/reference/rest/v1/tasklists/list */ function listTaskLists() { try { // Returns all the authenticated user's task lists. const taskLists = Tasks.Tasklists.list(); // If taskLists are available then print all tasklists. if (!taskLists.items) { console.log("No task lists found."); return; } // Print the tasklist title and tasklist id. for (let i = 0; i < taskLists.items.length; i++) { const taskList = taskLists.items[i]; console.log( 'Task list with title "%s" and ID "%s" was found.', taskList.title, taskList.id, ); } } catch (err) { // TODO (developer) - Handle exception from Task API console.log("Failed with an error %s ", err.message); } } // [END tasks_lists_task_lists] // [START tasks_list_tasks] /** * Lists task items for a provided tasklist ID. * @param {string} taskListId The tasklist ID. * @see https://developers.google.com/tasks/reference/rest/v1/tasks/list */ function listTasks(taskListId) { try { // List the task items of specified tasklist using taskList id. const tasks = Tasks.Tasks.list(taskListId); // If tasks are available then print all task of given tasklists. if (!tasks.items) { console.log("No tasks found."); return; } // Print the task title and task id of specified tasklist. for (let i = 0; i < tasks.items.length; i++) { const task = tasks.items[i]; console.log( 'Task with title "%s" and ID "%s" was found.', task.title, task.id, ); } } catch (err) { // TODO (developer) - Handle exception from Task API console.log("Failed with an error %s", err.message); } } // [END tasks_list_tasks] // [START tasks_add_task] /** * Adds a task to a tasklist. * @param {string} taskListId The tasklist to add to. * @see https://developers.google.com/tasks/reference/rest/v1/tasks/insert */ function addTask(taskListId) { // Task details with title and notes for inserting new task let task = { title: "Pick up dry cleaning", notes: "Remember to get this done!", }; try { // Call insert method with taskDetails and taskListId to insert Task to specified tasklist. task = Tasks.Tasks.insert(task, taskListId); // Print the Task ID of created task. console.log('Task with ID "%s" was created.', task.id); } catch (err) { // TODO (developer) - Handle exception from Tasks.insert() of Task API console.log("Failed with an error %s", err.message); } } // [END tasks_add_task] ================================================ FILE: advanced/test_adminSDK.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests listAllUsers function of adminSDK.gs */ function itShouldListAllUsers() { console.log("> itShouldListAllUsers"); listAllUsers(); } /** * Tests getUser function of adminSDK.gs */ function itShouldGetUser() { console.log("> itShouldGetUser"); getUser(); } /** * Tests addUser function of adminSDK.gs */ function itShouldAddUser() { console.log("> itShouldAddUser"); addUser(); } /** * Tests createAlias function of adminSDK.gs */ function itShouldCreateAlias() { console.log("> itShouldCreateAlias"); createAlias(); } /** * Tests listAllGroups function of adminSDK.gs */ function itShouldListAllGroups() { console.log("> itShouldListAllGroups"); listAllGroups(); } /** * Tests addGroupMember function of adminSDK.gs */ function itShouldAddGroupMember() { console.log("> itShouldAddGroupMember"); addGroupMember(); } /** * Tests migrateMessages function of adminSDK.gs */ function itShouldMigrateMessages() { console.log("> itShouldMigrateMessages"); migrateMessages(); } /** * Tests getGroupSettings function of adminSDK.gs */ function itShouldGetGroupSettings() { console.log("> itShouldGetGroupSettings"); getGroupSettings(); } /** * Tests updateGroupSettings function of adminSDK.gs */ function itShouldUpdateGroupSettings() { console.log("> itShouldUpdateGroupSettings"); updateGroupSettings(); } /** * Tests getLicenseAssignments function of adminSDK.gs */ function itShouldGetLicenseAssignments() { console.log("> itShouldGetLicenseAssignments"); getLicenseAssignments(); } /** * Tests insertLicenseAssignment function of adminSDK.gs */ function itShouldInsertLicenseAssignment() { console.log("> itShouldInsertLicenseAssignment"); insertLicenseAssignment(); } /** * Tests generateLoginActivityReport function of adminSDK.gs */ function itShouldGenerateLoginActivityReport() { console.log("> itShouldGenerateLoginActivityReport"); generateLoginActivityReport(); } /** * Tests generateUserUsageReport function of adminSDK.gs */ function itShouldGenerateUserUsageReport() { console.log("> itShouldGenerateUserUsageReport"); generateUserUsageReport(); } /** * Tests getSubscriptions function of adminSDK.gs */ function itShouldGetSubscriptions() { console.log("> itShouldGetSubscriptions"); getSubscriptions(); } /** * Runs all the tests */ function RUN_ALL_TESTS() { itShouldListAllUsers(); itShouldGetUser(); itShouldAddUser(); itShouldCreateAlias(); itShouldListAllGroups(); itShouldAddGroupMember(); itShouldMigrateMessages(); itShouldGetGroupSettings(); itShouldUpdateGroupSettings(); itShouldGetLicenseAssignments(); itShouldInsertLicenseAssignment(); itShouldGenerateLoginActivityReport(); itShouldGenerateUserUsageReport(); itShouldGetSubscriptions(); } ================================================ FILE: advanced/test_adsense.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Replace with correct values const accountName = "account name"; const clientName = "ad client name"; /** * Tests listAccounts function of adsense.gs */ function itShouldListAccounts() { console.log("> itShouldListAccounts"); listAccounts(); } /** * Tests listAdClients function of adsense.gs */ function itShouldListAdClients() { console.log("> itShouldListAdClients"); listAdClients(accountName); } /** * Tests listAdUnits function of adsense.gs */ function itShouldListAdUnits() { console.log("> itShouldListAdUnits"); listAdUnits(clientName); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldListAccounts(); itShouldListAdClients(); itShouldListAdUnits(); } ================================================ FILE: advanced/test_analytics.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Replace with the required profileId const profileId = "abcd"; /** * Tests listAccounts function of analytics.gs */ function itShouldListAccounts() { console.log("> itShouldListAccounts"); listAccounts(); } /** * Tests runReport function of analytics.gs */ function itShouldRunReport() { console.log("> itShouldRunReport"); runReport(profileId); } /** * Runs all the tests */ function RUN_ALL_TESTS() { itShouldListAccounts(); itShouldRunReport(); } ================================================ FILE: advanced/test_bigquery.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests runQuery function of adminSDK.gs */ function itShouldRunQuery() { console.log("> itShouldRunQuery"); runQuery(); } /** * Tests loadCsv function of adminSDK.gs */ function itShouldLoadCsv() { console.log("> itShouldLoadCsv"); loadCsv(); } /** * Runs all the tests */ function RUN_ALL_TESTS() { itShouldRunQuery(); itShouldLoadCsv(); } ================================================ FILE: advanced/test_calendar.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests listCalendars function of calendar.gs */ function itShouldListCalendars() { console.log("> itShouldListCalendars"); listCalendars(); } /** * Tests createEvent function of calendars.gs */ function itShouldCreateEvent() { console.log("> itShouldCreateEvent"); createEvent(); } /** * Tests gerRelativeDate function of calendar.gs */ function itShouldGetRelativeDate() { console.log("> itShouldGetRelativeDate"); console.log(`no offset: ${getRelativeDate(0, 0)}`); console.log(`4 hour offset: ${getRelativeDate(0, 4)}`); console.log(`1 day offset: ${getRelativeDate(1, 0)}`); console.log(`1 day and 3 hour off set: ${getRelativeDate(1, 3)}`); } /** * Tests listNext10Events function of calendar.gs */ function itShouldListNext10Events() { console.log("> itShouldListNext10Events"); listNext10Events(); } /** * Tests logSyncedEvents function of calendar.gs */ function itShouldLogSyncedEvents() { console.log("> itShouldLogSyncedEvents"); logSyncedEvents("primary", true); logSyncedEvents("primary", false); } /** * Tests conditionalUpdate function of calendar.gs */ function itShouldConditionalUpdate() { console.log("> itShouldConditionalUpdate (takes 30 seconds)"); conditionalUpdate(); } /** * Tests conditionalFetch function of calendar.gs */ function itShouldConditionalFetch() { console.log("> itShouldConditionalFetch"); conditionalFetch(); } /** * Runs all the tests */ function RUN_ALL_TESTS() { itShouldListCalendars(); itShouldCreateEvent(); itShouldGetRelativeDate(); itShouldListNext10Events(); itShouldLogSyncedEvents(); itShouldConditionalUpdate(); itShouldConditionalFetch(); } ================================================ FILE: advanced/test_classroom.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests listCourses function of classroom.gs */ function itShouldListCourses() { console.log("> itShouldListCourses"); listCourses(); } /** * Runs all the tests */ function RUN_ALL_TESTS() { itShouldListCourses(); } ================================================ FILE: advanced/test_displayvideo.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests listPartners function of displayvideo.gs */ function itShouldListPartners() { console.log("> itShouldListPartners"); listPartners(); } /** * Tests listActiveCampaigns function of displayvideo.gs */ function itShouldListActiveCampaigns() { console.log("> itShouldListActiveCampaigns"); listActiveCampaigns(); } /** * Tests updateLineItemName function of displayvideo.gs */ function itShouldUpdateLineItemName() { console.log("> itShouldUpdateLineItemName"); updateLineItemName(); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldListPartners(); itShouldListActiveCampaigns(); itShouldUpdateLineItemName(); } ================================================ FILE: advanced/test_docs.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // TODO (developer) - Replace with your documentId const documentId = "1EaLpBfuo3bMUeP6_P34auuQroh3bCWi6hLDppY6J6us"; /** * A simple exists assertion check. Expects a value to exist. Errors if DNE. * @param {any} value A value that is expected to exist. */ function expectToExist(value) { if (!value) { console.log("DNE"); return; } console.log("TEST: Exists"); } /** * A simple exists assertion check for primatives (no nested objects). * Expects actual to equal expected. Logs the output. * @param {any} expected The actual value. * @param {any} actual The expected value. */ function expectToEqual(expected, actual) { if (actual !== expected) { console.log("TEST: actual: %s = expected: %s", actual, expected); return; } console.log("TEST: actual: %s = expected: %s", actual, expected); } /** * Runs all tests. */ function RUN_ALL_TESTS() { itShouldCreateDocument(); itShouldInsertTextWithStyle(); itShouldReplaceText(); itShouldReadFirstParagraph(); } /** * Creates a presentation. */ function itShouldCreateDocument() { const documentId = createDocument(); expectToExist(documentId); deleteFileOnCleanup(documentId); } /** * Insert text with style. */ function itShouldInsertTextWithStyle() { const documentId = createDocument(); expectToExist(documentId); const text = "This is the sample document"; const replies = insertAndStyleText(documentId, text); expectToEqual(2, replies.length); deleteFileOnCleanup(documentId); } /** * Find and Replace the text. */ function itShouldReplaceText() { const documentId = createDocument(); expectToExist(documentId); const text = "This is the sample document"; const response = insertAndStyleText(documentId, text); expectToEqual(2, response.replies.length); const findTextToReplacementMap = { sample: "test", document: "Doc" }; const replies = findAndReplace(documentId, findTextToReplacementMap); expectToEqual(2, replies.length); deleteFileOnCleanup(documentId); } /** * Read first paragraph */ function itShouldReadFirstParagraph() { const paragraphText = readFirstParagraph(documentId); expectToExist(paragraphText); expectToEqual(89, paragraphText.length); } /** * Delete the file * @param {string} id Document ID */ function deleteFileOnCleanup(id) { Drive.Files.remove(id); } ================================================ FILE: advanced/test_doubleclick.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests listUserProfiles function of doubleclick.gs */ function itShouldListUserProfiles() { console.log("> itShouldListUserProfiles"); listUserProfiles(); } /** * Tests listActiveCampaigns function of doubleclick.gs */ function itShouldListActiveCampaigns() { console.log("> itShouldListActiveCampaigns"); listActiveCampaigns(); } /** * Tests createAdvertiserAndCampaign function of doubleclick.gs */ function itShouldCreateAdvertiserAndCampaign() { console.log("> itShouldCreateAdvertiserAndCampaign"); createAdvertiserAndCampaign(); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldListUserProfiles(); itShouldListActiveCampaigns(); itShouldCreateAdvertiserAndCampaign(); } ================================================ FILE: advanced/test_doubleclickbidmanager.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests listQueries function of doubleclickbidmanager.gs */ function itShouldListQueries() { console.log("> itShouldListQueries"); listQueries(); } /** * Tests createAndRunQuery function of doubleclickbidmanager.gs */ function itShouldCreateAndRunQuery() { console.log("> itShouldCreateAndRunQuery"); createAndRunQuery(); } /** * Tests fetchReport function of doubleclickbidmanager.gs */ function itShouldFetchReport() { console.log("> itShouldFetchReport"); fetchReport(); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldListQueries(); itShouldCreateAndRunQuery(); itShouldFetchReport(); } ================================================ FILE: advanced/test_drive.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Helper functions to help test drive.gs expectToExist(...) * @param {string} value * To test drive.gs please add drive services */ function expectToExist(value) { if (value) { console.log("TEST: Exists"); } else { throw new Error("TEST: DNE"); } } /** * Helper functions to help test drive.gs expectToEqual * @param {string} actual * @param {string} expected * To test drive.gs please add drive services */ function expectToEqual(actual, expected) { console.log("TEST: actual: %s = expected: %s", actual, expected); if (actual !== expected) { console.log("TEST: actual: %s expected: %s", actual, expected); } } /** * Helper functions to help test drive.gs createFolder() * * To test drive.gs please add drive services */ function createTestFolder() { DriveApp.createFolder("test1"); DriveApp.createFolder("test2"); } /** * Helper functions to help test drive.gs getFilesByName(...) * * To test drive.gs please add drive services */ function fileCleanUp() { DriveApp.getFilesByName("google_logo.png").next().setTrashed(true); } /** * Helper functions folderCleanUp() * * To test getFoldersByName() please add drive services */ function folderCleanUp() { DriveApp.getFoldersByName("test1").next().setTrashed(true); DriveApp.getFoldersByName("test2").next().setTrashed(true); } /** * drive.gs test functions below */ /** * tests drive.gs uploadFile * @return {string} fileId The ID of the file */ function checkUploadFile() { uploadFile(); const fileId = DriveApp.getFilesByName("google_logo.png").next().getId(); expectToExist(fileId); return fileId; } /** * tests drive.gs listRootFolders */ function checkListRootFolders() { createTestFolder(); const folders = DriveApp.getFolders(); while (folders.hasNext()) { const folder = folders.next(); console.log(`${folder.getName()} ${folder.getId()}`); } listRootFolders(); folderCleanUp(); } /** * tests drive.gs addCustomProperty * @param {string} fileId The ID of the file */ function checkAddCustomProperty(fileId) { addCustomProperty(fileId); expectToEqual( Drive.Properties.get(fileId, "department", { visibility: "PUBLIC" }).value, "Sales", ); } /** * Run all tests */ function RUN_ALL_TESTS() { const fileId = checkUploadFile(); checkListRootFolders(); checkAddCustomProperty(fileId); listRevisions(fileId); fileCleanUp(); } ================================================ FILE: advanced/test_gmail.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Run All functions of gmail.gs * Add gmail services to run */ function RUN_ALL_TESTS() { console.log("> ltShouldListLabelInfo"); listLabelInfo(); console.log("> ltShouldListInboxSnippets"); listInboxSnippets(); console.log("> ltShouldLogRecentHistory"); logRecentHistory(); console.log("> ltShouldGetRawMessage"); getRawMessage(); } ================================================ FILE: advanced/test_people.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Helper functions for sheets.gs testing * * to tests people.gs add people api services */ function RUN_ALL_TESTS() { console.log("> itShouldGetConnections"); getConnections(); console.log("> itShouldGetSelf"); // Requires the scope userinfo.profile getSelf(); console.log("> itShouldGetAccount"); getAccount("me"); } ================================================ FILE: advanced/test_sheets.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Helper functions for sheets.gs testing * to tests sheets.gs add sheets services * * create test spreadsheets * @return {string} spreadsheet */ function createTestSpreadsheet() { const spreadsheet = SpreadsheetApp.create("Test Spreadsheet"); for (let i = 0; i < 3; ++i) { spreadsheet.appendRow([1, 2, 3]); } return spreadsheet.getId(); } /** * populate the created spreadsheet with values * @param {string} spreadsheetId */ function populateValues(spreadsheetId) { const batchUpdateRequest = Sheets.newBatchUpdateSpreadsheetRequest(); const repeatCellRequest = Sheets.newRepeatCellRequest(); const values = []; for (let i = 0; i < 10; ++i) { values[i] = []; for (let j = 0; j < 10; ++j) { values[i].push("Hello"); } } const range = "A1:J10"; SpreadsheetApp.openById(spreadsheetId).getRange(range).setValues(values); SpreadsheetApp.flush(); } /** * Functions to test sheets.gs below this line * tests readRange function of sheets.gs * @return {string} spreadsheet ID */ function itShouldReadRange() { console.log("> itShouldReadRange"); spreadsheetId = createTestSpreadsheet(); populateValues(spreadsheetId); readRange(spreadsheetId); return spreadsheetId; } /** * tests the addPivotTable function of sheets.gs * @param {string} spreadsheetId */ function itShouldAddPivotTable(spreadsheetId) { console.log("> itShouldAddPivotTable"); const spreadsheet = SpreadsheetApp.openById(spreadsheetId); const sheets = spreadsheet.getSheets(); sheetId = sheets[0].getSheetId(); addPivotTable(spreadsheetId, sheetId, sheetId); SpreadsheetApp.flush(); console.log("Created pivot table"); } /** * runs all the tests */ function RUN_ALL_TEST() { const spreadsheetId = itShouldReadRange(); console.log("> itShouldWriteToMultipleRanges"); writeToMultipleRanges(spreadsheetId); console.log("> itShouldAddSheet"); addSheet(spreadsheetId); itShouldAddPivotTable(spreadsheetId); } ================================================ FILE: advanced/test_shoppingContent.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Before running these tests replace the product resource variables const productResource1 = {}; const productResource2 = {}; const productResource3 = {}; /** * Tests productInsert function of shoppingContent.gs */ function itShouldProductInsert() { console.log("> itShouldPproductInsert"); productInsert(); } /** * Tests productList function of shoppingContent.gs */ function itShouldProductList() { console.log("> itShouldProductList"); productList(); } /** * Tests custombatch function of shoppingContent.gs */ function itShouldCustombatch() { console.log("> itShouldCustombatch"); custombatch(productResource1, productResource2, productResource3); } /** * Tests updateAccountTax function of shoppingContent.gs */ function itShouldUpdateAccountTax() { console.log("> itShouldUpdateAccountTax"); updateAccountTax(); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldProductInsert(); itShouldProductList(); itShouldCustombatch(); itShouldUpdateAccountTax(); } ================================================ FILE: advanced/test_slides.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * A simple existance assertion. Logs if the value is falsy. * @param {object} value The value we expect to exist. */ function expectToExist(value) { if (!value) { console.log("DNE"); return; } console.log("TEST: Exists"); } /** * A simple equality assertion. Logs if there is a mismatch. * @param {object} expected The expected value. * @param {object} actual The actual value. */ function expectToEqual(expected, actual) { if (actual !== expected) { console.log("TEST: actual: %s = expected: %s", actual, expected); return; } console.log("TEST: actual: %s = expected: %s", actual, expected); } /** * Creates a presentation. * @param {string} presentationId The presentation ID. * @param {string} pageId The page ID. * @return {string} objectId */ function addShape(presentationId, pageId) { // Create a new square textbox, using the supplied element ID. const elementId = "MyTextBox_01"; const pt350 = { magnitude: 350, unit: "PT", }; const requests = [ { createShape: { objectId: elementId, shapeType: "ELLIPSE", elementProperties: { pageObjectId: pageId, size: { height: pt350, width: pt350, }, transform: { scaleX: 1, scaleY: 1, translateX: 350, translateY: 100, unit: "PT", }, }, }, }, // Insert text into the box, using the supplied element ID. { insertText: { objectId: elementId, insertionIndex: 0, text: "Text Formatted!", }, }, ]; // Execute the request. const createTextboxWithTextResponse = Slides.Presentations.batchUpdate( { requests: requests, }, presentationId, ); const createShapeResponse = createTextboxWithTextResponse.replies[0].createShape; console.log("Created textbox with ID: %s", createShapeResponse.objectId); // [END slides_create_textbox_with_text] return createShapeResponse.objectId; } /** * Runs all tests. */ function RUN_ALL_TESTS() { itShouldCreateAPresentation(); itShouldCreateASlide(); itShouldCreateATextboxWithText(); itShouldFormatShapes(); itShouldReadPage(); } /** * Creates a presentation. */ function itShouldCreateAPresentation() { const presentationId = createPresentation(); expectToExist(presentationId); deleteFileOnCleanup(presentationId); } /** * Creates a new slide. */ function itShouldCreateASlide() { console.log("> itShouldCreateASlide"); const presentationId = createPresentation(); const slideId = createSlide(presentationId); expectToExist(slideId); deleteFileOnCleanup(presentationId); } /** * Creates a slide with text. */ function itShouldCreateATextboxWithText() { const presentationId = createPresentation(); const slide = createSlide(presentationId); const pageId = slide.replies[0].createSlide.objectId; const response = addTextBox(presentationId, pageId); expectToEqual(2, response.replies.length); const boxId = response.replies[0].createShape.objectId; expectToExist(boxId); deleteFileOnCleanup(presentationId); } /** * Test for Read Page. */ function itShouldReadPage() { const presentationId = createPresentation(); const slide = createSlide(presentationId); const pageId = slide.replies[0].createSlide.objectId; const response = readPageElementIds(presentationId, pageId); expectToEqual(3, response.pageElements.length); deleteFileOnCleanup(presentationId); } /** * Test for format shapes */ function itShouldFormatShapes() { const presentationId = createPresentation(); const slide = createSlide(presentationId); const pageId = slide.replies[0].createSlide.objectId; const shapeId = addShape(presentationId, pageId); const replies = formatShapeText(presentationId, shapeId); expectToExist(replies); deleteFileOnCleanup(presentationId); } /** * Delete the file * @param {string} id presentationId */ function deleteFileOnCleanup(id) { Drive.Files.remove(id); } ================================================ FILE: advanced/test_tagManager.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Before running tagManager tests create a test tagMAnager account // and replace the value below with its account path const path = "accounts/6007387289"; /** * Tests createContainerVersion function of tagManager.gs * @param {string} accountPath Tag manager account's path * @return {object} version The container version */ function itShouldCreateContainerVersion(accountPath) { console.log("> itShouldCreateContainerVersion"); const version = createContainerVersion(accountPath); return version; } /** * Tests publishVersionAndQuickPreviewDraft function of tagManager.gs * @param {object} version tag managers container version */ function itShouldPublishVersionAndQuickPreviewDraft(version) { console.log("> itShouldPublishVersionAndQuickPreviewDraft"); publishVersionAndQuickPreviewDraft(version); } /** * Tests createAndReauthorizeUserEnvironment function of tagManager.gs * @param {object} version tag managers container version */ function itShouldCreateAndReauthorizeUserEnvironment(version) { console.log("> itShouldCreateAndReauthorizeUserEnvironment"); createAndReauthorizeUserEnvironment(version); } /** * Tests logAllAccountUserPermissionsWithContainerAccess function of tagManager.gs * @param {string} accountPath Tag manager account's path */ function itShouldLogAllAccountUserPermissionsWithContainerAccess(accountPath) { console.log("> itShouldLogAllAccountUserPermissionsWithContainerAccess"); logAllAccountUserPermissionsWithContainerAccess(accountPath); } /** * Runs all tests */ function RUN_ALL_TESTS() { const version = itShouldCreateContainerVersion(path); itShouldPublishVersionAndQuickPreviewDraft(version); itShouldCreateAndReauthorizeUserEnvironment(version); itShouldLogAllAccountUserPermissionsWithContainerAccess(path); } ================================================ FILE: advanced/test_tasks.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Test functions for tasks.gs * * Add task API services to test */ /** * tests listTaskLists of tasks.gs */ function itShouldListTaskLists() { console.log("> itShouldListTaskLists"); listTaskLists(); } /** * tests listTasks of tasks.gs */ function itShouldListTasks() { console.log("> itShouldListTasks"); const taskId = Tasks.Tasklists.list().items[0].id; listTasks(taskId); } /** * tests addTask of tasks.gs */ function itShouldAddTask() { console.log("> itShouldAddTask"); const taskId = Tasks.Tasklists.list().items[0].id; addTask(taskId); } /** * run all tests */ function RUN_ALL_TESTS() { itShouldListTaskLists(); itShouldListTasks(); itShouldAddTask(); itShouldListTasks(); } ================================================ FILE: advanced/test_youtube.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Run all tests */ function RUN_ALL_TESTS() { console.log("> itShouldSearchByKeyword"); searchByKeyword(); console.log("> itShouldRetrieveMyUploads"); retrieveMyUploads(); console.log("> itShouldAddSubscription"); addSubscription(); console.log("> itShouldCreateSlides"); createSlides(); } ================================================ FILE: advanced/test_youtubeAnalytics.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests createReport function of youtubeAnalytics.gs */ function itShouldCreateReport() { console.log("> itShouldCreateReport"); createReport(); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldCreateReport(); } ================================================ FILE: advanced/test_youtubeContentId.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests claimYourVideoWithMonetizePolicy function of youtubeContentId.gs */ function itShouldClaimVideoWithMonetizePolicy() { console.log("> itShouldClaimVideoWithMonetizePolicy"); claimYourVideoWithMonetizePolicy(); } /** * Tests updateAssetOwnership function of youtubeContentId.gs */ function itShouldUpdateAssetOwnership() { console.log("> itShouldUpdateAssetOwnership"); updateAssetOwnership(); } /** * Tests releaseClaim function of youtubeContentId.gs */ function itShouldReleaseClaim() { console.log("> itShouldReleaseClaim"); releaseClaim(); } /** * Run all tests */ function RUN_ALL_TESTS() { itShouldClaimVideoWithMonetizePolicy(); itShouldUpdateAssetOwnership(); itShouldReleaseClaim(); } ================================================ FILE: advanced/youtube.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_youtube_search] /** * Searches for videos about dogs, then logs the video IDs and title. * Note that this sample limits the results to 25. To return more * results, pass additional parameters as shown in the YouTube Data API docs. * @see https://developers.google.com/youtube/v3/docs/search/list */ function searchByKeyword() { try { const results = YouTube.Search.list("id,snippet", { q: "dogs", maxResults: 25, }); if (results === null) { console.log("Unable to search videos"); return; } for (const item of results.items) { console.log("[%s] Title: %s", item.id.videoId, item.snippet.title); } } catch (err) { // TODO (developer) - Handle exceptions from Youtube API console.log("Failed with an error %s", err.message); } } // [END apps_script_youtube_search] // [START apps_script_youtube_uploads] /** * This function retrieves the user's uploaded videos by: * 1. Fetching the user's channel's. * 2. Fetching the user's "uploads" playlist. * 3. Iterating through this playlist and logs the video IDs and titles. * 4. If there is a next page of resuts, fetching it and returns to step 3. */ function retrieveMyUploads() { try { // @see https://developers.google.com/youtube/v3/docs/channels/list const results = YouTube.Channels.list("contentDetails", { mine: true, }); if (!results || results.items.length === 0) { console.log("No Channels found."); return; } for (let i = 0; i < results.items.length; i++) { const item = results.items[i]; /** Get the channel ID - it's nested in contentDetails, as described in the * Channel resource: https://developers.google.com/youtube/v3/docs/channels. */ const playlistId = item.contentDetails.relatedPlaylists.uploads; let nextPageToken = null; do { // @see: https://developers.google.com/youtube/v3/docs/playlistItems/list const playlistResponse = YouTube.PlaylistItems.list("snippet", { playlistId: playlistId, maxResults: 25, pageToken: nextPageToken, }); if (!playlistResponse || playlistResponse.items.length === 0) { console.log("No Playlist found."); break; } for (let j = 0; j < playlistResponse.items.length; j++) { const playlistItem = playlistResponse.items[j]; console.log( "[%s] Title: %s", playlistItem.snippet.resourceId.videoId, playlistItem.snippet.title, ); } nextPageToken = playlistResponse.nextPageToken; } while (nextPageToken); } } catch (err) { // TODO (developer) - Handle exception console.log("Failed with err %s", err.message); } } // [END apps_script_youtube_uploads] // [START apps_script_youtube_subscription] /** * This sample subscribes the user to the Google Developers channel on YouTube. * @see https://developers.google.com/youtube/v3/docs/subscriptions/insert */ function addSubscription() { // Replace this channel ID with the channel ID you want to subscribe to const channelId = "UC_x5XG1OV2P6uZZ5FSM9Ttw"; const resource = { snippet: { resourceId: { kind: "youtube#channel", channelId: channelId, }, }, }; try { const response = YouTube.Subscriptions.insert(resource, "snippet"); console.log( "Added subscription for channel title : %s", response.snippet.title, ); } catch (e) { if (e.message.match("subscriptionDuplicate")) { console.log( `Cannot subscribe; already subscribed to channel: ${channelId}`, ); } else { // TODO (developer) - Handle exception console.log(`Error adding subscription: ${e.message}`); } } } // [END apps_script_youtube_subscription] // [START apps_script_youtube_slides] /** * Creates a slide presentation with 10 videos from the YouTube search `YOUTUBE_QUERY`. * The YouTube Advanced Service must be enabled before using this sample. */ const PRESENTATION_TITLE = "San Francisco, CA"; const YOUTUBE_QUERY = "San Francisco, CA"; /** * Gets a list of YouTube videos. * @param {String} query - The query term to search for. * @return {object[]} A list of objects with YouTube video data. * @see https://developers.google.com/youtube/v3/docs/search/list */ function getYouTubeVideosJSON(query) { const youTubeResults = YouTube.Search.list("id,snippet", { q: query, type: "video", maxResults: 10, }); return youTubeResults.items.map((item) => { return { url: `https://youtu.be/${item.id.videoId}`, title: item.snippet.title, thumbnailUrl: item.snippet.thumbnails.high.url, }; }); } /** * Creates a presentation where each slide features a YouTube video. * Logs out the URL of the presentation. */ function createSlides() { try { const youTubeVideos = getYouTubeVideosJSON(YOUTUBE_QUERY); const presentation = SlidesApp.create(PRESENTATION_TITLE); presentation .getSlides()[0] .getPageElements()[0] .asShape() .getText() .setText(PRESENTATION_TITLE); if (!presentation) { console.log("Unable to create presentation"); return; } // Add slides with videos and log the presentation URL to the user. for (const video of youTubeVideos) { const slide = presentation.appendSlide(); slide.insertVideo( video.url, 0, 0, presentation.getPageWidth(), presentation.getPageHeight(), ); } console.log(presentation.getUrl()); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } // [END apps_script_youtube_slides] ================================================ FILE: advanced/youtubeAnalytics.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_youtube_report] /** * Creates a spreadsheet containing daily view counts, watch-time metrics, * and new-subscriber counts for a channel's videos. */ function createReport() { // Retrieve info about the user's YouTube channel. const channels = YouTube.Channels.list("id,contentDetails", { mine: true, }); const channelId = channels.items[0].id; // Retrieve analytics report for the channel. const oneMonthInMillis = 1000 * 60 * 60 * 24 * 30; const today = new Date(); const lastMonth = new Date(today.getTime() - oneMonthInMillis); const metrics = [ "views", "estimatedMinutesWatched", "averageViewDuration", "subscribersGained", ]; const result = YouTubeAnalytics.Reports.query({ ids: `channel==${channelId}`, startDate: formatDateString(lastMonth), endDate: formatDateString(today), metrics: metrics.join(","), dimensions: "day", sort: "day", }); if (!result.rows) { console.log("No rows returned."); return; } const spreadsheet = SpreadsheetApp.create("YouTube Analytics Report"); const sheet = spreadsheet.getActiveSheet(); // Append the headers. const headers = result.columnHeaders.map((columnHeader) => { return formatColumnName(columnHeader.name); }); sheet.appendRow(headers); // Append the results. sheet .getRange(2, 1, result.rows.length, headers.length) .setValues(result.rows); console.log("Report spreadsheet created: %s", spreadsheet.getUrl()); } /** * Converts a Date object into a YYYY-MM-DD string. * @param {Date} date The date to convert to a string. * @return {string} The formatted date. */ function formatDateString(date) { return Utilities.formatDate(date, Session.getScriptTimeZone(), "yyyy-MM-dd"); } /** * Formats a column name into a more human-friendly name. * @param {string} columnName The unprocessed name of the column. * @return {string} The formatted column name. * @example "averageViewPercentage" becomes "Average View Percentage". */ function formatColumnName(columnName) { let name = columnName.replace(/([a-z])([A-Z])/g, "$1 $2"); name = name.slice(0, 1).toUpperCase() + name.slice(1); return name; } // [END apps_script_youtube_report] ================================================ FILE: advanced/youtubeContentId.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_youtube_claim] /** * This function creates a partner-uploaded claim on a video with the specified * asset and policy rules. * @see https://developers.google.com/youtube/partner/docs/v1/claims/insert */ function claimYourVideoWithMonetizePolicy() { // The ID of the content owner that you are acting on behalf of. const onBehalfOfContentOwner = "replaceWithYourContentOwnerID"; // A YouTube video ID to claim. In this example, the video must be uploaded // to one of your onBehalfOfContentOwner's linked channels. const videoId = "replaceWithYourVideoID"; const assetId = "replaceWithYourAssetID"; const claimToInsert = { videoId: videoId, assetId: assetId, contentType: "audiovisual", // Set the claim policy to monetize. You can also specify a policy ID here // instead of policy rules. // For details, please refer to the YouTube Content ID API Policies // documentation: // https://developers.google.com/youtube/partner/docs/v1/policies policy: { rules: [{ action: "monetize" }] }, }; try { const claimInserted = YouTubeContentId.Claims.insert(claimToInsert, { onBehalfOfContentOwner: onBehalfOfContentOwner, }); console.log("Claim created on video %s: %s", videoId, claimInserted); } catch (e) { console.log( "Failed to create claim on video %s, error: %s", videoId, e.message, ); } } // [END apps_script_youtube_claim] // [START apps_script_youtube_update_asset_ownership] /** * This function updates your onBehalfOfContentOwner's ownership on an existing * asset. * @see https://developers.google.com/youtube/partner/docs/v1/ownership/update */ function updateAssetOwnership() { // The ID of the content owner that you are acting on behalf of. const onBehalfOfContentOwner = "replaceWithYourContentOwnerID"; // Replace values with your asset id const assetId = "replaceWithYourAssetID"; // The new ownership here would replace your existing ownership on the asset. const myAssetOwnership = { general: [ { ratio: 100, owner: onBehalfOfContentOwner, type: "include", territories: ["US", "CA"], }, ], }; try { const updatedOwnership = YouTubeContentId.Ownership.update( myAssetOwnership, assetId, { onBehalfOfContentOwner: onBehalfOfContentOwner }, ); console.log("Ownership updated on asset %s: %s", assetId, updatedOwnership); } catch (e) { console.log( "Ownership update failed on asset %s, error: %s", assetId, e.message, ); } } // [END apps_script_youtube_update_asset_ownership] // [START apps_script_youtube_release_claim] /** * This function releases an existing claim your onBehalfOfContentOwner has * on a video. * @see https://developers.google.com/youtube/partner/docs/v1/claims/patch */ function releaseClaim() { // The ID of the content owner that you are acting on behalf of. const onBehalfOfContentOwner = "replaceWithYourContentOwnerID"; // The ID of the claim to be released. const claimId = "replaceWithYourClaimID"; // To release the claim, change the resource's status to inactive. const claimToBeReleased = { status: "inactive", }; try { const claimReleased = YouTubeContentId.Claims.patch( claimToBeReleased, claimId, { onBehalfOfContentOwner: onBehalfOfContentOwner }, ); console.log("Claim %s was released: %s", claimId, claimReleased); } catch (e) { console.log("Failed to release claim %s, error: %s", claimId, e.message); } } // [END apps_script_youtube_release_claim] ================================================ FILE: ai/autosummarize/README.md ================================================ # Editor Add-on: Sheets - AutoSummarize AI ## Project Description Google Workspace Editor Add-on for Google Sheets that uses AI to create AI summaries in bulk for a listing of Google Docs and Slides files. ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Enable the Vertex AI API 1. Create a Service Account and grant the role Service Account Token Creator Role 1. Create a private key with type JSON. This will download the JSON file for use in the next section. 1. Open an Apps Script Project bound to a Google Sheets Spreadsheet. 1. Rename the script to `Autosummarize AI`. 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add a Script Property. Enter `model_id` as the property name and `gemini-pro-vision` as the value. 1. Add a Script Property. Enter `project_location` as the property name and `us-central1` as the value. 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. 1. Add `OAuth2 v43` Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. 1. Enable the `Drive v3` advanced service. 1. Add the project code to Apps Script ## Usage 1. Insert one or more links to any Google Doc or Slides files in a column. 1. Select one or more of the links in the sheet. 1. From the `Sheets` menu, select `Extensions > AutoSummarize AI > Open AutoSummarize AI` 1. Click Get summaries button. ================================================ FILE: ai/autosummarize/appsscript.json ================================================ { "timeZone": "America/Denver", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "dependencies": { "libraries": [ { "userSymbol": "OAuth2", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", "version": "43", "developmentMode": false } ], "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v3", "serviceId": "drive" } ] } } ================================================ FILE: ai/autosummarize/gemini.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ function scriptPropertyWithDefault(key, defaultValue = undefined) { const scriptProperties = PropertiesService.getScriptProperties(); const value = scriptProperties.getProperty(key); if (value) { return value; } return defaultValue; } const VERTEX_AI_LOCATION = scriptPropertyWithDefault( "project_location", "us-central1", ); const MODEL_ID = scriptPropertyWithDefault("model_id", "gemini-pro-vision"); const SERVICE_ACCOUNT_KEY = scriptPropertyWithDefault("service_account_key"); /** * Packages prompt and necessary settings, then sends a request to * Vertex API. Returns the response as an JSON object extracted from the * Vertex API response object. * * @param {string} prompt The prompt to senb to Vertex AI API. * @param {string} options.temperature The temperature setting set by user. * @param {string} options.maxOutputTokens The number of tokens to limit to the prompt. */ function getAiSummary(parts, options = {}) { const defaultOptions = { temperature: 0.1, maxOutputTokens: 8192, topK: 1, topP: 1, stopSequences: [], }; const request = { contents: [ { role: "user", parts: parts, }, ], generationConfig: { ...defaultOptions, ...options, }, }; const credentials = credentialsForVertexAI(); const fetchOptions = { method: "POST", headers: { Authorization: `Bearer ${credentials.accessToken}`, }, contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify(request), }; const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}` + `/locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; const response = UrlFetchApp.fetch(url, fetchOptions); const responseCode = response.getResponseCode(); if (responseCode >= 400) { throw new Error(`Unable to process file: Error code ${responseCode}`); } const responseText = response.getContentText(); const parsedResponse = JSON.parse(responseText); if (parsedResponse.error) { throw new Error(parsedResponse.error.message); } const text = parsedResponse.candidates[0].content.parts[0].text; return text; } /** * Gets credentials required to call Vertex API using a Service Account. * Requires use of Service Account Key stored with project * * @return {!Object} Containing the Cloud Project Id and the access token. */ function credentialsForVertexAI() { const credentials = SERVICE_ACCOUNT_KEY; if (!credentials) { throw new Error("service_account_key script property must be set."); } const parsedCredentials = JSON.parse(credentials); const service = OAuth2.createService("Vertex") .setTokenUrl("https://oauth2.googleapis.com/token") .setPrivateKey(parsedCredentials.private_key) .setIssuer(parsedCredentials.client_email) .setPropertyStore(PropertiesService.getScriptProperties()) .setScope("https://www.googleapis.com/auth/cloud-platform"); return { projectId: parsedCredentials.project_id, accessToken: service.getAccessToken(), }; } ================================================ FILE: ai/autosummarize/main.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Creates a menu entry in the Google Sheets Extensions menu when the document is opened. * * @param {object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { SpreadsheetApp.getUi() .createAddonMenu() .addItem("📄 Open AutoSummarize AI", "showSidebar") .addSeparator() .addItem("❎ Quick summary", "doAutoSummarizeAI") .addItem("❌ Remove all summaries", "removeAllSummaries") .addToUi(); } /** * Runs when the add-on is installed; calls onOpen() to ensure menu creation and * any other initializion work is done immediately. This method is only used by * the desktop add-on and is never called by the mobile version. * * @param {object} e The event parameter for a simple onInstall trigger. */ function onInstall(e) { onOpen(e); } /** * Opens sidebar in Sheets with AutoSummarize AI interface. */ function showSidebar() { const ui = HtmlService.createHtmlOutputFromFile("sidebar").setTitle( "AutoSummarize AI", ); SpreadsheetApp.getUi().showSidebar(ui); } /** * Deletes all of the AutoSummarize AI created sheets * i.e. any sheets with prefix of 'AutoSummarize AI' */ function removeAllSummaries() { const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); const allSheets = spreadsheet.getSheets(); for (const sheet of allSheets) { const sheetName = sheet.getName(); // Check if the sheet name starts with "AutoSummarize AI" if (sheetName.startsWith("AutoSummarize AI")) { spreadsheet.deleteSheet(sheet); } } } /** * Wrapper function for add-on. */ function doAutoSummarizeAI( customPrompt1, customPrompt2, temperature = 0.1, tokens = 2048, ) { // Get selected cell values. console.log("Getting selection..."); const selection = SpreadsheetApp.getSelection() .getActiveRange() .getRichTextValues() .map((value) => { if (value[0].getLinkUrl()) { return value[0].getLinkUrl(); } return value[0].getText(); }); // Get AI summary const data = summarizeFiles( selection, customPrompt1, customPrompt2, temperature, tokens, ); // Add and format a new new sheet. const now = new Date(); const nowFormatted = Utilities.formatDate( now, now.getTimezoneOffset().toString(), "MM/dd HH:mm", ); let sheetName = `AutoSummarize AI (${nowFormatted})`; if (SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName)) { sheetName = `AutoSummarize AI (${nowFormatted}:${now.getSeconds()})`; } const aiSheet = SpreadsheetApp.getActiveSpreadsheet() .insertSheet() .setName(sheetName); const aiSheetHeaderStyle = SpreadsheetApp.newTextStyle() .setFontSize(12) .setBold(true) .setFontFamily("Google Sans") .setForegroundColor("#ffffff") .build(); const aiSheetValuesStyle = SpreadsheetApp.newTextStyle() .setFontSize(10) .setBold(false) .setFontFamily("Google Sans") .setForegroundColor("#000000") .build(); aiSheet .getRange("A1:E1") .setBackground("#434343") .setTextStyle(aiSheetHeaderStyle) .setValues([ [ "Link", "Title", `Summary from Gemini AI [Temperature: ${temperature}]`, `Custom Prompt #1: ${customPrompt1}`, `Custom Prompt #2: ${customPrompt2}`, ], ]) .setWrap(true); aiSheet.setColumnWidths(1, 1, 100); aiSheet.setColumnWidths(2, 1, 300); aiSheet.setColumnWidths(3, 3, 300); // Copy results aiSheet.getRange(`A2:E${data.length + 1}`).setValues(data); aiSheet .getRange(`A2:E${data.length + 1}`) .setBackground("#ffffff") .setTextStyle(aiSheetValuesStyle) .setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP) .setVerticalAlignment("top"); aiSheet .getRange(`C2:E${data.length + 1}`) .setBackground("#efefef") .setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP); aiSheet.deleteColumns(8, 19); aiSheet.deleteRows( aiSheet.getLastRow() + 1, aiSheet.getMaxRows() - aiSheet.getLastRow(), ); } ================================================ FILE: ai/autosummarize/sidebar.html ================================================

AutoSummarize AI

 How to Use...

 Custom Prompts


 Prompt Settings

================================================ FILE: ai/autosummarize/summarize.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Exports a Google Doc/Sheet/Slide to the requested format. * * @param {string} fileId - ID of file to export * @param {string} targetType - MIME type to export as * @return Base64 encoded file content */ function exportFile(fileId, targetType = "application/pdf") { const exportUrl = `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=${encodeURIComponent(targetType)}&supportsAllDrives=true`; const requestOptions = { headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, }, }; const response = UrlFetchApp.fetch(exportUrl, requestOptions); const blob = response.getBlob(); return Utilities.base64Encode(blob.getBytes()); } /** * Downloads a binary file from Drive. * * @param {string} fileId - ID of file to export * @param {string} targetType - MIME type to export as * @return Base64 encoded file content */ function downloadFile(fileId) { const exportUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true`; const requestOptions = { headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, }, }; const response = UrlFetchApp.fetch(exportUrl, requestOptions); const blob = response.getBlob(); return Utilities.base64Encode(blob.getBytes()); } /** * Main function for AutoSummarize AI process. */ function summarizeFiles( sourceSheetLinks, customPrompt1, customPrompt2, temperature, tokens, ) { return sourceSheetLinks.map((fileUrl) => { console.log("Processing:", fileUrl); let fileName = ""; let summary = ""; let customPrompt1Response = ""; let customPrompt2Response = ""; if (!fileUrl) { return [ "", fileName, summary, customPrompt1Response, customPrompt2Response, ]; } try { const promptParts = [ { text: "Summarize the following document.", }, { text: "Return your response as a single paragraph. Reformat any lists as part of the paragraph. Output only the single paragraph as plain text. Do not use more than 3 sentences. Do not use markdown.", }, ]; const fileIdMatchPattern = /\/d\/(.*?)\//gi; const match = fileIdMatchPattern.exec(fileUrl); if (!match) { console.log(`Could not extract file ID from URL: ${fileUrl}`); return [ fileUrl, fileName, "Could not extract file ID from URL.", "", "", ]; } const fileId = match[1]; // Get file title and type. const currentFile = Drive.Files.get(fileId, { supportsAllDrives: true }); const fileMimeType = currentFile.mimeType; fileName = currentFile.name; console.log(`Processing ${fileName} (ID: ${fileId})...`); // Add file content to the prompt switch (fileMimeType) { case "application/vnd.google-apps.presentation": case "application/vnd.google-apps.document": case "application/vnd.google-apps.spreadsheet": promptParts.push({ inlineData: { mimeType: "application/pdf", data: exportFile(fileId, "application/pdf"), }, }); break; case "application/pdf": case "image/gif": case "image/jpeg": case "image/png": promptParts.push({ inlineData: { mimeType: fileMimeType, data: downloadFile(fileId), }, }); break; default: console.log(`Unsupported file type: ${fileMimeType}`); return [ fileUrl, fileName, summary, customPrompt1Response, customPrompt2Response, ]; } // Prompt for summary const geminiOptions = { temperature, maxOutputTokens: tokens, }; summary = getAiSummary(promptParts, geminiOptions); // If any custom prompts, request those too if (customPrompt1) { promptParts[0].text = customPrompt1; customPrompt1Response = getAiSummary(promptParts, geminiOptions); } if (customPrompt2) { promptParts[0].text = customPrompt2; customPrompt2Response = getAiSummary(promptParts, geminiOptions); } return [ fileUrl, fileName, summary, customPrompt1Response, customPrompt2Response, ]; } catch (e) { // Add error row values if anything else goes wrong. console.log(e); return [ fileUrl, fileName, "Something went wrong. Make sure you have access to this row's link.", "", "", ]; } }); } ================================================ FILE: ai/custom-func-ai-agent/AiVertex.js ================================================ /* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const LOCATION = PropertiesService.getScriptProperties().getProperty("LOCATION"); const GEMINI_MODEL_ID = PropertiesService.getScriptProperties().getProperty("GEMINI_MODEL_ID"); const REASONING_ENGINE_ID = PropertiesService.getScriptProperties().getProperty( "REASONING_ENGINE_ID", ); const SERVICE_ACCOUNT_KEY = PropertiesService.getScriptProperties().getProperty( "SERVICE_ACCOUNT_KEY", ); const credentials = credentialsForVertexAI(); /** * @param {string} statement The statement to fact-check. */ function requestLlmAuditorAdkAiAgent(statement) { return UrlFetchApp.fetch( `https://${LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}/locations/${LOCATION}/reasoningEngines/${REASONING_ENGINE_ID}:streamQuery?alt=sse`, { method: "post", headers: { Authorization: `Bearer ${credentials.accessToken}` }, contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify({ class_method: "async_stream_query", input: { user_id: "google_sheets_custom_function_fact_check", message: statement, }, }), }, ).getContentText(); } /** * @param {string} prompt The Gemini prompt to use. */ function requestOutputFormatting(prompt) { const response = UrlFetchApp.fetch( `https://${LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}/locations/${LOCATION}/publishers/google/models/${GEMINI_MODEL_ID}:generateContent`, { method: "post", headers: { Authorization: `Bearer ${credentials.accessToken}` }, contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify({ contents: [ { role: "user", parts: [{ text: prompt }], }, ], generationConfig: { temperature: 0.1, maxOutputTokens: 2048 }, safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, ], }), }, ); return JSON.parse(response).candidates[0].content.parts[0].text; } /** * Gets credentials required to call Vertex API using a Service Account. * Requires use of Service Account Key stored with project. * * @return {!Object} Containing the Google Cloud project ID and the access token. */ function credentialsForVertexAI() { const credentials = SERVICE_ACCOUNT_KEY; if (!credentials) { throw new Error("service_account_key script property must be set."); } const parsedCredentials = JSON.parse(credentials); const service = OAuth2.createService("Vertex") .setTokenUrl("https://oauth2.googleapis.com/token") .setPrivateKey(parsedCredentials.private_key) .setIssuer(parsedCredentials.client_email) .setPropertyStore(PropertiesService.getScriptProperties()) .setScope("https://www.googleapis.com/auth/cloud-platform"); return { projectId: parsedCredentials.project_id, accessToken: service.getAccessToken(), }; } ================================================ FILE: ai/custom-func-ai-agent/Code.js ================================================ /* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const DEFAULT_OUTPUT_FORMAT = "Summarize it. Only keep the verdict result and main arguments. " + "Do not reiterate the fact being checked. Remove all markdown. " + "State the verdit result in a first paragraph in a few words and " + "the rest of the summary in a second paragraph."; /** * Passes a statement to fact-check and, optionally, output formatting instructions. * * @param {string} statement The statement to fact-check as a string or single cell * reference (data ranges are not supported). * @param {string} outputFormat The instructions as a string or single cell reference * (data ranges are not supported). * * @return The generated and formatted verdict * @customfunction */ function FACT_CHECK(statement, outputFormat = DEFAULT_OUTPUT_FORMAT) { return requestOutputFormatting( `Here is a fact checking result: ${requestLlmAuditorAdkAiAgent(statement)}.\n\n${outputFormat}`, ); } ================================================ FILE: ai/custom-func-ai-agent/README.md ================================================ # Google Sheets Custom Function relying on ADK AI Agent and Gemini model A [Vertex AI](https://cloud.google.com/vertex-ai) agent-powered **fact checker** custom function for Google Sheets to be used as a bound Apps Script project. ![](./images/showcase.png) ## Tutorial For detailed instructions to deploy and run this sample, follow the [dedicated tutorial](https://developers.google.com/apps-script/samples/custom-functions/fact-check). ## Overview The **Google Sheets custom function** named `FACT_CHECK` integrates the sophisticated, multi-tool, multi-step reasoning capabilities of a **Vertex AI Agent Engine (ADK Agent)** directly into your Google Sheets spreadsheets. It operates as an end-to-end solution. It analyzes a statement, grounds its response using the latest web information, and returns the result in the format you need: * Usage: `=FACT_CHECK("Your statement here")` for a concise and summarized output. `=FACT_CHECK("Your statement here", "Your output formatting instructions here")` for a specific output format. * Reasoning: [**LLM Auditor ADK AI Agent (Python sample)**](https://github.com/google/adk-samples/tree/main/python/agents/llm-auditor). * Output formatting: [**Gemini model**](https://cloud.google.com/vertex-ai/generative-ai/docs/models). ## Prerequisites * Google Cloud Project with billing enabled. ## Set up your environment 1. Configure the Google Cloud project 1. Enable the Vertex AI API 1. Create a Service Account and grant the role `Vertex AI User` 1. Create a private key with type JSON. This will download the JSON file. 1. Setup, install, and deploy the LLM Auditor ADK AI Agent sample 1. Use Vertex AI 1. Use the same Google Cloud project 1. Use the location `us-central1` 1. Use the Vertex AI Agent Engine 1. Open an Apps Script project bound to a Google Sheets spreadsheet 1. Add a Script Property. Enter `LOCATION` as the property name and `us-central1` as the value. 1. Add a Script Property. Enter `GEMINI_MODEL_ID` as the property name and `gemini-2.5-flash-lite` as the value. 1. Add a Script Property. Enter `REASONING_ENGINE_ID` as the property name and the ID of the deployed LLM Auditor ADK AI Agent as the value. 1. Add a Script Property. Enter `SERVICE_ACCOUNT_KEY` as the property name and paste the JSON key from the service account as the value. 1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF` 1. Set the script files `Code.gs` and `AiVertex.gs` in the Apps Script project with the JS file contents in this project ================================================ FILE: ai/custom-func-ai-agent/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": { "libraries": [ { "userSymbol": "OAuth2", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", "version": "43" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: ai/custom-func-ai-studio/Code.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Passes a prompt and a data range to Gemini AI. * * @param {range} range The range of cells. * @param {string} prompt The text prompt as a string or single cell reference. * @return The Gemini response. * @customfunction */ function gemini(range, prompt) { return getAiSummary(`For the range of cells ${range}, ${prompt}`); } ================================================ FILE: ai/custom-func-ai-studio/README.md ================================================ # Google Sheets Custom Function with AI Studio ## Project Description Google Sheets Custom Function to be used as a bound Apps Script project with a Google Sheets Spreadsheet ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Enable Generative Language API - (may skip as is automatically done in step 2) 1. Create a Google Gemini API Key 1. Navigate to https://aistudio.google.com/app/apikey 1. Create API key for existing project from step 1 1. Copy the generated key for use in the next step. 1. Open an Apps Script Project bound to a Google Sheets Spreadsheet 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add a Script Property. Enter `api_key` as the property name and use the Gemini API Key as the value 1. Add the project code to Apps Script ## Usage Insert a custom function in Google Sheets, passing a range and a prompt as parameters Example: ``` =gemini(A1:A10,"Extract colors from the product description") ``` ================================================ FILE: ai/custom-func-ai-studio/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: ai/custom-func-ai-studio/gemini.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Packages prompt and necessary settings, then sends a request to the * Generative Language API. Returns the text string response, extracted from the * Gemini AI response object. * * @param {string} prompt String representing the prompt for Gemini AI call. * @return {string} Result of Gemini AI in string format. */ function getAiSummary(prompt) { const data = { contents: [ { parts: [ { text: prompt, }, ], }, ], generationConfig: { temperature: 0.2, topK: 1, topP: 1, maxOutputTokens: 2048, stopSequences: [], }, safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, ], }; const options = { method: "post", contentType: "application/json", payload: JSON.stringify(data), // Convert the JavaScript object to a JSON string. }; const apiKey = PropertiesService.getScriptProperties().getProperty("api_key"); const response = UrlFetchApp.fetch( `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${apiKey}`, options, ); const payload = JSON.parse(response.getContentText()); const text = payload.candidates[0].content.parts[0].text; return text; } ================================================ FILE: ai/custom_func_vertex/Code.js ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Passes a prompt and a data range to Gemini AI. * * @param {range} range The range of cells. * @param {string} prompt The text prompt as a string or single cell reference. * @return The Gemini response. * @customfunction */ function gemini(range, prompt) { return getAiSummary( `For the table of data: ${range} Answer the following: ${prompt}. Do not use formatting. Remove all markdown.`, ); } ================================================ FILE: ai/custom_func_vertex/README.md ================================================ # Google Sheets Custom Function with AI Studio ## Project Description Google Sheets Custom Function to be used as a bound Apps Script project with a Google Sheets Spreadsheet. ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Enable the Vertex AI API 1. Create a Service Account and grant the role `Vertex AI User` 1. Create a private key with type JSON. This will download the JSON file for use in the next section. 1. Open an Apps Script Project bound to a Google Sheets Spreadsheet 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add a Script Property. Enter `model_id` as the property name and `gemini-pro` as the value. 1. Add a Script Property. Enter `project_location` as the property name and `us-central1` as the value. 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. 1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. 1. Add the project code to Apps Script ## Usage Insert a custom function in Google Sheets, passing a range and a prompt as parameters Example: ``` =gemini(A1:A10,"Extract colors from the product description") ``` ================================================ FILE: ai/custom_func_vertex/aiVertex.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const VERTEX_AI_LOCATION = PropertiesService.getScriptProperties().getProperty("project_location"); const MODEL_ID = PropertiesService.getScriptProperties().getProperty("model_id"); const SERVICE_ACCOUNT_KEY = PropertiesService.getScriptProperties().getProperty( "service_account_key", ); /** * Packages prompt and necessary settings, then sends a request to * Vertex API. Returns the response as an JSON object extracted from the * Vertex API response object. * * @param prompt - String representing your prompt for Gemini AI. */ function getAiSummary(prompt) { const request = { contents: [ { role: "user", parts: [ { text: prompt, }, ], }, ], generationConfig: { temperature: 0.1, maxOutputTokens: 2048, }, safetySettings: [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, ], }; const credentials = credentialsForVertexAI(); const fetchOptions = { method: "post", headers: { Authorization: `Bearer ${credentials.accessToken}`, }, contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify(request), }; const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}/` + `locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; const response = UrlFetchApp.fetch(url, fetchOptions); const payload = JSON.parse(response); const text = payload.candidates[0].content.parts[0].text; return text; } /** * Gets credentials required to call Vertex API using a Service Account. * Requires use of Service Account Key stored with project * * @return {!Object} Containing the Cloud Project Id and the access token. */ function credentialsForVertexAI() { const credentials = SERVICE_ACCOUNT_KEY; if (!credentials) { throw new Error("service_account_key script property must be set."); } const parsedCredentials = JSON.parse(credentials); const service = OAuth2.createService("Vertex") .setTokenUrl("https://oauth2.googleapis.com/token") .setPrivateKey(parsedCredentials.private_key) .setIssuer(parsedCredentials.client_email) .setPropertyStore(PropertiesService.getScriptProperties()) .setScope("https://www.googleapis.com/auth/cloud-platform"); return { projectId: parsedCredentials.project_id, accessToken: service.getAccessToken(), }; } ================================================ FILE: ai/custom_func_vertex/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": { "libraries": [ { "userSymbol": "OAuth2", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", "version": "43" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: ai/devdocs-link-preview/Cards.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Creates the Card to display documentation summary to user. * * @param {string} pageTitle Title of the page/card section. * @param {string} summary Page summary to display. * @return {!Card} */ function buildCard(pageTitle, summary, showRating = true) { const cardHeader = CardService.newCardHeader().setTitle("About this page"); const summarySection = CardService.newCardSection().addWidget( CardService.newTextParagraph().setText(summary), ); const feedbackSection = CardService.newCardSection().setHeader("Rate this summary"); if (showRating) { const thumbsUpAction = CardService.newAction() .setFunctionName("onRatingClicked") .setParameters({ key: "upVotes", title: pageTitle, pageSummary: summary, }); const thumbsDownAction = CardService.newAction() .setFunctionName("onRatingClicked") .setParameters({ key: "downVotes", title: pageTitle, pageSummary: summary, }); const thumbsUpButton = CardService.newImageButton() .setIconUrl( "https://fonts.gstatic.com/s/i/googlematerialicons/thumb_up_alt/v11/gm_blue-24dp/1x/gm_thumb_up_alt_gm_blue_24dp.png", ) .setAltText("Looks good") .setOnClickAction(thumbsUpAction); const thumbsDownButton = CardService.newImageButton() .setIconUrl( "https://fonts.gstatic.com/s/i/googlematerialicons/thumb_down_alt/v11/gm_blue-24dp/1x/gm_thumb_down_alt_gm_blue_24dp.png", ) .setAltText("Not great") .setOnClickAction(thumbsDownAction); const ratingButtons = CardService.newButtonSet() .addButton(thumbsUpButton) .addButton(thumbsDownButton); feedbackSection.addWidget(ratingButtons); } else { feedbackSection.addWidget( CardService.newTextParagraph().setText("Thank you for your feedback."), ); } const card = CardService.newCardBuilder() .setHeader(cardHeader) .addSection(summarySection) .addSection(feedbackSection) .build(); return card; } /** * Creates a Card to let user know an error has occurred. * * @return {!Card} */ function buildErrorCard() { const cardHeader = CardService.newCardHeader().setTitle( "Uh oh! Something went wrong.", ); const errorMessage = CardService.newTextParagraph().setText( "It looks like Gemini got stage fright.", ); const tryAgainButton = CardService.newTextButton() .setText("Try again") .setTextButtonStyle(CardService.TextButtonStyle.TEXT) .setOnClickAction(CardService.newAction().setFunctionName("onLinkPreview")); const buttonList = CardService.newButtonSet().addButton(tryAgainButton); const mainSection = CardService.newCardSection() .addWidget(errorMessage) .addWidget(buttonList); const errorCard = CardService.newCardBuilder() .setHeader(cardHeader) .addSection(mainSection) .build(); return errorCard; } ================================================ FILE: ai/devdocs-link-preview/Helpers.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Wraper around script properties to allow for a default value if unset. */ function scriptPropertyWithDefault(key, defaultValue = undefined) { const scriptProperties = PropertiesService.getScriptProperties(); const value = scriptProperties.getProperty(key); if (value) { return value; } return defaultValue; } ================================================ FILE: ai/devdocs-link-preview/Main.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Creates a link preview card for Google developer documentation links. * * @param {!Object} event * @return {!Card} */ function onLinkPreview(event) { const hostApp = event.hostApp; if (!event[hostApp].matchedUrl.url) { return; } const url = event[hostApp].matchedUrl.url; try { const info = getPageSummary(url); const card = buildCard(info.title, info.summary); const linkPreview = CardService.newLinkPreview() .setPreviewCard(card) .setTitle(info.title) .setLinkPreviewTitle(info.title); return linkPreview; } catch (error) { // Log the error console.error("Error occurred:", error); const errorCard = buildErrorCard(); return CardService.newActionResponseBuilder() .setNavigation(CardService.newNavigation().updateCard(errorCard)) .build(); } } /** * Action handler for a good rating . * * @param {!Object} e The event passed from click action. * @return {!Card} */ function onRatingClicked(e) { const key = e.parameters.key; const title = e.parameters.title; const pageSummary = e.parameters.pageSummary; const properties = PropertiesService.getScriptProperties(); let rating = Number(properties.getProperty(key) ?? 0); properties.setProperty(key, ++rating); const card = buildCard(title, pageSummary, false); const linkPreview = CardService.newLinkPreview() .setPreviewCard(card) .setTitle(title) .setLinkPreviewTitle(title); return linkPreview; } ================================================ FILE: ai/devdocs-link-preview/README.md ================================================ # Google Workspace Add-on - Developer Docs Link previews ## Project Description A Google Workspace Add-on that creates custom link previews for pages on the Google developer documentation site. The link preview uses AI to generate page summaries. ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Enable the Vertex AI API 1. Create a Service Account and grant the role `Vertex AI User` 1. Create a private key with type JSON. This will download the JSON file for use in the next section. 1. Open a stand alone Apps Script Project 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. 1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. 1. Add the project code to Apps Script ================================================ FILE: ai/devdocs-link-preview/Vertex.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const VERTEX_AI_LOCATION = scriptPropertyWithDefault( "project_location", "us-central1", ); const MODEL_ID = scriptPropertyWithDefault("model_id", "gemini-2.5-flash"); const SERVICE_ACCOUNT_KEY = scriptPropertyWithDefault("service_account_key"); /** * Invokes Gemini to extract the title and summary of a given URL. Responses may be cached. */ function getPageSummary(targetUrl) { const cachedResponse = CacheService.getScriptCache().get(targetUrl); if (cachedResponse) { return JSON.parse(cachedResponse); } const request = { contents: [ { role: "user", parts: [ { text: targetUrl, }, ], }, ], systemInstruction: { parts: [ { text: `You are a Google Developers documentation expert. In 2-3 sentences, create a short description of what the following web page is about based on the snippet of HTML from the page. Make the summary scannable. Don't repeat the URL in the description. Use proper grammar. Make the description easy to read. Only include the description in your response, exclude any conversational parts of the response. Make sure you use the most recent Google product names. Output the response as JSON with the page title as "title" and the summary as "summary"`, }, ], }, generationConfig: { temperature: 0.2, candidateCount: 1, maxOutputTokens: 2048, }, }; const credentials = credentialsForVertexAI(); const fetchOptions = { method: "POST", headers: { Authorization: `Bearer ${credentials.accessToken}`, }, contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify(request), }; const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}` + `/locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; const response = UrlFetchApp.fetch(url, fetchOptions); const responseText = response.getContentText(); console.log(responseText); if (response.getResponseCode() >= 400) { console.log(responseText); throw new Error("Unable to generate preview,"); } const parsedResponse = JSON.parse(responseText); const modelResponse = parsedResponse.candidates[0].content.parts[0].text; const jsonMatch = modelResponse.match(/(?<=^`{3}json$)([\s\S]*)(?=^`{3}$)/gm); if (!jsonMatch) { throw new Error("Unable to generate preview,"); } const jsonResponse = jsonMatch[0]; CacheService.getScriptCache().put(targetUrl, jsonResponse); return JSON.parse(jsonResponse); } /** * Gets credentials required to call Vertex API using a Service Account. * Requires use of Service Account Key stored with project * * @return {!Object} Containing the Cloud Project Id and the access token. */ function credentialsForVertexAI() { const credentials = SERVICE_ACCOUNT_KEY; if (!credentials) { throw new Error("service_account_key script property must be set."); } const parsedCredentials = JSON.parse(credentials); const service = OAuth2.createService("Vertex") .setTokenUrl("https://oauth2.googleapis.com/token") .setPrivateKey(parsedCredentials.private_key) .setIssuer(parsedCredentials.client_email) .setPropertyStore(PropertiesService.getScriptProperties()) .setScope("https://www.googleapis.com/auth/cloud-platform"); return { projectId: parsedCredentials.project_id, accessToken: service.getAccessToken(), }; } ================================================ FILE: ai/devdocs-link-preview/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "dependencies": { "libraries": [ { "userSymbol": "OAuth2", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", "version": "43" } ] }, "oauthScopes": [ "https://www.googleapis.com/auth/workspace.linkpreview", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/cloud-platform" ], "addOns": { "common": { "name": "DevDocs Previews", "logoUrl": "https://www.gstatic.com/images/branding/productlogos/google_developers/v8/web-24dp/logo_google_developers_color_1x_web_24dp.png", "layoutProperties": { "primaryColor": "#1A73E8" } }, "docs": { "linkPreviewTriggers": [ { "patterns": [ { "hostPattern": "developers.google.*" } ], "runFunction": "onLinkPreview", "labelText": "Page title", "logoUrl": "https://www.gstatic.com/images/branding/productlogos/google_developers/v8/web-24dp/logo_google_developers_color_1x_web_24dp.png" } ] }, "sheets": { "linkPreviewTriggers": [ { "patterns": [ { "hostPattern": "developers.google.*" } ], "runFunction": "onLinkPreview", "labelText": "Page title", "logoUrl": "https://www.gstatic.com/images/branding/productlogos/google_developers/v8/web-24dp/logo_google_developers_color_1x_web_24dp.png" } ] }, "slides": { "linkPreviewTriggers": [ { "patterns": [ { "hostPattern": "developers.google.*" } ], "runFunction": "onLinkPreview", "labelText": "Page title", "logoUrl": "https://www.gstatic.com/images/branding/productlogos/google_developers/v8/web-24dp/logo_google_developers_color_1x_web_24dp.png" } ] } } } ================================================ FILE: ai/drive-rename/README.md ================================================ # Google Workspace Add-on Drive - Name with Intelligence ## Project Description Google Workspace Add-on for Google Drive, which uses AI to recommend new names for the selected Doc in Google Drive by passing the body of the document within the AI prompt for context. ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Enable the Vertex AI API 1. Enable Google Drive API 1. Configure OAuth consent screen 1. Create a Service Account and grant the role Service `Vertex AI User` role 1. Create a private key with type JSON. This will download the JSON file for use in the next section. 1. Open a standalone Apps Script project. 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add a Script Property. Enter `model_id` as the property name and `gemini-pro` as the value. 1. Add a Script Property. Enter `project_location` as the property name and `us-central1` as the value. 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. 1. Add `Google Drive API v3` advanced service. 1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. 1. Add the project code to Apps Script ================================================ FILE: ai/drive-rename/ai.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const VERTEX_AI_LOCATION = PropertiesService.getScriptProperties().getProperty("project_location"); const MODEL_ID = PropertiesService.getScriptProperties().getProperty("model_id"); const SERVICE_ACCOUNT_KEY = PropertiesService.getScriptProperties().getProperty( "service_account_key", ); const STANDARD_PROMPT = ` Your task is to create 3 potential document names for this content. Also, create a summary for this content, using 2 to 3 sentences, and don't include formatting. Format the response as a JSON object with the first field called names and the summary field called summary. The content is below: `; /** * Packages prompt and necessary settings, then sends a request to * Vertex API. Returns the response as an JSON object extracted from the * Vertex API response object. * * @param prompt - String representing your prompt for Gemini AI. */ function getAiSummary(prompt) { const request = { contents: [ { role: "user", parts: [ { text: STANDARD_PROMPT, }, { text: prompt, }, ], }, ], generationConfig: { temperature: 0.2, maxOutputTokens: 2048, response_mime_type: "application/json", }, }; const credentials = credentialsForVertexAI(); const fetchOptions = { method: "POST", headers: { Authorization: `Bearer ${credentials.accessToken}`, }, contentType: "application/json", payload: JSON.stringify(request), }; const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}/locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; const response = UrlFetchApp.fetch(url, fetchOptions); const payload = JSON.parse(response.getContentText()); const jsonPayload = JSON.parse(payload.candidates[0].content.parts[0].text); return jsonPayload; } /** * Gets credentials required to call Vertex API using a Service Account. * * */ function credentialsForVertexAI() { const credentials = SERVICE_ACCOUNT_KEY; if (!credentials) { throw new Error("service_account_key script property must be set."); } const parsedCredentials = JSON.parse(credentials); const service = OAuth2.createService("Vertex") .setTokenUrl("https://oauth2.googleapis.com/token") .setPrivateKey(parsedCredentials.private_key) .setIssuer(parsedCredentials.client_email) .setPropertyStore(PropertiesService.getScriptProperties()) .setScope("https://www.googleapis.com/auth/cloud-platform"); return { projectId: parsedCredentials.project_id, accessToken: service.getAccessToken(), }; } ================================================ FILE: ai/drive-rename/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "serviceId": "drive", "version": "v3" } ] }, "oauthScopes": [ "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/drive.addons.metadata.readonly", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/documents" ], "urlFetchWhitelist": [ "https://*.googleusercontent.com/", "https://*.googleapis.com/" ], "addOns": { "common": { "name": "Name with Intelligence", "logoUrl": "https://fonts.gstatic.com/s/i/googlematerialicons/drive_file_rename_outline/v12/googblue-48dp/2x/gm_drive_file_rename_outline_googblue_48dp.png", "layoutProperties": { "primaryColor": "#4285f4", "secondaryColor": "#3f8bca" } }, "drive": { "homepageTrigger": { "runFunction": "onHomepageOpened" }, "onItemsSelectedTrigger": { "runFunction": "onDriveItemsSelected" } } } } ================================================ FILE: ai/drive-rename/drive.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Renames a file based on user selection / updates card. * * @param {!Event} e Add-on event context * @return {!Card} */ function renameFile(e) { const newName = e.formInput.names; const id = e.drive.activeCursorItem.id; DriveApp.getFileById(id).setName(newName); const eUpdated = { hostApp: "drive", drive: { selectedItems: [[Object]], activeCursorItem: { title: newName, id: id, iconUrl: e.drive.activeCursorItem.iconUrl, mimeType: e.drive.activeCursorItem.mimeType, }, commonEventObject: { hostApp: "DRIVE", platform: "WEB" }, clientPlatform: "web", }, }; return onCardUpdate(eUpdated); } /** * Redraws the same card to force AI to refresh its data. * * @param {!Event} e Add-on event context * @return {!Card} */ function updateCard(e) { const id = e.drive.activeCursorItem.id; const eConverted = { hostApp: "drive", drive: { selectedItems: [[Object]], activeCursorItem: { title: DriveApp.getFileById(id).getName(), id: id, iconUrl: e.drive.activeCursorItem.iconUrl, mimeType: e.drive.activeCursorItem.mimeType, }, commonEventObject: { hostApp: "DRIVE", platform: "WEB" }, clientPlatform: "web", }, }; return onCardUpdate(eConverted); } /** * Fetches the body of given document, using DocumentApp. * * @param {string} id The Google Document file ID. * @return {string} The body of the Google Document. */ function getDocumentBody(id) { const doc = DocumentApp.openById(id); const body = doc.getBody(); const text = body.getText(); return text; } /** * Fetches the body of given document, using DocsApi. * * @param {string} id The Google Document file ID. * @return {string} The body of the Google Document. */ function getDocAPIBody(id) { // Call DOC API REST endpoint to get the file const url = `https://docs.googleapis.com/v1/documents/${id}`; const response = UrlFetchApp.fetch(url, { method: "GET", headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, }, muteHttpExceptions: true, }); if (response.getResponseCode() !== 200) { throw new Error(`Drive API returned error \ ${response.getResponseCode()} :\ ${response.getContentText()}`); } const file = response.getContentText(); const data = JSON.parse(file); return data.body.content; } /** * Sends the given document to the trash folder. * * @param {!Event} e Add-on event context */ function moveFileToTrash(e) { const id = e.drive.activeCursorItem.id; const file = DriveApp.getFileById(id); file.setTrashed(true); } ================================================ FILE: ai/drive-rename/main.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Main entry point for add-on when opened. * * @param e - Add-on event context */ function onHomepageOpened(e) { const card = buildHomePage(); return { action: { navigations: [ { pushCard: card, }, ], }, }; } /** * Handles selection of a file in Google Drive. * * @param e - Add-on event context */ function onDriveItemsSelected(e) { return { action: { navigations: [ { pushCard: buildSelectionPage(e), }, ], }, }; } /** * Handles the update of the card on demand. * * @param e - (Modified) add-on event context */ function onCardUpdate(e) { return { action: { navigations: [ { updateCard: buildSelectionPage(e), }, ], }, }; } ================================================ FILE: ai/drive-rename/ui.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const ICO_HEADER = "https://fonts.gstatic.com/s/i/googlematerialicons/drive_file_rename_outline/v12/googblue-48dp/2x/gm_drive_file_rename_outline_googblue_48dp.png"; const ICON_RENAME = "https://fonts.gstatic.com/s/i/googlematerialicons/drive_file_rename_outline/v12/googblue-18dp/2x/gm_drive_file_rename_outline_googblue_18dp.png"; const ICON_RETRY = "https://fonts.gstatic.com/s/i/googlematerialicons/refresh/v16/googblue-18dp/2x/gm_refresh_googblue_18dp.png"; const ICON_DELETE = "https://fonts.gstatic.com/s/i/googlematerialicons/delete/v17/black-18dp/2x/gm_delete_black_18dp.png"; /** * Builds the card for the selected active item. * * @param e - Add-on event context */ function buildSelectionPage(e) { const selected = e.drive.activeCursorItem; // Check if Google Doc type, respond unsupported if not if (selected.mimeType !== "application/vnd.google-apps.document") { return { sections: [ { widgets: [ { textParagraph: { text: "Note: currently only Google Docs file types are supported.", }, }, ], }, ], header: buildHeader(), }; } // Get document body const docBody = getDocumentBody(selected.id); // Create widgets starting with Title const widgets = [ { textParagraph: { text: `${selected.title}`, }, }, ]; // Check if doc is empty before calling AI if (docBody.length > 1) { // Get AI data const aiResponse = getAiSummary(docBody); console.log("RESPONSE"); console.log(aiResponse); // Add the Summary text widgets.push({ decoratedText: { topLabel: "Summary", text: aiResponse.summary, wrapText: true, }, }); // Divider widgets.push({ divider: {} }); // Create an object of items const items = []; for (const name of aiResponse.names) { items.push({ text: name, value: name, selected: false, }); } // Set first item as selected items[0].selected = true; // Add the Radio button of 'names' as items widgets.push({ selectionInput: { name: "names", label: "Select a new name", type: "RADIO_BUTTON", items: items, }, }); // Create the 'Rename' button widgets.push({ buttonList: { buttons: [ { text: "Rename", icon: { iconUrl: ICON_RENAME, altText: "Rename", }, onClick: { action: { function: "renameFile", parameters: [ { key: "id", value: selected.id, }, ], loadIndicator: "SPINNER", }, }, }, { text: "", icon: { iconUrl: ICON_RETRY, altText: "Retry", }, onClick: { action: { function: "updateCard", parameters: [ { key: "id", value: selected.id, }, ], loadIndicator: "SPINNER", }, }, }, ], }, horizontalAlignment: "CENTER", }); } // end if // Don't call AI, but offer to delete else { // Add the Summary text widgets.push({ decoratedText: { topLabel: "Summary", text: "Empty document", wrapText: true, }, }); // Divider widgets.push({ divider: {} }); // Create the 'Delete' button widgets.push({ buttonList: { buttons: [ { text: "Move to trash", icon: { iconUrl: ICON_DELETE, altText: "Move to trash", }, onClick: { action: { function: "moveFileToTrash", parameters: [ { key: "id", value: selected.id, }, ], loadIndicator: "SPINNER", }, }, color: { red: 0.961, green: 0.6, blue: 0.667, alpha: 1, }, }, ], }, horizontalAlignment: "CENTER", }); } // end else return { sections: [ { widgets, }, ], header: buildHeader(), }; } /** * Builds the header for the Add-on Cards. */ function buildHeader() { const header = { title: "Name with Intelligence", subtitle: `"Untitled documents" no more!`, // Better Doc names w/ Gemini AI", imageUrl: ICO_HEADER, imageType: "SQUARE", }; return header; } /** * Builds the home page card. */ function buildHomePage() { const widgets = [ { textParagraph: { text: "Name with Intelligence enables you to quickly rename any Google Doc using suggestions provided via Google Gemini.", }, }, { divider: {} }, { textParagraph: { text: "👉 To use, select a Google Doc to rename. Then choose a new name from the list of AI generated names provided for you. A quick summary of the file is also provided by Google Gemini to help you make your decision.", }, }, { divider: {} }, { textParagraph: { text: "Note: currently only Google Docs file types are supported.", }, }, ]; return { sections: [ { widgets, }, ], header: buildHeader(), }; } ================================================ FILE: ai/email-classifier/Cards.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Triggered when the add-on is opened from the Gmail homepage. * * @param {!Object} e - The event object. * @returns {!Card} - The homepage card. */ function onHomepageTrigger(e) { return buildHomepageCard(); } /** * Builds the main card displayed on the Gmail homepage. * * @returns {!Card} - The homepage card. */ function buildHomepageCard() { // Create a new card builder const cardBuilder = CardService.newCardBuilder(); // Create a card header const cardHeader = CardService.newCardHeader(); cardHeader.setImageUrl( "https://fonts.gstatic.com/s/i/googlematerialicons/label_important/v20/googblue-24dp/1x/gm_label_important_googblue_24dp.png", ); cardHeader.setImageStyle(CardService.ImageStyle.CIRCLE); cardHeader.setTitle("Email Classifier"); // Add the header to the card cardBuilder.setHeader(cardHeader); // Create a card section const cardSection = CardService.newCardSection(); // Create buttons for generating sample emails and analyzing sentiment const buttonSet = CardService.newButtonSet(); // Create "Classify emails" button const classifyButton = createFilledButton({ text: "Classify emails", functionName: "main", color: "#007bff", icon: "new_label", }); buttonSet.addButton(classifyButton); // Create "Create Labels" button const createLabelsButtton = createFilledButton({ text: "Create labels", functionName: "createLabels", color: "#34A853", icon: "add", }); // Create "Remove Labels" button const removeLabelsButtton = createFilledButton({ text: "Remove labels", functionName: "removeLabels", color: "#FF0000", icon: "delete", }); if (labelsCreated()) { buttonSet.addButton(removeLabelsButtton); } else { buttonSet.addButton(createLabelsButtton); } // Add the button set to the section cardSection.addWidget(buttonSet); // Add the section to the card cardBuilder.addSection(cardSection); // Build and return the card return cardBuilder.build(); } /** * Creates a filled text button with the specified text, function, and color. * * @param {{text: string, functionName: string, color: string, icon: string}} options * - text: The text to display on the button. * - functionName: The name of the function to call when the button is clicked. * - color: The background color of the button. * - icon: The material icon to display on the button. * @returns {!TextButton} - The created text button. */ function createFilledButton({ text, functionName, color, icon }) { // Create a new text button const textButton = CardService.newTextButton(); // Set the button text textButton.setText(text); // Set the action to perform when the button is clicked const action = CardService.newAction(); action.setFunctionName(functionName); action.setLoadIndicator(CardService.LoadIndicator.SPINNER); textButton.setOnClickAction(action); // Set the button style to filled textButton.setTextButtonStyle(CardService.TextButtonStyle.FILLED); // Set the background color textButton.setBackgroundColor(color); textButton.setMaterialIcon(CardService.newMaterialIcon().setName(icon)); return textButton; } /** * Creates a notification response with the specified text. * * @param {string} notificationText - The text to display in the notification. * @returns {!ActionResponse} - The created action response. */ function buildNotificationResponse(notificationText) { // Create a new notification const notification = CardService.newNotification(); notification.setText(notificationText); // Create a new action response builder const actionResponseBuilder = CardService.newActionResponseBuilder(); // Set the notification for the action response actionResponseBuilder.setNotification(notification); // Build and return the action response return actionResponseBuilder.build(); } /** * Creates a card to display the spreadsheet link. * * @param {string} spreadsheetUrl - The URL of the spreadsheet. * @returns {!ActionResponse} - The created action response. */ function showSpreadsheetLink(spreadsheetUrl) { const updatedCardBuilder = CardService.newCardBuilder(); updatedCardBuilder.setHeader( CardService.newCardHeader().setTitle("Sheet generated!"), ); const updatedSection = CardService.newCardSection() .addWidget( CardService.newTextParagraph().setText("Click to open the sheet:"), ) .addWidget( CardService.newTextButton().setText("Open Sheet").setOpenLink( CardService.newOpenLink() .setUrl(spreadsheetUrl) .setOpenAs(CardService.OpenAs.FULL_SCREEN) // Opens in a new browser tab/window .setOnClose(CardService.OnClose.NOTHING), // Does nothing when the tab is closed ), ) .addWidget( CardService.newTextButton() // Optional: Add a button to go back or refresh .setText("Go Back") .setOnClickAction( CardService.newAction().setFunctionName("onHomepageTrigger"), ), // Go back to the initial state ); updatedCardBuilder.addSection(updatedSection); const newNavigation = CardService.newNavigation().updateCard( updatedCardBuilder.build(), ); return CardService.newActionResponseBuilder() .setNavigation(newNavigation) // This updates the current card in the UI .build(); } ================================================ FILE: ai/email-classifier/ClassifyEmail.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Constructs the prompt for classifying an email. * * @param {string} subject The subject of the email. * @param {string} body The body of the email. * @returns {string} The prompt for classifying an email. */ const classifyEmailPrompt = (subject, body) => ` Objective: You are an AI assistant tasked with classifying email threads. Analyze the entire email thread provided below and determine the single most appropriate classification label. Your response must conform to the provided schema. **Classification Labels & Descriptions:** * **needs-response**: The sender is explicitly or implicitly expecting a **direct, communicative reply** from me (${ME}) to answer a question, acknowledge receipt of information, confirm understanding, or continue a conversation. **Prioritize this label if the core expectation is purely a written or verbal communication back to the sender.** * **action-required**: The email thread requires me (${ME}) to perform a **distinct task, make a formal decision, provide a review leading to approval/rejection, or initiate a process that results in a demonstrable change or outcome.** This label is for actions *beyond* just sending a reply, such as completing a document, setting up a meeting, approving a request, delegating a task, or performing a delegated duty. * **for-your-info**: The email thread's primary purpose is to convey information, updates, or announcements. No immediate action or direct reply is expected or required from me (${ME}); the main purpose is for me to be informed and aware. This includes both routine 'FYI' updates and critical announcements where my role is to comprehend, not act or respond. **Evaluation Criteria - Consider the following:** * **Sender's Intent & My Role:** What does the sender want me (${ME}) to do, say, or know? * **Direct Requests:** Are there explicit questions or calls to action addressed to me (${ME})? * **Distinguishing Action vs. Response:** * If the email primarily asks for a *verbal or written communication* (e.g., answering a specific question, providing feedback, confirming receipt, giving thoughts, and is directly addressed to me (${ME})), it's likely \`needs-response\`. * If the email requires me to *perform a specific task or make a formal decision that goes beyond simply communicating* (e.g., completing a document, scheduling, approving a request, delegating, implementing a change), it's likely \`action-required\`. * **Urgency/Deadlines:** Are there time-sensitive elements mentioned? * **Last Message Focus:** Give slightly more weight to the content of the most recent messages in the thread. * **Keywords:** * Look for terms like "answer," "reply to," "your thoughts on," "confirm," "acknowledge" for \`needs-response\`. * Look for terms like "complete," "approve," "review and approve," "sign," "process," "set up," "delegate" for \`action-required\`. * Look for terms like "FYI," "update," "announcement," "read," "info" for \`for-information\`. * **Overall Significance:** Is the topic critical or routine, influencing the *type* of information being conveyed? **Input:** Email message content Subject: ${subject} --- Email Thread Messages --- ${body} --- End of Email Thread --- **Output:** Return the single best classification and a brief justification. Format: JSON object with '[Classification]', and '[Reason]' `.trim(); /** * Classifies an email based on its subject and messages. * * @param {string} subject The subject of the email. * @param {!Array} messages An array of Gmail messages. * @returns {!Object} The classification object. */ function classifyEmail(subject, messages) { const body = []; for (let i = 0; i < messages.length; i++) { const message = messages[i]; body.push(`Message ${i + 1}:`); body.push(`From: ${message.getFrom()}`); body.push(`To:${message.getTo()}`); body.push("Body:"); body.push(message.getPlainBody()); body.push("---"); } // Prepare the request payload const payload = { contents: [ { role: "user", parts: [ { text: classifyEmailPrompt(subject, body.join("\n")), }, ], }, ], generationConfig: { temperature: 0, topK: 1, topP: 0.1, seed: 37, maxOutputTokens: 1024, responseMimeType: "application/json", // Expected response format for simpler parsing. responseSchema: { type: "object", properties: { classification: { type: "string", enum: Object.keys(classificationLabels), }, reason: { type: "string", }, }, }, }, }; // Prepare the request options const options = { method: "POST", headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, }, contentType: "application/json", muteHttpExceptions: true, // Set to true to inspect the error response payload: JSON.stringify(payload), }; // Make the API request const response = UrlFetchApp.fetch(API_URL, options); // Parse the response. There are two levels of JSON responses to parse. const parsedResponse = JSON.parse(response.getContentText()); const text = parsedResponse.candidates[0].content.parts[0].text; const classification = JSON.parse(text); return classification; } ================================================ FILE: ai/email-classifier/Code.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Main function to process emails, classify them, and update a spreadsheet. * This function searches for unread emails in the inbox from the last 7 days, * classifies them based on their subject and content, adds labels to the emails, * creates draft responses for emails that need a response, and logs the * classification results in a spreadsheet. * @return {string} The URL of the spreadsheet. */ function main() { // Calculate the date 7 days ago const today = new Date(); const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); // Create a Sheet const headers = ["Subject", "Classification", "Reason"]; const spreadsheet = createSheetWithHeaders(headers); // Format the date for the Gmail search query (YYYY/MM/DD) // Using Utilities.formatDate ensures correct formatting based on script // timezone const formattedDate = Utilities.formatDate( sevenDaysAgo, Session.getScriptTimeZone(), "yyyy/MM/dd", ); // Construct the search query const query = `is:unread after:${formattedDate} in:inbox`; console.log(`Searching for emails with query: ${query}`); // Search for threads matching the query // Note: GmailApp.search() returns threads where *at least one* message // matches const threads = GmailApp.search(query); createLabels(); for (const thread of threads) { const messages = thread.getMessages(); const subject = thread.getFirstMessageSubject(); const { classification, reason } = classifyEmail(subject, messages); console.log(`Classification: ${classification}, Reason: ${reason}`); thread.addLabel(classificationLabels[classification].gmailLabel); if (classification === "needs-response") { const draft = draftEmail(subject, messages); thread.createDraftReplyAll(null, { htmlBody: draft }); } addDataToSheet(spreadsheet, hyperlink(thread), classification, reason); } return showSpreadsheetLink(spreadsheet.getUrl()); } ================================================ FILE: ai/email-classifier/Constants.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const PROJECT_ID = ""; const LOCATION = "us-central1"; const API_ENDPOINT = `${LOCATION}-aiplatform.googleapis.com`; const MODEL = "gemini-2.5-pro-preview-05-06"; const GENERATE_CONTENT_API = "generateContent"; const API_URL = `https://${API_ENDPOINT}/v1/projects/${PROJECT_ID}/locations/${LOCATION}/publishers/google/models/${MODEL}:${GENERATE_CONTENT_API}`; const ME = ""; ================================================ FILE: ai/email-classifier/DraftEmail.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Constructs a prompt for drafting an email. * * @param {string} subject The subject of the email thread. * @param {string} body The body of the email thread. * @returns {string} The prompt string. */ const draftEmailPrompt = (subject, body) => ` You are an AI assistant. Based on the following email thread: Subject: ${subject} --- Email Thread Messages --- ${body} --- End of Email Thread --- Task: Considering all messages in this thread: - Help me ${ME} draft a polite and professional reply that addresses the key points from the most recent message(s) in HTML - Do NOT include subject of the email Draft Criteria: Consider the following: * Explicit Questions: Are there direct questions posed to me ${ME}, especially in the most recent messages? * Calls to Action: Are there clear instructions or requests for the me ${ME}, to *do* something? * Urgency/Deadlines: Does the thread mention deadlines or urgent requests? * Sender's Intent: What does the sender seem to want? * My Role: What am I (${ME}) being asked to do or know? * Keywords: Look for terms like "please," "urgent," "FYI," "question," "task," "review," "approve," "respond," "deadline." * Last Message Focus: Give slightly more weight to the most recent messages. * Overall Significance: Is the topic critical or routine? Output: Return the draft message in HTML format. Format: HTML Example format: My Email

Hello, World!

This is an HTML email sent from Google Apps Script.

`.trim(); /** * Drafts an email based on the given subject and messages. * * @param {string} subject The subject of the email thread. * @param {!Array} messages An array of Gmail messages. * @returns {string|null} The drafted email in HTML format or null if not found. */ function draftEmail(subject, messages) { const body = []; for (let i = 0; i < messages.length; i++) { const message = messages[i]; body.push(`Message ${i + 1}:`); body.push(`From: ${message.getFrom()}`); body.push(`To:${message.getTo()}`); body.push("Body:"); body.push(message.getPlainBody()); body.push("---"); } // Prepare the request payload const payload = { contents: [ { role: "user", parts: [ { text: draftEmailPrompt(subject, body.join("\n")), }, ], }, ], generationConfig: { temperature: 0, topK: 1, topP: 0.1, seed: 37, maxOutputTokens: 1024, responseMimeType: "text/plain", }, }; // Prepare the request options const options = { method: "POST", headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, }, contentType: "application/json", muteHttpExceptions: true, // Set to true to inspect the error response payload: JSON.stringify(payload), }; // Make the API request const response = UrlFetchApp.fetch(API_URL, options); // Parse the response. There are two levels of JSON responses to parse. const parsedResponse = JSON.parse(response.getContentText()); const draft = parsedResponse.candidates[0].content.parts[0].text; return extractHtmlContent(draft); } /** * Extracts HTML content from a string. * * @param {string} textString The string to extract HTML content from. * @returns {string|null} The HTML content or null if not found. */ function extractHtmlContent(textString) { // The regex pattern: // ````html` (literal start marker) // `(.*?)` (capturing group for any character, non-greedily, including newlines) // ` ``` ` (literal end marker) // `s` flag makes '.' match any character including newlines. const match = textString.match(/```html(.*?)```/s); if (match?.[1]) { return match[1]; // Return the content of the first capturing group } return null; // Or an empty string, depending on desired behavior if not found } ================================================ FILE: ai/email-classifier/Labels.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const classificationLabels = { "action-required": { name: "🚨 Action Required", textColor: "#ffffff", backgroundColor: "#1c4587", }, "needs-response": { name: "↪️ Needs Response", textColor: "#ffffff", backgroundColor: "#16a765", }, "for-your-info": { name: "ℹ️ For Your Info", textColor: "#000000", backgroundColor: "#fad165", }, }; /** * Creates Gmail labels based on the classification labels defined in the `classificationLabels` object. * If a label already exists, it updates the color. Otherwise, it creates a new label. * After creating or updating labels, it logs a message to the console and returns the homepage card. * @returns {!CardService.Card} The homepage card. */ function createLabels() { for (const labelName in classificationLabels) { const classificationLabel = classificationLabels[labelName]; const { name, textColor, backgroundColor } = classificationLabel; let gmailLabel = GmailApp.getUserLabelByName(name); if (!gmailLabel) { gmailLabel = GmailApp.createLabel(name); Gmail.Users.Labels.update( { name: name, color: { textColor: textColor, backgroundColor: backgroundColor, }, }, "me", fetchLabelId(name), ); } classificationLabel.gmailLabel = gmailLabel; } console.log("Labels created."); return buildHomepageCard(); } /** * Checks if all classification labels exist in Gmail. * @returns {boolean} True if all labels exist, false otherwise. */ function labelsCreated() { for (const labelName in classificationLabels) { const { name } = classificationLabels[labelName]; const gmailLabel = GmailApp.getUserLabelByName(name); if (!gmailLabel) { return false; } } return true; } /** * Fetches the ID of a Gmail label by its name. * @param {string} name The name of the label. * @returns {string} The ID of the label. */ function fetchLabelId(name) { return Gmail.Users.Labels.list("me").labels.find((_) => _.name === name).id; } /** * Removes all classification labels from Gmail. * After removing labels, it logs a message to the console and returns the homepage card. * @returns {!CardService.Card} The homepage card. */ function removeLabels() { for (const labelName in classificationLabels) { const classificationLabel = classificationLabels[labelName]; const gmailLabel = GmailApp.getUserLabelByName(classificationLabel.name); if (gmailLabel) { gmailLabel.deleteLabel(); classificationLabel.gmailLabel = undefined; } } console.log("Labels removed."); return buildHomepageCard(); } ================================================ FILE: ai/email-classifier/README.md ================================================ # Email Classifier This Apps Script project provides a Gmail add-on that classifies emails based on their content and subject, and performs actions such as adding labels, creating draft responses, and logging results in a Google Sheet. It leverages the Gemini API for natural language processing. ## Features * **Email Classification:** Classifies unread emails in your inbox into three categories: * `needs-response`: Emails requiring a direct reply. * `action-required`: Emails requiring a specific task or decision. * `for-your-info`: Emails for information only, no action needed. * **Labeling:** Adds Gmail labels to emails based on their classification. * **Draft Responses:** Generates draft email responses for emails classified as `needs-response`. * **Spreadsheet Logging:** Logs email details, classification, and reason to a Google Sheet. * **User-Friendly Interface:** Provides a Gmail add-on with buttons for classification, label creation, and removal. ## Setup ### 1. Enable Google APIs * Go to the [Google Cloud Console](https://console.cloud.google.com/). * Create or select a project. * **Gemini API:** * [Enable the Gemini API](https://console.cloud.google.com/flows/enableapi?apiid=aiplatform.googleapis.com) * **Gmail API:** * [Enable the Gmail API](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com) * **Sheets API:** * [Enable the Sheets API](https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com) ### 2. Apps Script Project 1. **Create a New Project:** * Go to [script.google.com](https://script.google.com). * Create a new project. 1. **Enable `appsscript.json` Manifest:** * Go to **Project Settings**. * Check the **Show "appsscript.json" manifest file in editor** option. 1. **Associate with Google Cloud Project:** * In your Apps Script project, go to **Project Settings**. * Under **Google Cloud Platform (GCP) Project**, click **Change project**. * Enter your Google Cloud Project number and click **Set project**. 1. **Copy Code:** * Copy the code from each `.gs` file in this directory into the corresponding file in your Apps Script project. 1. **Update `Constants.gs`:** * Replace the placeholder values in `Constants.gs`: * `PROJECT_ID`: Your Google Cloud Project ID. * `ME`: Your name. 1. **Update `appsscript.json`:** * Ensure the `appsscript.json` file is configured correctly. ### 3. Configure OAuth Consent Screen Google Workspace add-ons require a consent screen configuration. Configuring your add-on's OAuth consent screen defines what Google displays to users. 1. **Go to Google Cloud Console:** * Navigate to the [Google Auth Platform - Branding page](https://console.cloud.google.com/auth/branding). 1. **App Information:** * **App name:** Enter a name for your add-on (e.g., "Email Classifier"). * **User support email:** Select your email address. * **Developer contact information:** Enter your email address. * Click **Next**. 1. **Audience:** * Select **Internal**. * Click **Next**. 1. **Contact Information:** * Select your email address. * Click **Next**. 1. **Finish:** * Check **I agree to the [Google API Services: User Data Policy](https://developers.google.com/terms/api-services-user-data-policy)**. * Click **Continue**. 1. **Create:** * Click **Create**. ### 4. Deploy the Add-on 1. **Deploy:** * Click "Deploy" > "Test deployments". * Select "Gmail add-on". * Click "Install" to install the add-on for your account. ## How to Run 1. **Open Gmail:** * Open Gmail in your browser. 1. **Open the Add-on:** * The "Email Classifier" add-on should appear in the right sidebar. 1. **Classify Emails:** * Click the "Classify emails" button. * The add-on will process unread emails from the last 7 days. 1. **View Results:** * A link to the generated Google Sheet will be displayed. * Open the sheet to view the classification results. 1. **Create/Remove Labels:** * Use the "Create labels" or "Remove labels" buttons to manage Gmail labels. ## Code Overview * **`Cards.gs`:** * Defines the UI for the Gmail add-on, including buttons and actions. * **`ClassifyEmail.gs`:** * Constructs prompts for the Gemini API. * Sends email content to the Gemini API for classification. * Parses the API response. * **`Code.gs`:** * Main function to search, classify, label, and log emails. * **`Constants.gs`:** * Stores project-specific constants (e.g., API URL, project ID, email). * **`DraftEmail.gs`:** * Constructs prompts for the Gemini API to generate draft responses. * Sends email content to the Gemini API for draft generation. * Parses the API response. * **`Labels.gs`:** * Creates, updates, and removes Gmail labels. * **`Sheet.gs`:** * Creates and updates Google Sheets for logging. * **`appsscript.json`:** * Configuration file for the Apps Script project. ## Important Notes * **Gemini API Usage:** This project relies on the Gemini API for natural language processing. Make sure you have the API enabled and have sufficient quota. * **OAuth Scopes:** The `appsscript.json` file includes the necessary OAuth scopes for Gmail, Sheets, and the Gemini API. * **Error Handling:** The code includes basic error handling, but you may need to add more robust error handling for production use. * **Rate Limits:** Be mindful of API rate limits, especially when processing large numbers of emails. * **Security:** Ensure that you are handling user data securely. ## Disclaimer This code is provided as-is, without any warranty. Use at your own risk. Feel free to modify and adapt this code to your specific needs. ================================================ FILE: ai/email-classifier/Sheet.gs ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Creates a spreadsheet with the given headers. * @param {!Array} headers The headers for the spreadsheet. * @return {!Spreadsheet} The created spreadsheet. */ function createSheetWithHeaders(headers) { const today = new Date().toLocaleString(); const spreadsheet = SpreadsheetApp.create(`Emails from ${today}`); const sheet = spreadsheet.getActiveSheet(); sheet.getRange(1, 1, 1, headers.length).setValues([headers]); addTable(spreadsheet); console.log(`Successfully created spreadsheet: ${spreadsheet.getUrl()}`); return spreadsheet; } /** * Adds data to the spreadsheet. * @param {!Spreadsheet} spreadsheet The spreadsheet to add data to. * @param {string} subject The subject of the email. * @param {string} classification The classification of the email. * @param {string} reason The reason for the classification. */ function addDataToSheet(spreadsheet, subject, classification, reason) { const sheet = spreadsheet.getActiveSheet(); const newRow = [subject, classification, reason]; sheet.appendRow(newRow); } /** * Creates a hyperlink for the given thread. * @param {!GmailThread} thread The thread to create a hyperlink for. * @return {string} The hyperlink. */ function hyperlink(thread) { const link = `https://mail.google.com/mail/u/0/#inbox/${thread.getId()}`; return `=HYPERLINK("${link}", "${thread.getFirstMessageSubject()}")`; } /** * Adds a table to the spreadsheet with a dropdown for classification. * @param {!Spreadsheet} ss The spreadsheet to add the table to. */ function addTable(ss) { const values = Object.keys(classificationLabels).map((label) => { return { userEnteredValue: label }; }); const addTableRequest = { requests: [ { addTable: { table: { name: "Email classification", range: { sheetId: 0, startColumnIndex: 0, endColumnIndex: 2, }, columnProperties: [ { columnIndex: 1, columnType: "DROPDOWN", dataValidationRule: { condition: { type: "ONE_OF_LIST", values: values }, }, }, ], }, }, }, ], }; Sheets.Spreadsheets.batchUpdate(addTableRequest, ss.getId()); } ================================================ FILE: ai/email-classifier/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Gmail", "version": "v1", "serviceId": "gmail" }, { "userSymbol": "Sheets", "version": "v4", "serviceId": "sheets" } ] }, "oauthScopes": [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/spreadsheets" ], "addOns": { "common": { "name": "Email Classifier", "logoUrl": "https://fonts.gstatic.com/s/i/googlematerialicons/label_important/v20/googblue-24dp/1x/gm_label_important_googblue_24dp.png" }, "gmail": { "homepageTrigger": { "runFunction": "onHomepageTrigger", "enabled": true } } }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: ai/gmail-sentiment-analysis/Cards.gs ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Builds the card for to display in the sidepanel of gmail. * @return {CardService.Card} The card to show to the user. */ function buildCard_GmailHome(notifyOk = false) { const imageUrl = "https://icons.iconarchive.com/icons/roundicons/100-free-solid/48/spy-icon.png"; const image = CardService.newImage().setImageUrl(imageUrl); const cardHeader = CardService.newCardHeader() .setImageUrl(imageUrl) .setImageStyle(CardService.ImageStyle.CIRCLE) .setTitle("Analyze your GMail"); const action = CardService.newAction().setFunctionName("analyzeSentiment"); const button = CardService.newTextButton() .setText("Identify angry customers") .setOnClickAction(action) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); const buttonSet = CardService.newButtonSet().addButton(button); const section = CardService.newCardSection() .setHeader("Emails sentiment analysis") .addWidget(buttonSet); const card = CardService.newCardBuilder() .setHeader(cardHeader) .addSection(section); /** * This builds the card that contains the footer that informs * the user about the successful execution of the Add-on. */ if (notifyOk === true) { const fixedFooter = CardService.newFixedFooter().setPrimaryButton( CardService.newTextButton() .setText("Analysis complete") .setOnClickAction( CardService.newAction().setFunctionName("buildCard_GmailHome"), ), ); card.setFixedFooter(fixedFooter); } return card.build(); } ================================================ FILE: ai/gmail-sentiment-analysis/Code.gs ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Callback for rendering the homepage card. * @return {CardService.Card} The card to show to the user. */ function onHomepage(e) { return buildCard_GmailHome(); } ================================================ FILE: ai/gmail-sentiment-analysis/Gmail.gs ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Callback for initiating the sentiment analysis. * @return {CardService.Card} The card to show to the user. */ function analyzeSentiment() { emailSentiment(); return buildCard_GmailHome(true); } /** * Gets the last 10 threads in the inbox and the corresponding messages. * Fetches the label that should be applied to negative messages. * The processSentiment is called on each message * and tested with RegExp to check for a negative answer from the model */ function emailSentiment() { const threads = GmailApp.getInboxThreads(0, 10); const msgs = GmailApp.getMessagesForThreads(threads); const label_upset = GmailApp.getUserLabelByName("UPSET TONE 😡"); let currentPrediction; for (let i = 0; i < msgs.length; i++) { for (let j = 0; j < msgs[i].length; j++) { const emailText = msgs[i][j].getPlainBody(); currentPrediction = processSentiment(emailText); if (currentPrediction === true) { label_upset.addToThread(msgs[i][j].getThread()); } } } } ================================================ FILE: ai/gmail-sentiment-analysis/README.md ================================================ # Gmail sentiment analysis with Vertex AI ## Project Description Google Workspace Add-on that extends Gmail and adds sentiment analysis capabilities. ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Enable the Vertex AI API 1. Create a Service Account and grant the role `Vertex AI User` 1. Create a private key with type JSON. This will download the JSON file for use in the next section. 1. Open an Apps Script Project bound to a Google Sheets Spreadsheet 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. 1. Add OAuth2 v43 Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. 1. Add the project code to Apps Script ## Usage 1. Create a label in Gmail with this exact text and emojy (case sensitive!): UPSET TONE 😡 1. In Gmail, click on the Productivity toolbox icon (icon of a spy) in the sidepanel. 1. The sidepanel will open up. Grant the Add-on autorization to run. 1. The Add-on will load. Click on the blue button "Identify angry customers." 1. Close the Add-on by clicking on the X in the top right corner. 1. It can take a couple of minutes until the label is applied to the messages that have a negative tone. 1. If you don't want to wait until the labels are added, you can refresh the browser. ================================================ FILE: ai/gmail-sentiment-analysis/Vertex.gs ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const PROJECT_ID = "ADD YOUR GCP PROJECT ID HERE"; const VERTEX_AI_LOCATION = "europe-west2"; const MODEL_ID = "gemini-2.5-pro"; const SERVICE_ACCOUNT_KEY = PropertiesService.getScriptProperties().getProperty( "service_account_key", ); /** * Packages prompt and necessary settings, then sends a request to * Vertex API. * A check is performed to see if the response from Vertex AI contains FALSE as a value. * Returns the outcome of that check which is a boolean. * * @param emailText - Email message that is sent to the model. */ function processSentiment(emailText) { const prompt = `Analyze the following message: ${emailText}. If the sentiment of this message is negative, answer with FALSE. If the sentiment of this message is neutral or positive, answer with TRUE. Do not use any other words than the ones requested in this prompt as a response!`; const request = { contents: [ { role: "user", parts: [ { text: prompt, }, ], }, ], generationConfig: { temperature: 0.9, maxOutputTokens: 1024, }, }; const credentials = credentialsForVertexAI(); const fetchOptions = { method: "POST", headers: { Authorization: `Bearer ${credentials.accessToken}`, }, contentType: "application/json", muteHttpExceptions: true, payload: JSON.stringify(request), }; const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/` + `locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; const response = UrlFetchApp.fetch(url, fetchOptions); const payload = JSON.parse(response.getContentText()); const regex = /FALSE/; return regex.test(payload.candidates[0].content.parts[0].text); } /** * Gets credentials required to call Vertex API using a Service Account. * Requires use of Service Account Key stored with project * * @return {!Object} Containing the Cloud Project Id and the access token. */ function credentialsForVertexAI() { const credentials = SERVICE_ACCOUNT_KEY; if (!credentials) { throw new Error("service_account_key script property must be set."); } const parsedCredentials = JSON.parse(credentials); const service = OAuth2.createService("Vertex") .setTokenUrl("https://oauth2.googleapis.com/token") .setPrivateKey(parsedCredentials.private_key) .setIssuer(parsedCredentials.client_email) .setPropertyStore(PropertiesService.getScriptProperties()) .setScope("https://www.googleapis.com/auth/cloud-platform"); return { projectId: parsedCredentials.project_id, accessToken: service.getAccessToken(), }; } ================================================ FILE: ai/gmail-sentiment-analysis/appsscript.json ================================================ { "timeZone": "Europe/Madrid", "dependencies": { "libraries": [ { "userSymbol": "OAuth2", "version": "43", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF" } ] }, "addOns": { "common": { "name": "Productivity toolbox", "logoUrl": "https://icons.iconarchive.com/icons/roundicons/100-free-solid/64/spy-icon.png", "useLocaleFromApp": true }, "gmail": { "homepageTrigger": { "runFunction": "onHomepage", "enabled": true } } }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: ai/standup-chat-app/README.md ================================================ # Chat API - Stand up with AI ## Project Description Google Chat application that creates AI summaries of a consolidation Chat threads and posts them back within the top-level Chat message. Use case is using AI to streamline Stand up content within Google Chat. ## Prerequisites * Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled ## Set up your environment 1. Create a Cloud Project 1. Configure OAuth consent screen 1. Enable the Admin SDK API 1. Enable the Generative Language API 1. Enable and configure the Google Chat API with the following values: 1. App status: Live - available to users 1. App name: “Standup” 1. Avatar URL: “https://www.gstatic.com/images/branding/productlogos/chat_2020q4/v8/web-24dp/logo_chat_2020q4_color_2x_web_24dp.png” 1. Description: “Standup App” 1. Enable Interactive features: Disabled 1. Create a Google Gemini API Key 1. Navigate to https://aistudio.google.com/app/apikey 1. Create API key for existing project from step 1 1. Copy the generated key 1. Create and open a standalone Apps Script project 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 1. Add the following script properties: 1. Set `API_KEY` with the API key previously generated as the value. 1. Set `SPREADSHEET_ID` with the file ID of a blank spreadsheet. 1. Set `SPACE_NAME` to the resource name of a Chat space (e.g. `spaces/AAAXYZ`) 1. Enable the Google Chat advanced service 1. Enable the AdminDirectory advanced service 1. Add the project code to Apps Script 1. Enable triggers: 1. Add Time-driven to run function `standup` at the desired interval frequency (e.g. Week timer) 1. Add Time-driven to run function `summarize` at the desired interval frequency (e.g. Hour timer) ================================================ FILE: ai/standup-chat-app/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Chat", "serviceId": "chat", "version": "v1" }, { "userSymbol": "AdminDirectory", "serviceId": "admin", "version": "directory_v1" } ] }, "webapp": { "executeAs": "USER_ACCESSING", "access": "DOMAIN" }, "oauthScopes": [ "https://www.googleapis.com/auth/chat.messages", "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/admin.directory.user.readonly", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/chat.spaces.create", "https://www.googleapis.com/auth/chat.spaces", "https://www.googleapis.com/auth/chat.spaces.readonly", "https://www.googleapis.com/auth/chat.spaces.create", "https://www.googleapis.com/auth/chat.delete", "https://www.googleapis.com/auth/chat.memberships", "https://www.googleapis.com/auth/chat.memberships.app", "https://www.googleapis.com/auth/userinfo.email" ] } ================================================ FILE: ai/standup-chat-app/db.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** @typedef {object} Message * @property {string} name * @property {string} text * @property {object} sender * @property {string} sender.type * @property {string} sender.name * @property {object[]} annotations * @property {number} annotations.startIndex * @property {string} annotations.type * @property {object} annotations.userMention * @property {number} annotations.length * @property {string} formattedText * @property {string} createTime * @property {string} argumentText * @property {object} thread * @property {string} thread.name * @property {object} space * @property {string} space.name */ class DB { /** * params {String} spreadsheetId */ constructor(spreadsheetId) { this.spreadsheetId = spreadsheetId; this.sheetName = "Messages"; } /** * @returns {SpreadsheetApp.Sheet} */ get sheet() { const spreadsheet = SpreadsheetApp.openById(this.spreadsheetId); let sheet = spreadsheet.getSheetByName(this.sheetName); // create if it does not exist if (sheet === undefined) { sheet = spreadsheet.insertSheet(); sheet.setName(this.sheetName); } return sheet; } /** * @returns {Message|undefined} */ get last() { const lastRow = this.sheet.getLastRow(); if (lastRow === 0) return undefined; return JSON.parse(this.sheet.getSheetValues(lastRow, 1, 1, 2)[0][1]); } /** * @params {Chat_v1.Chat.V1.Schema.Message} message */ append(message) { this.sheet.appendRow([message.name, JSON.stringify(message, null, 2)]); } } /** * Test function for DB Object */ function testDB() { const db = new DB(SPREADSHEET_ID); let thread = db.last; if (thread === undefined) return; console.log(thread); db.rowOffset = 1; thread = db.last; if (thread === undefined) return; console.log(thread); } ================================================ FILE: ai/standup-chat-app/gemini.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Makes a simple content-only call to Gemini AI. * * @param {string} text Prompt to pass to Gemini API. * @param {string} API_KEY Developer API Key enabled to call Gemini. * * @return {string} Response from AI call. */ function generateContent(text, API_KEY) { const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${API_KEY}`; return JSON.parse( UrlFetchApp.fetch(url, { method: "POST", headers: { "content-type": "application/json", }, payload: JSON.stringify({ contents: [ { parts: [{ text }], }, ], }), }).getContentText(), ); } ================================================ FILE: ai/standup-chat-app/main.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** TODO * Update global variables for your project settings * */ const API_KEY = PropertiesService.getScriptProperties().getProperty("API_KEY"); const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID"); // e.g. "1O0IW7fW1QeFLa7tIrv_h7_PlSUTB6kd0miQO_sXo7p0" const SPACE_NAME = PropertiesService.getScriptProperties().getProperty("SPACE_NAME"); // e.g. "spaces/AAAABCa12Cc" const SUMMARY_HEADER = "\n\n*Gemini Generated Summary*\n\n"; /** * Sends the message to create new standup instance. * Called by trigger on interval of standup, e.g. Weekly * * @return {string} The thread name of the message sent. */ function standup() { const db = new DB(SPREADSHEET_ID); const last = db.last; let text = ` Please share your weekly update here.\n\n*Source Code*: `; if (last) { text += `\n*Last Week*: <${linkToThread(last)}|View thread>`; } const message = Chat.Spaces.Messages.create( { text, }, PropertiesService.getScriptProperties().getProperty("spaceName"), // Demo replaces => SPACE_NAME ); db.append(message); console.log(`Thread Name: ${message.thread.name}`); return message.thread.name; } /** * Uses AI to create a summary of messages for a stand up period. * Called by trigger on interval required to summarize, e.g. Hourly * * @return n/a */ function summarize() { const db = new DB(SPREADSHEET_ID); const last = db.last; if (last === undefined) return; const filter = `thread.name=${last.thread.name}`; let { messages } = Chat.Spaces.Messages.list( PropertiesService.getScriptProperties().getProperty("spaceName"), { filter }, ); // Demo replaces => SPACE_NAME messages = (messages ?? []) .slice(1) .filter((message) => message.slashCommand === undefined); if (messages.length === 0) { return; } const history = messages .map(({ sender, text }) => `${cachedGetSenderDisplayName(sender)}: ${text}`) .join("/n"); const response = generateContent( `Summarize the following weekly tasks and discussion per team member in a single concise sentence for each individual with an extra newline between members, but without using markdown or any special character except for newlines: ${history}`, API_KEY, ); const summary = response.candidates[0].content?.parts[0].text; if (summary === undefined) { return; } Chat.Spaces.Messages.update( { text: last.formattedText + SUMMARY_HEADER + summary.replace("**", "*"), }, last.name, { update_mask: "text" }, ); } /** * Gets the display name from AdminDirectory Services. * * @param {!Object} sender * @return {string} User name on success | 'Unknown' if not. */ function getSenderDisplayName(sender) { try { const user = AdminDirectory.Users.get(sender.name.replace("users/", ""), { projection: "BASIC", viewType: "domain_public", }); return user.name.displayName ?? user.name.fullName; } catch (e) { console.error("Unable to get display name"); return "Unknown"; } } const cachedGetSenderDisplayName = memoize(getSenderDisplayName); /** * @params {Chat_v1.Chat.V1.Schema.Message|Message} message * @returns {String} */ function linkToThread(message) { // https://chat.google.com/room/SPACE/THREAD/ return `https://chat.google.com/room/${message.space.name.split("/").pop()}/${message.thread.name.split("/").pop()}`; } ================================================ FILE: ai/standup-chat-app/memoize.js ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * A generic hash function that takes a string and computes a hash using the * specified algorithm. * * @param {string} str - The string to hash. * @param {Utilities.DigestAlgorithm} algorithm - The algorithm to use to * compute the hash. Defaults to MD5. * @returns {string} The base64 encoded hash of the string. */ function hash(str, algorithm = Utilities.DigestAlgorithm.MD5) { const digest = Utilities.computeDigest(algorithm, str); return Utilities.base64Encode(digest); } /** * Memoizes a function by caching its results based on the arguments passed. * * @param {Function} func - The function to be memoized. * @param {number} [ttl=600] - The time to live in seconds for the cached * result. The maximum value is 600. * @param {Cache} [cache=CacheService.getScriptCache()] - The cache to store the * memoized results. * @returns {Function} - The memoized function. * * @example * * const cached = memoize(myFunction); * cached(1, 2, 3); // The result will be cached * cached(1, 2, 3); // The cached result will be returned * cached(4, 5, 6); // A new result will be calculated and cached */ function memoize(func, ttl = 600, cache = CacheService.getScriptCache()) { return (...args) => { // consider a more robust input to the hash function to handler complex // types such as functions, dates, and regex const key = hash(JSON.stringify([func.toString(), ...args])); const cached = cache.get(key); if (cached != null) { return JSON.parse(cached); } const result = func(...args); cache.put(key, JSON.stringify(result), ttl); return result; }; } ================================================ FILE: apps-script/execute/target.js ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_api_execute] /** * Return the set of folder names contained in the user's root folder as an * object (with folder IDs as keys). * @return {Object} A set of folder names keyed by folder ID. */ function getFoldersUnderRoot() { const root = DriveApp.getRootFolder(); const folders = root.getFolders(); const folderSet = {}; while (folders.hasNext()) { const folder = folders.next(); folderSet[folder.getId()] = folder.getName(); } return folderSet; } // [END apps_script_api_execute] ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "formatter": { "enabled": true, "indentWidth": 2, "indentStyle": "space", "lineWidth": 80 }, "linter": { "enabled": true, "rules": { "recommended": true } }, "files": { "ignore": ["moment.gs", "**/dist", "**/target", "**/pkg", "**/node_modules"] } } ================================================ FILE: calendar/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START calendar_quickstart] /** * Lists 10 upcoming events in the user's calendar. * @see https://developers.google.com/calendar/api/v3/reference/events/list */ function listUpcomingEvents() { const calendarId = "primary"; // Add query parameters in optionalArgs const optionalArgs = { timeMin: new Date().toISOString(), showDeleted: false, singleEvents: true, maxResults: 10, orderBy: "startTime", // use other optional query parameter here as needed. }; try { // call Events.list method to list the calendar events using calendarId optional query parameter const response = Calendar.Events.list(calendarId, optionalArgs); const events = response.items; if (events.length === 0) { console.log("No upcoming events found"); return; } // Print the calendar events for (const event of events) { let when = event.start.dateTime; if (!when) { when = event.start.date; } console.log("%s (%s)", event.summary, when); } } catch (err) { // TODO (developer) - Handle exception from Calendar API console.log("Failed with error %s", err.message); } } // [END calendar_quickstart] ================================================ FILE: chat/advanced-service/AppAuthenticationUtils.gs ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START chat_authentication_utils] // This script provides configuration and helper functions for app authentication. // It may require modifications to work in your environment. // For more information on app authentication, see // https://developers.google.com/workspace/chat/authenticate-authorize-chat-app const APP_AUTH_OAUTH_SCOPES = ["https://www.googleapis.com/auth/chat.bot"]; // Warning: This example uses a service account private key, it should always be stored in a // secure location. const SERVICE_ACCOUNT = { // TODO(developer): Replace with the Google Chat credentials to use for app authentication, // the service account private key's JSON. }; /** * Authenticates the app service by using the OAuth2 library. * * @return {Object} the authenticated app service */ function getService_() { return OAuth2.createService(SERVICE_ACCOUNT.client_email) .setTokenUrl(SERVICE_ACCOUNT.token_uri) .setPrivateKey(SERVICE_ACCOUNT.private_key) .setIssuer(SERVICE_ACCOUNT.client_email) .setSubject(SERVICE_ACCOUNT.client_email) .setScope(APP_AUTH_OAUTH_SCOPES) .setCache(CacheService.getUserCache()) .setLock(LockService.getUserLock()) .setPropertyStore(PropertiesService.getScriptProperties()); } /** * Generates headers with the app credentials to use to make Google Chat API calls. * * @return {Object} the header with credentials */ function getHeaderWithAppCredentials() { return { Authorization: `Bearer ${getService_().getAccessToken()}`, }; } // [END chat_authentication_utils] ================================================ FILE: chat/advanced-service/Main.gs ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // This script provides each code sample in a separate function. // It may require modifications to work in your environment. // For more information on user authentication, see // https://developers.google.com/workspace/chat/authenticate-authorize-chat-user // For more information on app authentication, see // https://developers.google.com/workspace/chat/authenticate-authorize-chat-app // [START chat_create_membership_user_cred] /** * This sample shows how to create membership with user credential for a human user * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships' * referenced in the manifest file (appsscript.json). */ function createMembershipUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; const membership = { member: { // TODO(developer): Replace USER_NAME here name: "users/USER_NAME", // User type for the membership type: "HUMAN", }, }; // Make the request const response = Chat.Spaces.Members.create(membership, parent); // Handle the response console.log(response); } // [END chat_create_membership_user_cred] // [START chat_create_membership_user_cred_for_app] /** * This sample shows how to create membership with app credential for an app * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships.app' * referenced in the manifest file (appsscript.json). */ function createMembershipUserCredForApp() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; const membership = { member: { // Member name for app membership, do not change this name: "users/app", // User type for the membership type: "BOT", }, }; // Make the request const response = Chat.Spaces.Members.create(membership, parent); // Handle the response console.log(response); } // [END chat_create_membership_user_cred_for_app] // [START chat_create_membership_user_cred_for_group] /** * This sample shows how to create membership with user credential for a group * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships' * referenced in the manifest file (appsscript.json). */ function createMembershipUserCredForGroup() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; const membership = { groupMember: { // TODO(developer): Replace GROUP_NAME here name: "groups/GROUP_NAME", }, }; // Make the request const response = Chat.Spaces.Members.create(membership, parent); // Handle the response console.log(response); } // [END chat_create_membership_user_cred_for_group] // [START chat_create_message_app_cred] /** * This sample shows how to create message with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function createMessageAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; const message = { text: "👋🌎 Hello world! I created this message by calling " + "the Chat API's `messages.create()` method.", cardsV2: [ { card: { header: { title: "About this message", imageUrl: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/info/default/24px.svg", }, sections: [ { header: "Contents", widgets: [ { textParagraph: { text: "🔡 Text which can include " + "hyperlinks 🔗, emojis 😄🎉, and @mentions 🗣️.", }, }, { textParagraph: { text: "🖼️ A card to display visual elements" + "and request information such as text 🔤, " + "dates and times 📅, and selections ☑️.", }, }, { textParagraph: { text: "👉🔘 An accessory widget which adds " + "a button to the bottom of a message.", }, }, ], }, { header: "What's next", collapsible: true, widgets: [ { textParagraph: { text: "❤️ Add a reaction.", }, }, { textParagraph: { text: "🔄 Update " + "or ❌ delete " + "the message.", }, }, ], }, ], }, }, ], accessoryWidgets: [ { buttonList: { buttons: [ { text: "View documentation", icon: { materialIcon: { name: "link" } }, onClick: { openLink: { url: "https://developers.google.com/workspace/chat/create-messages", }, }, }, ], }, }, ], }; const parameters = {}; // Make the request const response = Chat.Spaces.Messages.create( message, parent, parameters, getHeaderWithAppCredentials(), ); // Handle the response console.log(response); } // [END chat_create_message_app_cred] // [START chat_create_message_user_cred] /** * This sample shows how to create message with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' * referenced in the manifest file (appsscript.json). */ function createMessageUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; const message = { text: "👋🌎 Hello world!" + "Text messages can contain things like:\n\n" + "* Hyperlinks 🔗\n" + "* Emojis 😄🎉\n" + "* Mentions of other Chat users `@` \n\n" + "For details, see the " + ".", }; // Make the request const response = Chat.Spaces.Messages.create(message, parent); // Handle the response console.log(response); } // [END chat_create_message_user_cred] // [START chat_create_message_user_cred_at_mention] /** * This sample shows how to create message with user credential with a user mention * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' * referenced in the manifest file (appsscript.json). */ function createMessageUserCredAtMention() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; const message = { // The user with USER_NAME will be mentioned if they are in the space // TODO(developer): Replace USER_NAME here text: "Hello !", }; // Make the request const response = Chat.Spaces.Messages.create(message, parent); // Handle the response console.log(response); } // [END chat_create_message_user_cred_at_mention] // [START chat_create_message_user_cred_message_id] /** * This sample shows how to create message with user credential with message id * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' * referenced in the manifest file (appsscript.json). */ function createMessageUserCredMessageId() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; // Message id lets chat apps get, update or delete a message without needing // to store the system assigned ID in the message's resource name const messageId = "client-MESSAGE-ID"; const message = { text: "Hello with user credential!" }; // Make the request const response = Chat.Spaces.Messages.create(message, parent, { messageId: messageId, }); // Handle the response console.log(response); } // [END chat_create_message_user_cred_message_id] // [START chat_create_message_user_cred_request_id] /** * This sample shows how to create message with user credential with request id * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' * referenced in the manifest file (appsscript.json). */ function createMessageUserCredRequestId() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; // Specifying an existing request ID returns the message created with // that ID instead of creating a new message const requestId = "REQUEST_ID"; const message = { text: "Hello with user credential!" }; // Make the request const response = Chat.Spaces.Messages.create(message, parent, { requestId: requestId, }); // Handle the response console.log(response); } // [END chat_create_message_user_cred_request_id] // [START chat_create_message_user_cred_thread_key] /** * This sample shows how to create message with user credential with thread key * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' * referenced in the manifest file (appsscript.json). */ function createMessageUserCredThreadKey() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; // Creates the message as a reply to the thread specified by thread_key // If it fails, the message starts a new thread instead const messageReplyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"; const message = { text: "Hello with user credential!", thread: { // Thread key specifies a thread and is unique to the chat app // that sets it threadKey: "THREAD_KEY", }, }; // Make the request const response = Chat.Spaces.Messages.create(message, parent, { messageReplyOption: messageReplyOption, }); // Handle the response console.log(response); } // [END chat_create_message_user_cred_thread_key] // [START chat_create_message_user_cred_thread_name] /** * This sample shows how to create message with user credential with thread name * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.create' * referenced in the manifest file (appsscript.json). */ function createMessageUserCredThreadName() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here. const parent = "spaces/SPACE_NAME"; // Creates the message as a reply to the thread specified by thread.name // If it fails, the message starts a new thread instead const messageReplyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"; const message = { text: "Hello with user credential!", thread: { // Resource name of a thread that uniquely identify a thread // TODO(developer): Replace SPACE_NAME and THREAD_NAME here name: "spaces/SPACE_NAME/threads/THREAD_NAME", }, }; // Make the request const response = Chat.Spaces.Messages.create(message, parent, { messageReplyOption: messageReplyOption, }); // Handle the response console.log(response); } // [END chat_create_message_user_cred_thread_name] // [START chat_create_space_user_cred] /** * This sample shows how to create space with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.create' * referenced in the manifest file (appsscript.json). */ function createSpaceUserCred() { // Initialize request argument(s) const space = { spaceType: "SPACE", // TODO(developer): Replace DISPLAY_NAME here displayName: "DISPLAY_NAME", }; // Make the request const response = Chat.Spaces.create(space); // Handle the response console.log(response); } // [END chat_create_space_user_cred] // [START chat_delete_message_app_cred] /** * This sample shows how to delete a message with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function deleteMessageAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here const name = "spaces/SPACE_NAME/messages/MESSAGE_NAME"; const parameters = {}; // Make the request const response = Chat.Spaces.Messages.remove( name, parameters, getHeaderWithAppCredentials(), ); // Handle the response console.log(response); } // [END chat_delete_message_app_cred] // [START chat_delete_message_user_cred] /** * This sample shows how to delete a message with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages' * referenced in the manifest file (appsscript.json). */ function deleteMessageUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here const name = "spaces/SPACE_NAME/messages/MESSAGE_NAME"; // Make the request const response = Chat.Spaces.Messages.remove(name); // Handle the response console.log(response); } // [END chat_delete_message_user_cred] // [START chat_get_membership_app_cred] /** * This sample shows how to get membership with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function getMembershipAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MEMBER_NAME here const name = "spaces/SPACE_NAME/members/MEMBER_NAME"; const parameters = {}; // Make the request const response = Chat.Spaces.Members.get( name, parameters, getHeaderWithAppCredentials(), ); // Handle the response console.log(response); } // [END chat_get_membership_app_cred] // [START chat_get_membership_user_cred] /** * This sample shows how to get membership with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships.readonly' * referenced in the manifest file (appsscript.json). */ function getMembershipUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MEMBER_NAME here const name = "spaces/SPACE_NAME/members/MEMBER_NAME"; // Make the request const response = Chat.Spaces.Members.get(name); // Handle the response console.log(response); } // [END chat_get_membership_user_cred] // [START chat_get_message_app_cred] /** * This sample shows how to get message with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function getMessageAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here const name = "spaces/SPACE_NAME/messages/MESSAGE_NAME"; const parameters = {}; // Make the request const response = Chat.Spaces.Messages.get( name, parameters, getHeaderWithAppCredentials(), ); // Handle the response console.log(response); } // [END chat_get_message_app_cred] // [START chat_get_message_user_cred] /** * This sample shows how to get message with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.readonly' * referenced in the manifest file (appsscript.json). */ function getMessageUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here const name = "spaces/SPACE_NAME/messages/MESSAGE_NAME"; // Make the request const response = Chat.Spaces.Messages.get(name); // Handle the response console.log(response); } // [END chat_get_message_user_cred] // [START chat_get_space_app_cred] /** * This sample shows how to get space with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function getSpaceAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here const name = "spaces/SPACE_NAME"; const parameters = {}; // Make the request const response = Chat.Spaces.get( name, parameters, getHeaderWithAppCredentials(), ); // Handle the response console.log(response); } // [END chat_get_space_app_cred] // [START chat_get_space_user_cred] /** * This sample shows how to get space with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.readonly' * referenced in the manifest file (appsscript.json). */ function getSpaceUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here const name = "spaces/SPACE_NAME"; // Make the request const response = Chat.Spaces.get(name); // Handle the response console.log(response); } // [END chat_get_space_user_cred] // [START chat_list_memberships_app_cred] /** * This sample shows how to list memberships with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function listMembershipsAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here const parent = "spaces/SPACE_NAME"; // Filter membership by type (HUMAN or BOT) or role (ROLE_MEMBER or // ROLE_MANAGER) const filter = 'member.type = "HUMAN"'; // Iterate through the response pages using page tokens let responsePage; let pageToken = null; do { // Request response pages responsePage = Chat.Spaces.Members.list( parent, { filter: filter, pageSize: 10, pageToken: pageToken, }, getHeaderWithAppCredentials(), ); // Handle response pages if (responsePage.memberships) { for (const membership of responsePage.memberships) { console.log(membership); } } // Update the page token to the next one pageToken = responsePage.nextPageToken; } while (pageToken); } // [END chat_list_memberships_app_cred] // [START chat_list_memberships_user_cred] /** * This sample shows how to list memberships with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.memberships.readonly' * referenced in the manifest file (appsscript.json). */ function listMembershipsUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here const parent = "spaces/SPACE_NAME"; // Filter membership by type (HUMAN or BOT) or role (ROLE_MEMBER or // ROLE_MANAGER) const filter = 'member.type = "HUMAN"'; // Iterate through the response pages using page tokens let responsePage; let pageToken = null; do { // Request response pages responsePage = Chat.Spaces.Members.list(parent, { filter: filter, pageSize: 10, pageToken: pageToken, }); // Handle response pages if (responsePage.memberships) { for (const membership of responsePage.memberships) { console.log(membership); } } // Update the page token to the next one pageToken = responsePage.nextPageToken; } while (pageToken); } // [END chat_list_memberships_user_cred] // [START chat_list_messages_user_cred] /** * This sample shows how to list messages with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages.readonly' * referenced in the manifest file (appsscript.json). */ function listMessagesUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here const parent = "spaces/SPACE_NAME"; // Iterate through the response pages using page tokens let responsePage; let pageToken = null; do { // Request response pages responsePage = Chat.Spaces.Messages.list(parent, { pageSize: 10, pageToken: pageToken, }); // Handle response pages if (responsePage.messages) { for (const message of responsePage.messages) { console.log(message); } } // Update the page token to the next one pageToken = responsePage.nextPageToken; } while (pageToken); } // [END chat_list_messages_user_cred] // [START chat_list_spaces_app_cred] /** * This sample shows how to list spaces with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function listSpacesAppCred() { // Initialize request argument(s) // Filter spaces by space type (SPACE or GROUP_CHAT or DIRECT_MESSAGE) const filter = 'space_type = "SPACE"'; // Iterate through the response pages using page tokens let responsePage; let pageToken = null; do { // Request response pages responsePage = Chat.Spaces.list( { filter: filter, pageSize: 10, pageToken: pageToken, }, getHeaderWithAppCredentials(), ); // Handle response pages if (responsePage.spaces) { for (const space of responsePage.spaces) { console.log(space); } } // Update the page token to the next one pageToken = responsePage.nextPageToken; } while (pageToken); } // [END chat_list_spaces_app_cred] // [START chat_list_spaces_user_cred] /** * This sample shows how to list spaces with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.readonly' * referenced in the manifest file (appsscript.json). */ function listSpacesUserCred() { // Initialize request argument(s) // Filter spaces by space type (SPACE or GROUP_CHAT or DIRECT_MESSAGE) const filter = 'space_type = "SPACE"'; // Iterate through the response pages using page tokens let responsePage; let pageToken = null; do { // Request response pages responsePage = Chat.Spaces.list({ filter: filter, pageSize: 10, pageToken: pageToken, }); // Handle response pages if (responsePage.spaces) { for (const space of responsePage.spaces) { console.log(space); } } // Update the page token to the next one pageToken = responsePage.nextPageToken; } while (pageToken); } // [END chat_list_spaces_user_cred] // [START chat_set_up_space_user_cred] /** * This sample shows how to set up a named space with one initial member with * user credential. * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.create' * referenced in the manifest file (appsscript.json). */ function setUpSpaceUserCred() { // Initialize request argument(s) const space = { spaceType: "SPACE", // TODO(developer): Replace DISPLAY_NAME here displayName: "DISPLAY_NAME", }; const memberships = [ { member: { // TODO(developer): Replace USER_NAME here name: "users/USER_NAME", // User type for the membership type: "HUMAN", }, }, ]; // Make the request const response = Chat.Spaces.setup({ space: space, memberships: memberships, }); // Handle the response console.log(response); } // [END chat_set_up_space_user_cred] // [START chat_update_message_app_cred] /** * This sample shows how to update a message with app credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.bot' * used by service accounts. */ function updateMessageAppCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here const name = "spaces/SPACE_NAME/messages/MESSAGE_NAME"; const message = { text: "Text updated with app credential!", cardsV2: [ { card: { header: { title: "Card updated with app credential!", imageUrl: "https://fonts.gstatic.com/s/i/short-term/release/googlesymbols/info/default/24px.svg", }, }, }, ], }; // The field paths to update. Separate multiple values with commas or use // `*` to update all field paths. const updateMask = "text,cardsV2"; // Make the request const response = Chat.Spaces.Messages.patch( message, name, { updateMask: updateMask, }, getHeaderWithAppCredentials(), ); // Handle the response console.log(response); } // [END chat_update_message_app_cred] // [START chat_update_message_user_cred] /** * This sample shows how to update a message with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.messages' * referenced in the manifest file (appsscript.json). */ function updateMessageUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME and MESSAGE_NAME here const name = "spaces/SPACE_NAME/messages/MESSAGE_NAME"; const message = { text: "Updated with user credential!", }; // The field paths to update. Separate multiple values with commas or use // `*` to update all field paths. const updateMask = "text"; // Make the request const response = Chat.Spaces.Messages.patch(message, name, { updateMask: updateMask, }); // Handle the response console.log(response); } // [END chat_update_message_user_cred] // [START chat_update_space_user_cred] /** * This sample shows how to update a space with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces' * referenced in the manifest file (appsscript.json). */ function updateSpaceUserCred() { // Initialize request argument(s) // TODO(developer): Replace SPACE_NAME here const name = "spaces/SPACE_NAME"; const space = { displayName: "New space display name", }; // The field paths to update. Separate multiple values with commas or use // `*` to update all field paths. const updateMask = "displayName"; // Make the request const response = Chat.Spaces.patch(space, name, { updateMask: updateMask, }); // Handle the response console.log(response); } // [END chat_update_space_user_cred] ================================================ FILE: chat/advanced-service/README.md ================================================ # Google Chat API - Advanced Service samples ## Set up 1. Follow the Google Chat app quickstart for Apps Script https://developers.google.com/workspace/chat/quickstart/apps-script-app and open the resulting Apps Script project in a web browser. 1. Override the Apps Script project contents with the files `appsscript.json`, `AppAuthenticationUtils.gs`, and `Main.gs` from this code sample directory. 1. To run samples that use app credentials: 1. Create a service account. For steps, see [Authenticate as a Google Chat app](https://developers.google.com/workspace/chat/authenticate-authorize-chat-app). 1. Open `AppAuthenticationUtils.gs` and set the value of the constant `SERVICE_ACCOUNT` to the private key's JSON of the service account that you created in the previous step. ## Run In the `Main.gs` file, each function contains a sample that calls a Chat API method using either app or user authentication. To run one of the samples, select the name of the function from the dropdown menu and click `Run`. ================================================ FILE: chat/advanced-service/appsscript.json ================================================ { "timeZone": "America/New_York", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/chat.spaces", "https://www.googleapis.com/auth/chat.spaces.create", "https://www.googleapis.com/auth/chat.spaces.readonly", "https://www.googleapis.com/auth/chat.memberships", "https://www.googleapis.com/auth/chat.memberships.app", "https://www.googleapis.com/auth/chat.memberships.readonly", "https://www.googleapis.com/auth/chat.messages", "https://www.googleapis.com/auth/chat.messages.create", "https://www.googleapis.com/auth/chat.messages.readonly" ], "chat": {}, "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Chat", "version": "v1", "serviceId": "chat" } ], "libraries": [ { "userSymbol": "OAuth2", "version": "43", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF" } ] } } ================================================ FILE: chat/quickstart/Code.gs ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START chat_quickstart] /** * This quickstart sample shows how to list spaces with user credential * * It relies on the OAuth2 scope 'https://www.googleapis.com/auth/chat.spaces.readonly' * referenced in the manifest file (appsscript.json). */ function listSpaces() { // Initialize request argument(s) // Filter spaces by space type (SPACE or GROUP_CHAT or DIRECT_MESSAGE) const filter = 'space_type = "SPACE"'; // Iterate through the response pages using page tokens let responsePage; let pageToken = null; do { // Request response pages responsePage = Chat.Spaces.list({ filter: filter, pageToken: pageToken, }); // Handle response pages if (responsePage.spaces) { for (const space of responsePage.spaces) { console.log(space); } } // Update the page token to the next one pageToken = responsePage.nextPageToken; } while (pageToken); } // [END chat_quickstart] ================================================ FILE: chat/quickstart/README.md ================================================ # Google Chat Apps Script Quickstart Complete the steps described in the [quickstart instructions]( https://developers.google.com/workspace/chat/api/guides/quickstart/apps-script), and in about five minutes you'll have a simple Apps Script application that makes requests to the Google Chat API. ## Run After following the quickstart setup instructions, execute the function `listSpaces` from the Apps Script console. ================================================ FILE: chat/quickstart/appsscript.json ================================================ { "timeZone": "America/New_York", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": ["https://www.googleapis.com/auth/chat.spaces.readonly"], "chat": {}, "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Chat", "version": "v1", "serviceId": "chat" } ] } } ================================================ FILE: classroom/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_quickstart] /** * Lists 10 course names and ids. */ function listCourses() { /** here pass pageSize Query parameter as argument to get maximum number of result * @see https://developers.google.com/classroom/reference/rest/v1/courses/list */ const optionalArgs = { pageSize: 10, // Use other parameter here if needed }; try { // call courses.list() method to list the courses in classroom const response = Classroom.Courses.list(optionalArgs); const courses = response.courses; if (!courses || courses.length === 0) { console.log("No courses found."); return; } // Print the course names and IDs of the courses for (const course of courses) { console.log("%s (%s)", course.name, course.id); } } catch (err) { // TODO (developer)- Handle Courses.list() exception from Classroom API // get errors like PERMISSION_DENIED/INVALID_ARGUMENT/NOT_FOUND console.log("Failed with error %s", err.message); } } // [END classroom_quickstart] ================================================ FILE: classroom/snippets/addAlias.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_add_alias] /** * Updates the section and room of Google Classroom. * @param {string} course_id * @see https://developers.google.com/classroom/reference/rest/v1/courses.aliases/create */ function addAlias(course_id) { const alias = { alias: "p:bio_101", }; try { const course_alias = Classroom.Courses.Aliases.create(alias, course_id); console.log("%s successfully added as an alias!", course_alias.alias); } catch (err) { // TODO (developer) - Handle exception console.log( "Request to add alias %s failed with error %s.", alias.alias, err.message, ); } } // [END classroom_add_alias] ================================================ FILE: classroom/snippets/courseUpdate.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_update_course] /** * Updates the section and room of Google Classroom. * @param {string} courseId * @see https://developers.google.com/classroom/reference/rest/v1/courses/update */ function courseUpdate(courseId) { try { // Get the course using course ID let course = Classroom.Courses.get(courseId); course.section = "Period 3"; course.room = "302"; // Update the course course = Classroom.Courses.update(course, courseId); console.log('Course "%s" updated.', course.name); } catch (e) { // TODO (developer) - Handle exception console.log("Failed to update the course with error %s", e.message); } } // [END classroom_update_course] ================================================ FILE: classroom/snippets/createAlias.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_create_alias] /** * Creates Course with an alias specified */ function createAlias() { let course = { id: "p:bio_101", name: "10th Grade Biology", section: "Period 2", descriptionHeading: "Welcome to 10th Grade Biology", description: "We'll be learning about the structure of living creatures from a combination " + "of textbooks, guest lectures, and lab work. Expect to be excited!", room: "301", ownerId: "me", courseState: "PROVISIONED", }; try { // Create the course using course details. course = Classroom.Courses.create(course); console.log("Course created: %s (%s)", course.name, course.id); } catch (err) { // TODO (developer) - Handle Courses.create() exception console.log( "Failed to create course %s with an error %s", course.name, err.message, ); } } // [END classroom_create_alias] ================================================ FILE: classroom/snippets/createCourse.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_create_course] /** * Creates 10th Grade Biology Course. * @see https://developers.google.com/classroom/reference/rest/v1/courses/create * return {string} Id of created course */ function createCourse() { let course = { name: "10th Grade Biology", section: "Period 2", descriptionHeading: "Welcome to 10th Grade Biology", description: "We'll be learning about the structure of living creatures from a combination " + "of textbooks, guest lectures, and lab work. Expect to be excited!", room: "301", ownerId: "me", courseState: "PROVISIONED", }; try { // Create the course using course details. course = Classroom.Courses.create(course); console.log("Course created: %s (%s)", course.name, course.id); return course.id; } catch (err) { // TODO (developer) - Handle Courses.create() exception console.log( "Failed to create course %s with an error %s", course.name, err.message, ); } } // [END classroom_create_course] ================================================ FILE: classroom/snippets/getCourse.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_get_course] /** * Retrieves course by id. * @param {string} courseId * @see https://developers.google.com/classroom/reference/rest/v1/courses/get */ function getCourse(courseId) { try { // Get the course details using course id const course = Classroom.Courses.get(courseId); console.log('Course "%s" found. ', course.name); } catch (err) { // TODO (developer) - Handle Courses.get() exception of Handle Classroom API console.log( "Failed to found course %s with error %s ", courseId, err.message, ); } } // [END classroom_get_course] ================================================ FILE: classroom/snippets/listCourses.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_list_courses] /** * Lists all course names and ids. * @see https://developers.google.com/classroom/reference/rest/v1/courses/list */ function listCourses() { let courses = []; const pageToken = null; const optionalArgs = { pageToken: pageToken, pageSize: 100, }; try { const response = Classroom.Courses.list(optionalArgs); courses = response.courses; if (courses.length === 0) { console.log("No courses found."); return; } // Print the courses available in classroom console.log("Courses:"); for (const course in courses) { console.log("%s (%s)", courses[course].name, courses[course].id); } } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } // [END classroom_list_courses] ================================================ FILE: classroom/snippets/patchCourse.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START classroom_patch_course] /** * Updates the section and room of Google Classroom. * @param {string} courseId * @see https://developers.google.com/classroom/reference/rest/v1/courses/patch */ function coursePatch(courseId) { const course = { section: "Period 3", room: "302", }; const options = { updateMask: "section,room", }; // Update section and room in course. const updatedCourse = Classroom.Courses.patch(course, courseId, options); console.log(`Course "${updatedCourse.name}" updated.`); } // [END classroom_patch_course] ================================================ FILE: classroom/snippets/test_classroom_snippets.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests createCourse function of createCourse.gs * @return {string} courseId course id of created course */ function itShouldCreateCourse() { console.log("> itShouldCreateCourse"); const courseId = createCourse(); return courseId; } /** * Tests getCourse function of getCourse.gs * @param {string} courseId course id */ function itShouldGetCourse(courseId) { console.log("> itShouldGetCourse"); getCourse(courseId); } /** * Tests createAlias function of createAlias.gs */ function itShouldCreateAlias() { console.log("> itShouldCreateAlias"); createAlias(); } /** * Tests addAlias function of addAlias.gs * @param {string} courseId course id */ function itShouldAddAlias(courseId) { console.log("> itShouldAddAlias"); addAlias(courseId); } /** * Tests courseUpdate function of courseUpdate.gs * @param {string} courseId course id */ function itShouldUpdateCourse(courseId) { console.log("> itShouldUpdateCourse"); courseUpdate(courseId); } /** * Tests coursePatch function of patchCourse.gs * @param {string} courseId course id */ function itShouldPatchCourse(courseId) { console.log("> itShouldPatchCourse"); coursePatch(courseId); } /** * Tests listCourses function of listCourses.gs */ function itShouldListCourses() { console.log("> itShouldListCourses"); listCourses(); } /** * Runs all the tests */ function RUN_ALL_TESTS() { const courseId = itShouldCreateCourse(); itShouldGetCourse(courseId); itShouldCreateAlias(); itShouldAddAlias(courseId); itShouldUpdateCourse(courseId); itShouldPatchCourse(courseId); itShouldListCourses(); } ================================================ FILE: data-studio/appsscript.json ================================================ { "dataStudio": { "name": "Nucleus by Hooli", "company": "Hooli Inc.", "companyUrl": "https://hooli.xyz", "logoUrl": "https://hooli.xyz/middle-out-optimized/nucleus/logo.png", "addonUrl": "https://hooli.xyz/data-studio-connector", "supportUrl": "https://hooli.xyz/data-studio-connector/support", "description": "Nucleus by Hooli connector lets you connect to your data in Data Studio using Nucleus middle out optimization. You will need an account on hooli.xyz to use this connector. Create your account at https://hooli.xyz/signup", "shortDescription": "Connect to your data using Nucleus middle out optimization", "privacyPolicyUrl": "https://hooli.xyz/privacy", "termsOfServiceUrl": "https://hooli.xyz/tos", "authType": ["NONE"], "feeType": ["PAID"], "sources": [ "HOOLI_CHAT_LOG", "ENDFRAME_SERVER_STREAM", "RETINABYTE_USER_ANALYTICS" ], "templates": { "default": "872223s89f5fdkjnd983kjf" } }, "urlFetchWhitelist": ["https://api.hooli.xyz/", "https://hooli.xyz/"] } ================================================ FILE: data-studio/appsscript2.json ================================================ { "dataStudio": { "name": "npm Downloads - Build Guide", "logoUrl": "https://raw.githubusercontent.com/npm/logos/master/%22npm%22%20lockup/npm-logo-simplifed-with-white-space.png", "company": "Build Guide User", "companyUrl": "https://developers.google.com/datastudio/", "addonUrl": "https://github.com/google/datastudio/tree/master/community-connectors/npm-downloads", "supportUrl": "https://github.com/google/datastudio/issues", "description": "Get npm package download counts.", "sources": ["npm"] } } ================================================ FILE: data-studio/auth.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_get_auth_type_oauth2] /** * Returns the Auth Type of this connector. * @return {object} The Auth type. */ function getAuthTypeOAuth2() { const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse().setAuthType(cc.AuthType.OAUTH2).build(); } // [END apps_script_data_studio_get_auth_type_oauth2] // [START apps_script_data_studio_get_auth_type_path_user_pass] /** * Returns the Auth Type of this connector. * @return {object} The Auth type. */ function getAuthTypePathUserPass() { const cc = DataStudioApp.createCommunityConnector(); return cc .newAuthTypeResponse() .setAuthType(cc.AuthType.PATH_USER_PASS) .setHelpUrl("https://www.example.org/connector-auth-help") .build(); } // [END apps_script_data_studio_get_auth_type_path_user_pass] // [START apps_script_data_studio_get_auth_type_user_pass] /** * Returns the Auth Type of this connector. * @return {object} The Auth type. */ function getAuthTypeUserPass() { const cc = DataStudioApp.createCommunityConnector(); return cc .newAuthTypeResponse() .setAuthType(cc.AuthType.USER_PASS) .setHelpUrl("https://www.example.org/connector-auth-help") .build(); } // [END apps_script_data_studio_get_auth_type_user_pass] // [START apps_script_data_studio_get_auth_type_user_token] /** * Returns the Auth Type of this connector. * @return {object} The Auth type. */ function getAuthTypeUserToken() { const cc = DataStudioApp.createCommunityConnector(); return cc .newAuthTypeResponse() .setAuthType(cc.AuthType.USER_TOKEN) .setHelpUrl("https://www.example.org/connector-auth-help") .build(); } // [END apps_script_data_studio_get_auth_type_user_token] // [START apps_script_data_studio_get_auth_type_key] /** * Returns the Auth Type of this connector. * @return {object} The Auth type. */ function getAuthTypeKey() { const cc = DataStudioApp.createCommunityConnector(); return cc .newAuthTypeResponse() .setAuthType(cc.AuthType.KEY) .setHelpUrl("https://www.example.org/connector-auth-help") .build(); } // [END apps_script_data_studio_get_auth_type_key] // [START apps_script_data_studio_get_auth_type_none] /** * Returns the Auth Type of this connector. * @return {object} The Auth type. */ function getAuthTypeNone() { const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse().setAuthType(cc.AuthType.NONE).build(); } // [END apps_script_data_studio_get_auth_type_none] // [START apps_script_data_studio_auth_reset_oauth2] /** * Resets the auth service. */ function resetAuthOAuth2() { getOAuthService().reset(); } // [END apps_script_data_studio_auth_reset_oauth2] // [START apps_script_data_studio_auth_reset_path_user] /** * Resets the auth service. */ function resetAuthPathUser() { const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty("dscc.path"); userProperties.deleteProperty("dscc.username"); userProperties.deleteProperty("dscc.password"); } // [END apps_script_data_studio_auth_reset_path_user] // [START apps_script_data_studio_auth_reset_user] /** * Resets the auth service. */ function resetAuthUser() { const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty("dscc.username"); userProperties.deleteProperty("dscc.password"); } // [END apps_script_data_studio_auth_reset_user] // [START apps_script_data_studio_auth_reset_user_token] /** * Resets the auth service. */ function resetAuthUserToken() { const userTokenProperties = PropertiesService.getUserProperties(); userTokenProperties.deleteProperty("dscc.username"); userTokenProperties.deleteProperty("dscc.password"); } // [END apps_script_data_studio_auth_reset_user_token] // [START apps_script_data_studio_auth_reset_key] /** * Resets the auth service. */ function resetAuthKey() { const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty("dscc.key"); } // [END apps_script_data_studio_auth_reset_key] // [START apps_script_data_studio_auth_valid_oauth2] /** * Returns true if the auth service has access. * @return {boolean} True if the auth service has access. */ function isAuthValidOAuth2() { return getOAuthService().hasAccess(); } // [END apps_script_data_studio_auth_valid_oauth2] // [START apps_script_data_studio_auth_valid_path_user_pass] /** * Returns true if the auth service has access. * @return {boolean} True if the auth service has access. */ function isAuthValidPathUserPass() { const userProperties = PropertiesService.getUserProperties(); const path = userProperties.getProperty("dscc.path"); const userName = userProperties.getProperty("dscc.username"); const password = userProperties.getProperty("dscc.password"); // This assumes you have a validateCredentials function that // can validate if the userName and password are correct. return validateCredentials(path, userName, password); } // [END apps_script_data_studio_auth_valid_path_user_pass] // [START apps_script_data_studio_auth_valid_user_pass] /** * Returns true if the auth service has access. * @return {boolean} True if the auth service has access. */ function isAuthValidUserPass() { const userProperties = PropertiesService.getUserProperties(); const userName = userProperties.getProperty("dscc.username"); const password = userProperties.getProperty("dscc.password"); // This assumes you have a validateCredentials function that // can validate if the userName and password are correct. return validateCredentials(userName, password); } // [END apps_script_data_studio_auth_valid_user_pass] // [START apps_script_data_studio_auth_valid_user_token] /** * Returns true if the auth service has access. * @return {boolean} True if the auth service has access. */ function isAuthValidUserToken() { const userProperties = PropertiesService.getUserProperties(); const userName = userProperties.getProperty("dscc.username"); const token = userProperties.getProperty("dscc.token"); // This assumes you have a validateCredentials function that // can validate if the userName and token are correct. return validateCredentials(userName, token); } // [END apps_script_data_studio_auth_valid_user_token] // [START apps_script_data_studio_auth_valid_key] /** * Returns true if the auth service has access. * @return {boolean} True if the auth service has access. */ function isAuthValidKey() { const userProperties = PropertiesService.getUserProperties(); const key = userProperties.getProperty("dscc.key"); // This assumes you have a validateKey function that can validate // if the key is valid. return validateKey(key); } // [END apps_script_data_studio_auth_valid_key] // [START apps_script_data_studio_auth_library] /** * Returns the configured OAuth Service. * @return {Service} The OAuth Service */ function getOAuthService() { return OAuth2.createService("exampleService") .setAuthorizationBaseUrl("...") .setTokenUrl("...") .setClientId("...") .setClientSecret("...") .setPropertyStore(PropertiesService.getUserProperties()) .setCallbackFunction("authCallback") .setScope("..."); } // [END apps_script_data_studio_auth_library] // [START apps_script_data_studio_auth_callback] /** * The OAuth callback. * @param {object} request The request data received from the OAuth flow. * @return {HtmlOutput} The HTML output to show to the user. */ function authCallback(request) { const authorized = getOAuthService().handleCallback(request); if (authorized) { return HtmlService.createHtmlOutput("Success! You can close this tab."); } return HtmlService.createHtmlOutput("Denied. You can close this tab"); } // [END apps_script_data_studio_auth_callback] // [START apps_script_data_studio_auth_urls] /** * Gets the 3P authorization URL. * @return {string} The authorization URL. * @see https://developers.google.com/apps-script/reference/script/authorization-info */ function get3PAuthorizationUrls() { return getOAuthService().getAuthorizationUrl(); } // [END apps_script_data_studio_auth_urls] // [START apps_script_data_studio_auth_set_credentials_path_user_pass] /** * Sets the credentials. * @param {Request} request The set credentials request. * @return {object} An object with an errorCode. */ function setCredentialsPathUserPass(request) { const creds = request.userPass; const path = creds.path; const username = creds.username; const password = creds.password; // Optional // Check if the provided path, username and password are valid through // a call to your service. You would have to have a `checkForValidCreds` // function defined for this to work. const validCreds = checkForValidCreds(path, username, password); if (!validCreds) { return { errorCode: "INVALID_CREDENTIALS", }; } const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty("dscc.path", path); userProperties.setProperty("dscc.username", username); userProperties.setProperty("dscc.password", password); return { errorCode: "NONE", }; } // [END apps_script_data_studio_auth_set_credentials_path_user_pass] // [START apps_script_data_studio_auth_set_credentials_user_pass] /** * Sets the credentials. * @param {Request} request The set credentials request. * @return {object} An object with an errorCode. */ function setCredentialsUserPass(request) { const creds = request.userPass; const username = creds.username; const password = creds.password; // Optional // Check if the provided username and password are valid through a // call to your service. You would have to have a `checkForValidCreds` // function defined for this to work. const validCreds = checkForValidCreds(username, password); if (!validCreds) { return { errorCode: "INVALID_CREDENTIALS", }; } const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty("dscc.username", username); userProperties.setProperty("dscc.password", password); return { errorCode: "NONE", }; } // [END apps_script_data_studio_auth_set_credentials_user_pass] // [START apps_script_data_studio_auth_set_credentials_user_token] /** * Sets the credentials. * @param {Request} request The set credentials request. * @return {object} An object with an errorCode. */ function setCredentialsUserToken(request) { const creds = request.userToken; const username = creds.username; const token = creds.token; // Optional // Check if the provided username and token are valid through a // call to your service. You would have to have a `checkForValidCreds` // function defined for this to work. const validCreds = checkForValidCreds(username, token); if (!validCreds) { return { errorCode: "INVALID_CREDENTIALS", }; } const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty("dscc.username", username); userProperties.setProperty("dscc.token", token); return { errorCode: "NONE", }; } // [END apps_script_data_studio_auth_set_credentials_user_token] // [START apps_script_data_studio_auth_set_credentials_key] /** * Sets the credentials. * @param {Request} request The set credentials request. * @return {object} An object with an errorCode. */ function setCredentialsKey(request) { const key = request.key; // Optional // Check if the provided key is valid through a call to your service. // You would have to have a `checkForValidKey` function defined for // this to work. const validKey = checkForValidKey(key); if (!validKey) { return { errorCode: "INVALID_CREDENTIALS", }; } const userProperties = PropertiesService.getUserProperties(); userProperties.setProperty("dscc.key", key); return { errorCode: "NONE", }; } // [END apps_script_data_studio_auth_set_credentials_key] ================================================ FILE: data-studio/build.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_build_get_config] /** * Builds the Community Connector config. * @return {Config} The Community Connector config. * @see https://developers.google.com/apps-script/reference/data-studio/config */ function getConfig() { const cc = DataStudioApp.createCommunityConnector(); const config = cc.getConfig(); config .newInfo() .setId("instructions") .setText("Enter npm package names to fetch their download count."); config .newTextInput() .setId("package") .setName("Enter a single package name.") .setHelpText("for example, googleapis or lighthouse") .setPlaceholder("googleapis") .setAllowOverride(true); config.setDateRangeRequired(true); return config.build(); } // [END apps_script_data_studio_build_get_config] // [START apps_script_data_studio_build_get_fields] /** * Builds the Community Connector fields object. * @return {Fields} The Community Connector fields. * @see https://developers.google.com/apps-script/reference/data-studio/fields */ function getFields() { const cc = DataStudioApp.createCommunityConnector(); const fields = cc.getFields(); const types = cc.FieldType; const aggregations = cc.AggregationType; fields .newDimension() .setId("packageName") .setName("Package Name") .setType(types.TEXT); fields .newDimension() .setId("day") .setName("Day") .setType(types.YEAR_MONTH_DAY); fields .newMetric() .setId("downloads") .setName("Downloads") .setType(types.NUMBER) .setAggregation(aggregations.SUM); return fields; } /** * Builds the Community Connector schema. * @param {object} request The request. * @return {object} The schema. */ function getSchema(request) { const fields = getFields().build(); return { schema: fields }; } // [END apps_script_data_studio_build_get_fields] // [START apps_script_data_studio_build_get_data] /** * Constructs an object with values as rows. * @param {Fields} requestedFields The requested fields. * @param {object[]} response The response. * @param {string} packageName The package name. * @return {object} An object containing rows with values. */ function responseToRows(requestedFields, response, packageName) { // Transform parsed data and filter for requested fields return response.map((dailyDownload) => { const row = []; for (const field of requestedFields.asArray()) { switch (field.getId()) { case "day": row.push(dailyDownload.day.replace(/-/g, "")); break; case "downloads": row.push(dailyDownload.downloads); break; case "packageName": row.push(packageName); break; default: row.push(""); } } return { values: row }; }); } /** * Gets the data for the community connector * @param {object} request The request. * @return {object} The data. */ function getData(request) { const requestedFieldIds = request.fields.map((field) => field.name); const requestedFields = getFields().forIds(requestedFieldIds); // Fetch and parse data from API const url = [ "https://api.npmjs.org/downloads/range/", request.dateRange.startDate, ":", request.dateRange.endDate, "/", request.configParams.package, ]; const response = UrlFetchApp.fetch(url.join("")); const parsedResponse = JSON.parse(response).downloads; const rows = responseToRows( requestedFields, parsedResponse, request.configParams.package, ); return { schema: requestedFields.build(), rows: rows, }; } // [END apps_script_data_studio_build_get_data] // [START apps_script_data_studio_build_get_auth_type] /** * Gets the Auth type. * @return {object} The auth type. */ function getAuthType() { const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse().setAuthType(cc.AuthType.NONE).build(); } // [END apps_script_data_studio_build_get_auth_type] ================================================ FILE: data-studio/caas.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_caas_example] const sqlString = "" + "SELECT " + " _TABLE_SUFFIX AS yyyymm, " + " ROUND(SUM(IF(fcp.start < @fast_fcp, fcp.density, 0)), 4) AS fast_fcp, " + " ROUND(SUM(IF(fcp.start >= 1000 AND fcp.start < 3000, fcp.density, 0)), 4) AS avg_fcp, " + " ROUND(SUM(IF(fcp.start >= 3000, fcp.density, 0)), 4) AS slow_fcp " + "FROM " + " `chrome-ux-report.all.*`, " + " UNNEST(first_contentful_paint.histogram.bin) AS fcp " + "WHERE " + " origin = @url " + "GROUP BY " + " yyyymm " + "ORDER BY " + " yyyymm "; /** * Gets the config. * @param {object} request The request. * @return {Config} The Community Connector config. */ function getConfig(request) { const cc = DataStudioApp.createCommunityConnector(); const config = cc.getConfig(); config .newTextInput() .setId("projectId") .setName("BigQuery Billing Project ID") .setPlaceholder("556727765207"); config .newTextInput() .setId("url") .setName("Enter your url") .setAllowOverride(true) .setPlaceholder("www.example.com"); config.setDateRangeRequired(true); return config.build(); } /** * Gets the fields. * @param {object} request The request. * @return {Fields} The Community Connector fields. */ function getFields() { const cc = DataStudioApp.createCommunityConnector(); const fields = cc.getFields(); const types = cc.FieldType; fields .newDimension() .setId("yyyymm") .setName("yyyymm") .setType(types.YEAR_MONTH); fields .newMetric() .setId("fast_fcp") .setName("fast_fcp") .setType(types.NUMBER); fields.newMetric().setId("avg_fcp").setName("avg_fcp").setType(types.NUMBER); fields .newMetric() .setId("slow_fcp") .setName("slow_fcp") .setType(types.NUMBER); return fields; } /** * Gets the schema. * @param {object} request * @return {object} The connector's schema. */ function getSchema(request) { return { schema: getFields().build(), }; } /** * Gets the connector's data. * @param {object} request The request. * @return {object} The data response. */ function getData(request) { const url = request.configParams?.url; const projectId = request.configParams?.projectId; const authToken = ScriptApp.getOAuthToken(); const response = { dataConfig: { type: "BIGQUERY", bigQueryConnectorConfig: { billingProjectId: projectId, query: sqlString, useStandardSql: true, queryParameters: [ { name: "url", parameterType: { type: "STRING", }, parameterValue: { value: url, }, }, { name: "fast_fcp", parameterType: { type: "INT64", }, parameterValue: { value: `${1000}`, }, }, ], }, }, authConfig: { accessToken: authToken, }, }; return response; } /** * Gets the auth type. * @return {object} The auth type. */ function getAuthType() { const cc = DataStudioApp.createCommunityConnector(); return cc.newAuthTypeResponse().setAuthType(cc.AuthType.NONE).build(); } // [END apps_script_data_studio_caas_example] ================================================ FILE: data-studio/data-source.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_params] const configParams = [ { type: "TEXTINPUT", name: "ZipCode", displayName: "ZIP Code", parameterControl: { allowOverride: true, }, }, { type: "SELECT_SINGLE", name: "units", displayName: "Units", parameterControl: { allowOverride: true, }, options: [ { label: "Metric", value: "metric", }, { label: "Imperial", value: "imperial", }, { label: "Kelvin", value: "kelvin", }, ], }, { type: "TEXTINPUT", name: "Days", displayName: "Days to forecast", }, ]; // [END apps_script_data_studio_params] ================================================ FILE: data-studio/errors.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_error_ds_user] function showErrorToAllUsers() { try { // Code that might fail. throw new Error("Something went wrong"); } catch (e) { throw new Error("DS_USER:This will be shown to admin & non-admin."); } } function showErrorToAdminUsers() { // Only admin users will see the following error. try { // Code that might fail. throw new Error("Something went wrong"); } catch (e) { throw new Error("This message will only be shown to admin users"); } } // [END apps_script_data_studio_error_ds_user] // [START apps_script_data_studio_error_helper] /** * Throws an error that complies with the community connector spec. * @param {string} message The error message. * @param {boolean} userSafe Determines whether this message is safe to show * to non-admin users of the connector. true to show the message, false * otherwise. false by default. */ function throwConnectorError(message, userSafe) { let safeMessage = message; const isUserSafe = typeof userSafe !== "undefined" && typeof userSafe === "boolean" ? userSafe : false; if (isUserSafe) { safeMessage = `DS_USER:${message}`; } throw new Error(safeMessage); } // [END apps_script_data_studio_error_helper] // [START apps_script_data_studio_error_logging] /** * Log an error that complies with the community connector spec. * @param {Error} originalError The original error that occurred. * @param {string} message Additional details about the error to include in * the log entry. */ function logConnectorError(originalError, message) { const logEntry = [ "Original error (Message): ", originalError, "(", message, ")", ]; console.error(logEntry.join("")); // Log to Stackdriver. } // [END apps_script_data_studio_error_logging] // [START apps_script_data_studio_error_error] function showErrorToNonAdminUsers() { // Error message that will be shown to a non-admin users. try { // Code that might fail. throw new Error("Something went wrong"); } catch (e) { logConnectorError(e, "quota_hour_exceeded"); // Log to Stackdriver. throwConnectorError( "You've exceeded the hourly quota. Try again later.", true, ); } } // [END apps_script_data_studio_error_error] ================================================ FILE: data-studio/links.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_links] // These variables should be filled in as necessary for your connector. let configJSON; let templateId; let deploymentId; const params = []; const jsonString = JSON.stringify(configJSON); const encoded = encodeURIComponent(jsonString); params.push(`connectorConfig=${encoded}`); params.push(`reportTemplateId=${templateId}`); params.push(`connectorId=${deploymentId}`); const joinedParams = params.join("&"); const URL = `https://datastudio.google.com/datasources/create?${joinedParams}`; // [END apps_script_data_studio_links] ================================================ FILE: data-studio/manifest.gs ================================================ ================================================ FILE: data-studio/semantics.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_data_studio_manual] const schema = [ { name: "Income", label: "Income (in USD)", dataType: "NUMBER", semantics: { conceptType: "METRIC", semanticGroup: "CURRENCY", semanticType: "CURRENCY_USD", }, }, { name: "Filing Year", label: "Year in which you filed the taxes.", dataType: "STRING", semantics: { conceptType: "METRIC", semanticGroup: "DATE_OR_TIME", semanticType: "YEAR", }, }, ]; // [END apps_script_data_studio_manual] ================================================ FILE: docs/README.md ================================================ # Google Docs Add-ons ## Cursor Inspector This add-on allows you to inspect the current state of the cursor or selection within a document. The information is presented in a sidebar and updates automatically every few seconds. ## Translate This add-on allows you to translate selected text from a set of source languages to a set of destination languages. ================================================ FILE: docs/cursorInspector/README.md ================================================ # Cursor Inspector Cursor Inspector is a sample script for Google Docs that allows you to inspect the current state of the cursor or selection within a document. The information is presented in a sidebar and updates automatically every few seconds. The data presented corresponds with the [`Cursor`](https://developers.google.com/apps-script/reference/document/cursor) and [`Selection`](https://developers.google.com/apps-script/reference/document/selection) classes of the API. ![Cursor Inspector screenshot](screenshot.png) ## Try it out For your convience we have deployed the script into a Google Docs [document](https://docs.google.com/document/d/1v6S7IkDL_YIaVn1rBcVbqFr3rbNUX9_kLfFc00WTtx8/view) that you can copy and use. Follow the instructions in the document to get started. ================================================ FILE: docs/cursorInspector/cursorInspector.gs ================================================ // Copyright 2013 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * Runs when the document is opened. */ function onOpen() { DocumentApp.getUi() .createMenu("Inspector") .addItem("Show sidebar", "showSidebar") .addToUi(); } /** * Show the sidebar. */ function showSidebar() { DocumentApp.getUi().showSidebar( HtmlService.createTemplateFromFile("Sidebar") .evaluate() .setTitle("Cursor Inspector") .setWidth(350), ); } /** * Returns the contents of an HTML file. * @param {string} file The name of the file to retrieve. * @return {string} The content of the file. */ function include(file) { return HtmlService.createTemplateFromFile(file).evaluate().getContent(); } /** * Gets the current cursor and selector information for the document. * @return {Object} The infomration. */ function getDocumentInfo() { const document = DocumentApp.getActiveDocument(); const cursor = document.getCursor(); const selection = document.getSelection(); const result = {}; if (cursor) { result.cursor = { element: getElementInfo(cursor.getElement()), offset: cursor.getOffset(), surroundingText: cursor.getSurroundingText().getText(), surroundingTextOffset: cursor.getSurroundingTextOffset(), }; } if (selection) { result.selection = { selectedElements: selection .getSelectedElements() .map((selectedElement) => ({ element: getElementInfo(selectedElement.getElement()), partial: selectedElement.isPartial(), startOffset: selectedElement.getStartOffset(), endOffsetInclusive: selectedElement.getEndOffsetInclusive(), })), }; } return result; } /** * Gets information about a given element. * @param {Element} element The element. * @return {Object} The information. */ function getElementInfo(element) { return { type: String(element.getType()), }; } ================================================ FILE: docs/cursorInspector/sidebar.css.html ================================================ ================================================ FILE: docs/cursorInspector/sidebar.html ================================================
Loading ...
Cursor
Selection
Element Partial Start End

Automatically refreshed every few seconds.
Last updated .

================================================ FILE: docs/cursorInspector/sidebar.js.html ================================================ ================================================ FILE: docs/dialog2sidebar/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Runs when the document opens, populating the menu. */ function onOpen() { DocumentApp.getUi() .createMenu("Sidebar") .addItem("Show", "showSidebar") .addToUi(); } /** * Shows the sidebar in the document. */ function showSidebar() { const page = HtmlService.createTemplateFromFile("Sidebar") .evaluate() .setTitle("Sidebar"); DocumentApp.getUi().showSidebar(page); } /** * Open a dialog in the document. * @return {string} The dialog ID. */ function openDialog() { const dialogId = Utilities.base64Encode(Math.random()); const template = HtmlService.createTemplateFromFile("Dialog"); template.dialogId = dialogId; const page = template.evaluate().setTitle("Dialog"); DocumentApp.getUi().showDialog(page); return dialogId; } /** * Include the contents of the given file into the HTML content. * @param {string} filename The filename * @return {string} The content of the rendered file. */ function include(filename) { return HtmlService.createHtmlOutputFromFile(filename).getContent(); } ================================================ FILE: docs/dialog2sidebar/Dialog.html ================================================

Content goes here...

================================================ FILE: docs/dialog2sidebar/Intercom.js.html ================================================ ================================================ FILE: docs/dialog2sidebar/README.md ================================================ # Dialog to Sidebar Communication in Apps Script This script demonstrates a method of setting up a communication channel between a dialog and a sidebar in Apps Script. This helps solve the common problem of having your sidebar know when a dialog is opened, is submitted, closed, etc. With the introduction of the IFRAME sandbox mode, HtmlService UIs can take advantage of the [HTML5 localStorage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). The open source library [intercom.js](https://github.com/diy/intercom.js/) builds a messaging system on top of this API, allowing for dialogs and sidebars in the same browser to communicate with each other. An overview of the process is as follows: * The sidebar requests a new dialog to be opened. * The backend generates a new ID for the dialog, opens the dialog (passing in that ID as a template parameter), and sends the ID back to the sidebar. * The sidebar listens for events on the dialog's intercom.js channel. * The dialog regularly "checks in" with the sidebar, resetting a [timer](https://developer.mozilla.org/en-US/Add-ons/Code_snippets/Timers). * When the user completes the dialog (by clicking either the "OK" or "Cancel" button) it sends this status change to the sidebar. * If the sidebar's timer actually fires, that means the dialog hasn't checked in recently, and it is considered "lost". ================================================ FILE: docs/dialog2sidebar/Sidebar.html ================================================ ================================================ FILE: docs/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START docs_quickstart] /** * Prints the title of the sample document: * https://docs.google.com/document/d/195j9eDD3ccgjQRttHhJPymLJUCOUjs-jmwTrekvdjFE/edit * @see https://developers.google.com/docs/api/reference/rest/v1/documents/get */ function printDocTitle() { const documentId = "195j9eDD3ccgjQRttHhJPymLJUCOUjs-jmwTrekvdjFE"; const doc = Docs.Documents.get(documentId, { includeTabsContent: true }); console.log(`The title of the doc is: ${doc.title}`); } // [END docs_quickstart] ================================================ FILE: docs/translate/README.md ================================================ # Translate Translate is a sample script for Google Docs that allows you to translate selected text from a set of source languages to a set of destination languages. The resulting translation can then be inserted back into the Google Document. This sample was originally designed as a [quickstart](https://developers.google.com/apps-script/quickstart/docs). ![Google Docs Translate Quickstart](https://developers.google.com/apps-script/images/quickstart-translate.png) ================================================ FILE: docs/translate/sidebar.html ================================================ ================================================ FILE: docs/translate/translate.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_docs_translate_quickstart] /** * @OnlyCurrentDoc * * The above comment directs Apps Script to limit the scope of file * access for this add-on. It specifies that this add-on will only * attempt to read or modify the files in which the add-on is used, * and not all of the user's files. The authorization request message * presented to users will reflect this limited scope. */ /** * Creates a menu entry in the Google Docs UI when the document is opened. * This method is only used by the regular add-on, and is never called by * the mobile add-on version. * * @param {object} e The event parameter for a simple onOpen trigger. To * determine which authorization mode (ScriptApp.AuthMode) the trigger is * running in, inspect e.authMode. */ function onOpen(e) { DocumentApp.getUi() .createAddonMenu() .addItem("Start", "showSidebar") .addToUi(); } /** * Runs when the add-on is installed. * This method is only used by the regular add-on, and is never called by * the mobile add-on version. * * @param {object} e The event parameter for a simple onInstall trigger. To * determine which authorization mode (ScriptApp.AuthMode) the trigger is * running in, inspect e.authMode. (In practice, onInstall triggers always * run in AuthMode.FULL, but onOpen triggers may be AuthMode.LIMITED or * AuthMode.NONE.) */ function onInstall(e) { onOpen(e); } /** * Opens a sidebar in the document containing the add-on's user interface. * This method is only used by the regular add-on, and is never called by * the mobile add-on version. */ function showSidebar() { const ui = HtmlService.createHtmlOutputFromFile("sidebar").setTitle("Translate"); DocumentApp.getUi().showSidebar(ui); } /** * Gets the text the user has selected. If there is no selection, * this function displays an error message. * * @return {Array.} The selected text. */ function getSelectedText() { const selection = DocumentApp.getActiveDocument().getSelection(); const text = []; if (selection) { const elements = selection.getSelectedElements(); for (let i = 0; i < elements.length; ++i) { if (elements[i].isPartial()) { const element = elements[i].getElement().asText(); const startIndex = elements[i].getStartOffset(); const endIndex = elements[i].getEndOffsetInclusive(); text.push(element.getText().substring(startIndex, endIndex + 1)); } else { const element = elements[i].getElement(); // Only translate elements that can be edited as text; skip images and // other non-text elements. if (element.editAsText) { const elementText = element.asText().getText(); // This check is necessary to exclude images, which return a blank // text element. if (elementText) { text.push(elementText); } } } } } if (!text.length) throw new Error("Please select some text."); return text; } /** * Gets the stored user preferences for the origin and destination languages, * if they exist. * This method is only used by the regular add-on, and is never called by * the mobile add-on version. * * @return {Object} The user's origin and destination language preferences, if * they exist. */ function getPreferences() { const userProperties = PropertiesService.getUserProperties(); return { originLang: userProperties.getProperty("originLang"), destLang: userProperties.getProperty("destLang"), }; } /** * Gets the user-selected text and translates it from the origin language to the * destination language. The languages are notated by their two-letter short * form. For example, English is 'en', and Spanish is 'es'. The origin language * may be specified as an empty string to indicate that Google Translate should * auto-detect the language. * * @param {string} origin The two-letter short form for the origin language. * @param {string} dest The two-letter short form for the destination language. * @param {boolean} savePrefs Whether to save the origin and destination * language preferences. * @return {Object} Object containing the original text and the result of the * translation. */ function getTextAndTranslation(origin, dest, savePrefs) { if (savePrefs) { PropertiesService.getUserProperties() .setProperty("originLang", origin) .setProperty("destLang", dest); } const text = getSelectedText().join("\n"); return { text: text, translation: translateText(text, origin, dest), }; } /** * Replaces the text of the current selection with the provided text, or * inserts text at the current cursor location. (There will always be either * a selection or a cursor.) If multiple elements are selected, only inserts the * translated text in the first element that can contain text and removes the * other elements. * * @param {string} newText The text with which to replace the current selection. */ function insertText(newText) { const selection = DocumentApp.getActiveDocument().getSelection(); if (selection) { let replaced = false; const elements = selection.getSelectedElements(); if ( elements.length === 1 && elements[0].getElement().getType() === DocumentApp.ElementType.INLINE_IMAGE ) { throw new Error("Can't insert text into an image."); } for (let i = 0; i < elements.length; ++i) { if (elements[i].isPartial()) { const element = elements[i].getElement().asText(); const startIndex = elements[i].getStartOffset(); const endIndex = elements[i].getEndOffsetInclusive(); element.deleteText(startIndex, endIndex); if (!replaced) { element.insertText(startIndex, newText); replaced = true; } else { // This block handles a selection that ends with a partial element. We // want to copy this partial text to the previous element so we don't // have a line-break before the last partial. const parent = element.getParent(); const remainingText = element.getText().substring(endIndex + 1); parent.getPreviousSibling().asText().appendText(remainingText); // We cannot remove the last paragraph of a doc. If this is the case, // just remove the text within the last paragraph instead. if (parent.getNextSibling()) { parent.removeFromParent(); } else { element.removeFromParent(); } } } else { const element = elements[i].getElement(); if (!replaced && element.editAsText) { // Only translate elements that can be edited as text, removing other // elements. element.clear(); element.asText().setText(newText); replaced = true; } else { // We cannot remove the last paragraph of a doc. If this is the case, // just clear the element. if (element.getNextSibling()) { element.removeFromParent(); } else { element.clear(); } } } } } else { const cursor = DocumentApp.getActiveDocument().getCursor(); const surroundingText = cursor.getSurroundingText().getText(); const surroundingTextOffset = cursor.getSurroundingTextOffset(); // If the cursor follows or preceds a non-space character, insert a space // between the character and the translation. Otherwise, just insert the // translation. let textToInsert = newText; if (surroundingText) { if (surroundingTextOffset > 0) { if (surroundingText.charAt(surroundingTextOffset - 1) !== " ") { textToInsert = ` ${textToInsert}`; } } if (surroundingTextOffset < surroundingText.length) { if (surroundingText.charAt(surroundingTextOffset) !== " ") { textToInsert += " "; } } } cursor.insertText(textToInsert); } } /** * Given text, translate it from the origin language to the destination * language. The languages are notated by their two-letter short form. For * example, English is 'en', and Spanish is 'es'. The origin language may be * specified as an empty string to indicate that Google Translate should * auto-detect the language. * * @param {string} text text to translate. * @param {string} origin The two-letter short form for the origin language. * @param {string} dest The two-letter short form for the destination language. * @return {string} The result of the translation, or the original text if * origin and dest languages are the same. */ function translateText(text, origin, dest) { if (origin === dest) return text; return LanguageApp.translate(text, origin, dest); } // [END apps_script_docs_translate_quickstart] ================================================ FILE: drive/activity/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START drive_activity_quickstart] /** * Lists activity for a Drive user. */ function listActivity() { const optionalArgs = { source: "drive.google.com", "drive.ancestorId": "root", pageSize: 10, }; const response = AppsActivity.Activities.list(optionalArgs); const activities = response.activities; if (activities && activities.length > 0) { console.log("Recent activity:"); for (i = 0; i < activities.length; i++) { const activity = activities[i]; const event = activity.combinedEvent; const user = event.user; const target = event.target; if (user == null || target == null) { } else { const time = new Date(Number(event.eventTimeMillis)); console.log( "%s: %s, %s, %s (%s)", time, user.name, event.primaryEventType, target.name, target.mimeType, ); } } } else { console.log("No recent activity"); } } // [END drive_activity_quickstart] ================================================ FILE: drive/activity-v2/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START drive_activity_v2_quickstart] /** * Lists 10 activity for a Drive user. * @see https://developers.google.com/drive/activity/v2/reference/rest/v2/activity/query */ function listDriveActivity() { const request = { pageSize: 10, // Use other parameter here if needed. }; try { // Activity.query method is used Query past activity in Google Drive. const response = DriveActivity.Activity.query(request); const activities = response.activities; if (!activities || activities.length === 0) { console.log("No activity."); return; } console.log("Recent activity:"); for (const activity of activities) { // get time information of activity. const time = getTimeInfo(activity); // get the action details/information const action = getActionInfo(activity.primaryActionDetail); // get the actor's details of activity const actors = activity.actors.map(getActorInfo); // get target information of activity. const targets = activity.targets.map(getTargetInfo); // print the time,actor,action and targets of drive activity. console.log("%s: %s, %s, %s", time, actors, action, targets); } } catch (err) { // TODO (developer) - Handle error from drive activity API console.log("Failed with an error %s", err.message); } } /** * @param {object} object * @return {string} Returns the name of a set property in an object, or else "unknown". */ function getOneOf(object) { for (const key in object) { return key; } return "unknown"; } /** * @param {object} activity Activity object. * @return {string} Returns a time associated with an activity. */ function getTimeInfo(activity) { if ("timestamp" in activity) { return activity.timestamp; } if ("timeRange" in activity) { return activity.timeRange.endTime; } return "unknown"; } /** * @param {object} actionDetail The primary action details of the activity. * @return {string} Returns the type of action. */ function getActionInfo(actionDetail) { return getOneOf(actionDetail); } /** * @param {object} user The User object. * @return {string} Returns user information, or the type of user if not a known user. */ function getUserInfo(user) { if ("knownUser" in user) { const knownUser = user.knownUser; const isMe = knownUser.isCurrentUser || false; return isMe ? "people/me" : knownUser.personName; } return getOneOf(user); } /** * @param {object} actor The Actor object. * @return {string} Returns actor information, or the type of actor if not a user. */ function getActorInfo(actor) { if ("user" in actor) { return getUserInfo(actor.user); } return getOneOf(actor); } /** * @param {object} target The Target object. * @return {string} Returns the type of a target and an associated title. */ function getTargetInfo(target) { if ("driveItem" in target) { const title = target.driveItem.title || "unknown"; return `driveItem:"${title}"`; } if ("drive" in target) { const title = target.drive.title || "unknown"; return `drive:"${title}"`; } if ("fileComment" in target) { const parent = target.fileComment.parent || {}; const title = parent.title || "unknown"; return `fileComment:"${title}"`; } return `${getOneOf(target)}:unknown`; } // [END drive_activity_v2_quickstart] ================================================ FILE: drive/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START drive_quickstart] /** * Lists the names and IDs of up to 10 files. */ function listFiles() { try { // Files.list method returns the list of files in drive. const files = Drive.Files.list({ fields: "nextPageToken, items(id, title)", maxResults: 10, }).items; // Print the title and id of files available in drive for (const file of files) { console.log("%s (%s)", file.title, file.id); } } catch (err) { // TODO(developer)-Handle Files.list() exception console.log("failed with error %s", err.message); } } // [END drive_quickstart] ================================================ FILE: forms/README.md ================================================ # Google Forms Add-ons ## [Notification Add-on](https://developers.google.com/apps-script/quickstart/forms-add-on) This add-on allows Form creators to automatically send email notifications when a form is submitted. In addition, the add-on allows form creators to be notified when they have received responses. ![Form Notifications](https://developers.google.com/apps-script/images/quickstart-form-notifications.png) ================================================ FILE: forms/notifications/README.md ================================================ # Form Notifications Add-on for Google Forms A sample Google Apps Script add-on for Google Forms. ## Introduction Google Apps Script allows developers to construct 'add-ons' -- small applications which extend and support Google Docs, Google Sheets, and now Google Forms. This sample shows how to construct a Google Forms add-on called [Form Notifications](https://chrome.google.com/webstore/detail/form-notifications/bbpdeojefjfhaelgljjcadpcckdfcdod). This add-on allows Form creators to automatically send email notifications when a form is submitted. In addition, the add-on allows form creators to be notified when they have received responses. This sample makes use of the following Apps Script concepts: * Google Forms Add-ons * Events and Triggers (specifically, onFormSubmit triggers) * Templated HTML * Dialogs and Sidebars * Sending Email with Apps Script ## Getting Started You can install the [Form Notifications](https://chrome.google.com/webstore/detail/form-notifications/bbpdeojefjfhaelgljjcadpcckdfcdod) add-on from the add-on store. If you would like to try re-building it yourself, you can follow the directions provided in the [Add-on for Google Forms Quickstart](https://developers.google.com/apps-script/quickstart/forms-add-on) documentation. ## Learn more To continue learning about how to extend Google Docs, Sheets and Forms with Apps Script, take a look at the following resources: * [Guide to Add-ons](https://developers.google.com/apps-script/add-ons/) * [Forms Service Reference](https://developers.google.com/apps-script/reference/forms) ## Support - Stack Overflow Tag: [google-apps-script](http://stackoverflow.com/questions/tagged/google-apps-script) ================================================ FILE: forms/notifications/about.html ================================================

Form Notifications was created as an sample add-on, and is meant for demonstration purposes only. It should not be used for complex or important workflows.

The number of notifications this add-on produces are limited by the owner's available email quota; it will not send email notifications if the owner's daily email quota has been exceeded. Collaborators using this add-on on the same form will be able to adjust the notification settings, but will not be able to disable the notification triggers set by other collaborators.

================================================ FILE: forms/notifications/authorizationEmail.html ================================================

The Google Forms add-on Form Notifications is set to run automatically whenever a form is submitted. The add-on was recently updated and it needs you to re-authorize it to run on your behalf.

The add-on's automatic functions are temporarily disabled until you re-authorize the add-on. You can accomplish this by opening one of the forms using the add-on and running the add-on through the menu. Alternatively, you can click this link to approve authorization directly:

Click here to re-authorize the add-on.

This notification email will be sent to you at most once per day until the add-on is re-authorized.


This automatic message was sent to you via the Form Notifications add-on for Google Forms.

================================================ FILE: forms/notifications/creatorNotification.html ================================================

Form Notifications (a Google Forms add-on) has detected that the form titled has received responses so far.

Summary of form responses

You are receiving this email because an editor of this form configured Form Notifications to alert you every time this form receives responses.

To change this setting, or to stop receiving these notifications, have the form owner or editors open the form and adjust the Form Notifications add-on configuration via the "Configure notifications" menu item.


This automatic message was sent to you via the Form Notifications add-on for Google Forms.

================================================ FILE: forms/notifications/notification.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_forms_notifications_quickstart] /** * @OnlyCurrentDoc * * The above comment directs Apps Script to limit the scope of file * access for this add-on. It specifies that this add-on will only * attempt to read or modify the files in which the add-on is used, * and not all of the user's files. The authorization request message * presented to users will reflect this limited scope. */ /** * A global constant String holding the title of the add-on. This is * used to identify the add-on in the notification emails. */ const ADDON_TITLE = "Form Notifications"; /** * A global constant 'notice' text to include with each email * notification. */ const NOTICE = "Form Notifications was created as an sample add-on, and is" + " meant for" + "demonstration purposes only. It should not be used for complex or important" + "workflows. The number of notifications this add-on produces are limited by the" + "owner's available email quota; it will not send email notifications if the" + "owner's daily email quota has been exceeded. Collaborators using this add-on on" + "the same form will be able to adjust the notification settings, but will not be" + "able to disable the notification triggers set by other collaborators."; /** * Adds a custom menu to the active form to show the add-on sidebar. * * @param {object} e The event parameter for a simple onOpen trigger. To * determine which authorization mode (ScriptApp.AuthMode) the trigger is * running in, inspect e.authMode. */ function onOpen(e) { try { FormApp.getUi() .createAddonMenu() .addItem("Configure notifications", "showSidebar") .addItem("About", "showAbout") .addToUi(); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Runs when the add-on is installed. * * @param {object} e The event parameter for a simple onInstall trigger. To * determine which authorization mode (ScriptApp.AuthMode) the trigger is * running in, inspect e.authMode. (In practice, onInstall triggers always * run in AuthMode.FULL, but onOpen triggers may be AuthMode.LIMITED or * AuthMode.NONE). */ function onInstall(e) { onOpen(e); } /** * Opens a sidebar in the form containing the add-on's user interface for * configuring the notifications this add-on will produce. */ function showSidebar() { try { const ui = HtmlService.createHtmlOutputFromFile("sidebar").setTitle( "Form Notifications", ); FormApp.getUi().showSidebar(ui); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Opens a purely-informational dialog in the form explaining details about * this add-on. */ function showAbout() { try { const ui = HtmlService.createHtmlOutputFromFile("about") .setWidth(420) .setHeight(270); FormApp.getUi().showModalDialog(ui, "About Form Notifications"); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Save sidebar settings to this form's Properties, and update the onFormSubmit * trigger as needed. * * @param {Object} settings An Object containing key-value * pairs to store. */ function saveSettings(settings) { try { PropertiesService.getDocumentProperties().setProperties(settings); adjustFormSubmitTrigger(); } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Queries the User Properties and adds additional data required to populate * the sidebar UI elements. * * @return {Object} A collection of Property values and * related data used to fill the configuration sidebar. */ function getSettings() { try { const settings = PropertiesService.getDocumentProperties().getProperties(); // Use a default email if the creator email hasn't been provided yet. if (!settings.creatorEmail) { settings.creatorEmail = Session.getEffectiveUser().getEmail(); } // Get text field items in the form and compile a list // of their titles and IDs. const form = FormApp.getActiveForm(); const textItems = form.getItems(FormApp.ItemType.TEXT); settings.textItems = []; for (let i = 0; i < textItems.length; i++) { settings.textItems.push({ title: textItems[i].getTitle(), id: textItems[i].getId(), }); } return settings; } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Adjust the onFormSubmit trigger based on user's requests. */ function adjustFormSubmitTrigger() { try { const form = FormApp.getActiveForm(); const triggers = ScriptApp.getUserTriggers(form); const settings = PropertiesService.getDocumentProperties(); const triggerNeeded = settings.getProperty("creatorNotify") === "true" || settings.getProperty("respondentNotify") === "true"; // Create a new trigger if required; delete existing trigger // if it is not needed. let existingTrigger = null; for (let i = 0; i < triggers.length; i++) { if (triggers[i].getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT) { existingTrigger = triggers[i]; break; } } if (triggerNeeded && !existingTrigger) { const trigger = ScriptApp.newTrigger("respondToFormSubmit") .forForm(form) .onFormSubmit() .create(); } else if (!triggerNeeded && existingTrigger) { ScriptApp.deleteTrigger(existingTrigger); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Responds to a form submission event if an onFormSubmit trigger has been * enabled. * * @param {Object} e The event parameter created by a form * submission; see * https://developers.google.com/apps-script/understanding_events */ function respondToFormSubmit(e) { try { const settings = PropertiesService.getDocumentProperties(); const authInfo = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); // Check if the actions of the trigger require authorizations that have not // been supplied yet -- if so, warn the active user via email (if possible). // This check is required when using triggers with add-ons to maintain // functional triggers. if ( authInfo.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED ) { // Re-authorization is required. In this case, the user needs to be alerted // that they need to reauthorize; the normal trigger action is not // conducted, since authorization needs to be provided first. Send at // most one 'Authorization Required' email a day, to avoid spamming users // of the add-on. sendReauthorizationRequest(); } else { // All required authorizations have been granted, so continue to respond to // the trigger event. // Check if the form creator needs to be notified; if so, construct and // send the notification. if (settings.getProperty("creatorNotify") === "true") { sendCreatorNotification(); } // Check if the form respondent needs to be notified; if so, construct and // send the notification. Be sure to respect the remaining email quota. if ( settings.getProperty("respondentNotify") === "true" && MailApp.getRemainingDailyQuota() > 0 ) { sendRespondentNotification(e.response); } } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Called when the user needs to reauthorize. Sends the user of the * add-on an email explaining the need to reauthorize and provides * a link for the user to do so. Capped to send at most one email * a day to prevent spamming the users of the add-on. */ function sendReauthorizationRequest() { try { const settings = PropertiesService.getDocumentProperties(); const authInfo = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); const lastAuthEmailDate = settings.getProperty("lastAuthEmailDate"); const today = new Date().toDateString(); if (lastAuthEmailDate !== today) { if (MailApp.getRemainingDailyQuota() > 0) { const template = HtmlService.createTemplateFromFile("authorizationEmail"); template.url = authInfo.getAuthorizationUrl(); template.notice = NOTICE; const message = template.evaluate(); MailApp.sendEmail( Session.getEffectiveUser().getEmail(), "Authorization Required", message.getContent(), { name: ADDON_TITLE, htmlBody: message.getContent(), }, ); } settings.setProperty("lastAuthEmailDate", today); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Sends out creator notification email(s) if the current number * of form responses is an even multiple of the response step * setting. */ function sendCreatorNotification() { try { const form = FormApp.getActiveForm(); const settings = PropertiesService.getDocumentProperties(); let responseStep = settings.getProperty("responseStep"); responseStep = responseStep ? Number.parseInt(responseStep) : 10; // If the total number of form responses is an even multiple of the // response step setting, send a notification email(s) to the form // creator(s). For example, if the response step is 10, notifications // will be sent when there are 10, 20, 30, etc. total form responses // received. if (form.getResponses().length % responseStep === 0) { const addresses = settings.getProperty("creatorEmail").split(","); if (MailApp.getRemainingDailyQuota() > addresses.length) { const template = HtmlService.createTemplateFromFile( "creatorNotification", ); template.summary = form.getSummaryUrl(); template.responses = form.getResponses().length; template.title = form.getTitle(); template.responseStep = responseStep; template.formUrl = form.getEditUrl(); template.notice = NOTICE; const message = template.evaluate(); MailApp.sendEmail( settings.getProperty("creatorEmail"), `${form.getTitle()}: Form submissions detected`, message.getContent(), { name: ADDON_TITLE, htmlBody: message.getContent(), }, ); } } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } /** * Sends out respondent notification emails. * * @param {FormResponse} response FormResponse object of the event * that triggered this notification */ function sendRespondentNotification(response) { try { const form = FormApp.getActiveForm(); const settings = PropertiesService.getDocumentProperties(); const emailId = settings.getProperty("respondentEmailItemId"); const emailItem = form.getItemById(Number.parseInt(emailId)); const respondentEmail = response .getResponseForItem(emailItem) .getResponse(); if (respondentEmail) { const template = HtmlService.createTemplateFromFile( "respondentNotification", ); template.paragraphs = settings.getProperty("responseText").split("\n"); template.notice = NOTICE; const message = template.evaluate(); MailApp.sendEmail( respondentEmail, settings.getProperty("responseSubject"), message.getContent(), { name: form.getTitle(), htmlBody: message.getContent(), }, ); } } catch (e) { // TODO (Developer) - Handle exception console.log("Failed with error: %s", e.error); } } // [END apps_script_forms_notifications_quickstart] ================================================ FILE: forms/notifications/respondentNotification.html ================================================


This automatic message was sent to you via the Form Notifications add-on for Google Forms.

================================================ FILE: forms/notifications/sidebar.html ================================================ ================================================ FILE: forms-api/demos/AppsScriptFormsAPIWebApp/Code.gs ================================================ // Copyright 2021 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. function doGet() { return HtmlService.createTemplateFromFile("Main").evaluate(); } ================================================ FILE: forms-api/demos/AppsScriptFormsAPIWebApp/FormsAPI.gs ================================================ // Copyright 2021 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Global constants. Customize as needed. const formsAPIUrl = "https://forms.googleapis.com/v1/forms/"; const formId = ""; const topicName = "projects/"; // To setup pub/sub topics, see: // https://cloud.google.com/pubsub/docs/building-pubsub-messaging-system /** * Forms API Method: forms.create * POST https://forms.googleapis.com/v1/forms */ function create(title) { const accessToken = ScriptApp.getOAuthToken(); const jsonTitle = JSON.stringify({ info: { title: title, }, }); const options = { headers: { Authorization: `Bearer ${accessToken}`, }, method: "post", contentType: "application/json", payload: jsonTitle, }; console.log(`Forms API POST options was: ${JSON.stringify(options)}`); const response = UrlFetchApp.fetch(formsAPIUrl, options); console.log(`Response from Forms API was: ${JSON.stringify(response)}`); return `${response}`; } /** * Forms API Method: forms.get * GET https://forms.googleapis.com/v1/forms/{formId}/responses/{responseId} */ function get(formId) { const accessToken = ScriptApp.getOAuthToken(); const options = { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, method: "get", }; try { const response = UrlFetchApp.fetch(formsAPIUrl + formId, options); console.log(`Response from Forms API was: ${response}`); return `${response}`; } catch (e) { console.log(JSON.stringify(e)); return `Error:${JSON.stringify(e)}

Unable to find Form with formId:
${formId}`; } } /** * Forms API Method: forms.batchUpdate * POST https://forms.googleapis.com/v1/forms/{formId}:batchUpdate */ function batchUpdate(formId) { const accessToken = ScriptApp.getOAuthToken(); // Request body to add a description to a Form const update = { requests: [ { updateFormInfo: { info: { description: "Please complete this quiz based on this week's readings for class.", }, updateMask: "description", }, }, ], }; const options = { headers: { Authorization: `Bearer ${accessToken}`, }, method: "post", contentType: "application/json", payload: JSON.stringify(update), muteHttpExceptions: true, }; const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}:batchUpdate`, options, ); console.log(`Response code from API: ${response.getResponseCode()}`); return response.getResponseCode(); } /** * Forms API Method: forms.responses.get * GET https://forms.googleapis.com/v1/forms/{formId}/responses/{responseId} */ function responsesGet(formId, responseId) { const accessToken = ScriptApp.getOAuthToken(); const options = { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, method: "get", }; try { const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}/responses/${responseId}`, options, ); console.log(`Response from Forms.responses.get was: ${response}`); return `${response}`; } catch (e) { console.log(JSON.stringify(e)); return `Error:${JSON.stringify(e)}`; } } /** * Forms API Method: forms.responses.list * GET https://forms.googleapis.com/v1/forms/{formId}/responses */ function responsesList(formId) { const accessToken = ScriptApp.getOAuthToken(); const options = { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, method: "get", }; try { const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}/responses`, options, ); console.log(`Response from Forms.responses was: ${response}`); return `${response}`; } catch (e) { console.log(JSON.stringify(e)); return `Error:${JSON.stringify(e)}`; } } /** * Forms API Method: forms.watches.create * POST https://forms.googleapis.com/v1/forms/{formId}/watches */ function createWatch(formId) { const accessToken = ScriptApp.getOAuthToken(); const myWatch = { watch: { target: { topic: { topicName: topicName, }, }, eventType: "RESPONSES", }, }; console.log(`myWatch is: ${JSON.stringify(myWatch)}`); const options = { headers: { Authorization: `Bearer ${accessToken}`, }, method: "post", contentType: "application/json", payload: JSON.stringify(myWatch), muteHttpExceptions: false, }; console.log(`options are: ${JSON.stringify(options)}`); console.log(`formsAPIURL was: ${formsAPIUrl}`); const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}/watches`, options, ); console.log(response); return `${response}`; } /** * Forms API Method: forms.watches.delete * DELETE https://forms.googleapis.com/v1/forms/{formId}/watches/{watchId} */ function deleteWatch(formId, watchId) { const accessToken = ScriptApp.getOAuthToken(); console.log(`formsAPIUrl is: ${formsAPIUrl}`); const options = { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, method: "delete", muteHttpExceptions: false, }; try { const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}/watches/${watchId}`, options, ); console.log(response); return `${response}`; } catch (e) { console.log(`API Error: ${JSON.stringify(e)}`); return JSON.stringify(e); } } /** * Forms API Method: forms.watches.list * GET https://forms.googleapis.com/v1/forms/{formId}/watches */ function watchesList(formId) { console.log(`formId is: ${formId}`); const accessToken = ScriptApp.getOAuthToken(); const options = { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, method: "get", }; try { const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}/watches`, options, ); console.log(response); return `${response}`; } catch (e) { console.log(`API Error: ${JSON.stringify(e)}`); return JSON.stringify(e); } } /** * Forms API Method: forms.watches.renew * POST https://forms.googleapis.com/v1/forms/{formId}/watches/{watchId}:renew */ function renewWatch(formId, watchId) { const accessToken = ScriptApp.getOAuthToken(); const options = { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, method: "post", }; try { const response = UrlFetchApp.fetch( `${formsAPIUrl + formId}/watches/${watchId}:renew`, options, ); console.log(response); return `${response}`; } catch (e) { console.log(`API Error: ${JSON.stringify(e)}`); return JSON.stringify(e); } } ================================================ FILE: forms-api/demos/AppsScriptFormsAPIWebApp/Main.html ================================================ Main Web Page

Forms API logoForms API & Apps Script Testing Application - v.1

Form Id:
Methods Status
forms.create (spec)
Form title:
forms.get (spec)
forms.batchUpdate (spec)
forms.responses.list (spec)

forms.responses.get (spec)
Response id:

forms.watches.create (spec)

forms.watches.delete (spec)
Watch id:

forms.watches.list (spec)

forms.watches.renew (spec)
Watch id:
Forms API Reference
================================================ FILE: forms-api/demos/AppsScriptFormsAPIWebApp/README.md ================================================ # Google Forms API Apps Script web app This solution demonstrates how to interact with the new Google Forms API directly from Apps Script using REST calls, not the native Apps Script Forms Service. ## General setup * Enable the Forms API for your Google Cloud project ## Web app setup 1. Create a new blank Apps Script project. 1. Click **Project Settings**, then: * Check **Show "appsscript.json" manifest file in editor**. * Enter the project number of the Google Cloud project that has the Forms API enabled and click **Change project**. 1. Copy the contents of the Apps Script, HTML and JSON files into your Apps Script project. 1. Edit the `FormsAPI.gs` file to customize the constants. * `formId`: Choose a `formId` from an existing form. * `topicName`: Optional, if using watches (pub/sub). Note: Further project setup is required to use the watch features. To set up pub/sub topics, see [Google Cloud Pubsub](https://cloud.google.com/pubsub/docs/building-pubsub-messaging-system) for additional details. 1. Deploy the project as a Web app, authorize access and click on the deployment URL. ================================================ FILE: forms-api/demos/AppsScriptFormsAPIWebApp/appsscript.json ================================================ { "timeZone": "America/New_York", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "dependencies": {}, "webapp": { "executeAs": "USER_DEPLOYING", "access": "MYSELF" }, "oauthScopes": [ "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/forms.body", "https://www.googleapis.com/auth/forms.body.readonly", "https://www.googleapis.com/auth/forms.responses.readonly", "https://www.googleapis.com/auth/userinfo.email" ] } ================================================ FILE: forms-api/snippets/README.md ================================================ # Forms API To run, you must set up your GCP project to use the Forms API. See: [Forms API](https://developers.google.com/forms/api/) ================================================ FILE: forms-api/snippets/retrieve_all_responses.gs ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START forms_retrieve_all_responses] function callFormsAPI() { console.log("Calling the Forms API!"); const formId = ""; // Get OAuth Token const OAuthToken = ScriptApp.getOAuthToken(); console.log(`OAuth token is: ${OAuthToken}`); const formsAPIUrl = `https://forms.googleapis.com/v1/forms/${formId}/responses`; console.log(`formsAPIUrl is: ${formsAPIUrl}`); const options = { headers: { Authorization: `Bearer ${OAuthToken}`, Accept: "application/json", }, method: "get", }; const response = UrlFetchApp.fetch(formsAPIUrl, options); console.log(`Response from forms.responses was: ${response}`); } // [END forms_retrieve_all_responses] ================================================ FILE: gmail/README.md ================================================ # Apps Scripts for Gmail Sample Google Apps Script functions for Gmail. ## [Mail Merge](https://developers.google.com/apps-script/articles/mail_merge) This tutorial shows an easy way to collect information from different users in a spreadsheet using Google Forms, then leverage it to generate and distribute personalized emails. ## [Sending Emails](https://developers.google.com/apps-script/articles/sending_emails) This tutorial shows how to use Spreadsheet data to send emails to different people. ## [Inline Image](inlineimage/inlineimage.gs) This example shows how to send an HTML email that includes an inline image attachment. ================================================ FILE: gmail/add-ons/appsscript.json ================================================ { "oauthScopes": [ "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/gmail.addons.current.message.metadata", "https://www.googleapis.com/auth/gmail.modify" ], "gmail": { "name": "Gmail Add-on Quickstart - QuickLabels", "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/label_googblue_24dp.png", "contextualTriggers": [ { "unconditional": {}, "onTriggerFunction": "buildAddOn" } ], "openLinkUrlPrefixes": ["https://mail.google.com/"], "primaryColor": "#4285F4", "secondaryColor": "#4285F4" } } ================================================ FILE: gmail/add-ons/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_gmail_quick_start] /** * Returns the array of cards that should be rendered for the current * e-mail thread. The name of this function is specified in the * manifest 'onTriggerFunction' field, indicating that this function * runs every time the add-on is started. * * @param {Object} e The data provided by the Gmail UI. * @return {Card[]} */ function buildAddOn(e) { // Activate temporary Gmail add-on scopes. const accessToken = e.messageMetadata.accessToken; GmailApp.setCurrentMessageAccessToken(accessToken); const messageId = e.messageMetadata.messageId; const message = GmailApp.getMessageById(messageId); // Get user and thread labels as arrays to enable quick sorting and indexing. const threadLabels = message.getThread().getLabels(); const labels = getLabelArray(GmailApp.getUserLabels()); const labelsInUse = getLabelArray(threadLabels); // Create a section for that contains all user Labels. const section = CardService.newCardSection().setHeader( 'Available User Labels', ); // Create a checkbox group for user labels that are added to prior section. const checkboxGroup = CardService.newSelectionInput() .setType(CardService.SelectionInputType.CHECK_BOX) .setFieldName("labels") .setOnChangeAction(CardService.newAction().setFunctionName("toggleLabel")); // Add checkbox with name and selected value for each User Label. for (let i = 0; i < labels.length; i++) { checkboxGroup.addItem( labels[i], labels[i], labelsInUse.indexOf(labels[i]) !== -1, ); } // Add the checkbox group to the section. section.addWidget(checkboxGroup); // Build the main card after adding the section. const card = CardService.newCardBuilder() .setHeader( CardService.newCardHeader() .setTitle("Quick Label") .setImageUrl( "https://www.gstatic.com/images/icons/material/system/1x/label_googblue_48dp.png", ), ) .addSection(section) .build(); return [card]; } /** * Updates the labels on the current thread based on * user selections. Runs via the OnChangeAction for * each CHECK_BOX created. * * @param {Object} e The data provided by the Gmail UI. */ function toggleLabel(e) { const selected = e.formInputs.labels; // Activate temporary Gmail add-on scopes. const accessToken = e.messageMetadata.accessToken; GmailApp.setCurrentMessageAccessToken(accessToken); const messageId = e.messageMetadata.messageId; const message = GmailApp.getMessageById(messageId); const thread = message.getThread(); if (selected != null) { for (const label of GmailApp.getUserLabels()) { if (selected.indexOf(label.getName()) !== -1) { thread.addLabel(label); } else { thread.removeLabel(label); } } } else { for (const label of GmailApp.getUserLabels()) { thread.removeLabel(label); } } } /** * Converts an GmailLabel object to a array of strings. * Used for easy sorting and to determine if a value exists. * * @param {labelsObjects} A GmailLabel object array. * @return {lables[]} An array of labels names as strings. */ function getLabelArray(labelsObjects) { const labels = []; for (let i = 0; i < labelsObjects.length; i++) { labels[i] = labelsObjects[i].getName(); } labels.sort(); return labels; } // [END apps_script_gmail_quick_start] ================================================ FILE: gmail/inlineimage/inlineimage.gs ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ function sendEmailToMyself() { // You can use this method to test the welcome email. sendEmailWithInlineImage(Session.getActiveUser().getEmail()); } function sendEmailWithInlineImage(toAddress) { const options = {}; const imageName = "cat_emoji"; // The URL "cid:cat_emoji" means that the inline attachment named "cat_emoji" would be used. options.htmlBody = `Welcome! Cat Emoji`; options.inlineImages = { [imageName]: Utilities.newBlob(getImageBinary(), "image/png", imageName), }; GmailApp.sendEmail(toAddress, "Welcome!", "Welcome!", options); } function getImageBinary() { // Cat Face Emoji from https://github.com/googlefonts/noto-emoji/blob/main/png/32/emoji_u1f431.png, Base64 encoded. const catPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+BJREFUeNrsV01ME1EQnpaltPK3iAT0oAsxMSYmlIOaGBO2etCDMTVq8CYl3jRBvehBI0YPehIPxhvFkxo1gHpQE9P15+KtROJFI6sxhEKwW6FY27o6s/vabpd9tPUn8eAkj8e+nTffzDez814B/kX5oXT4/7A9GceAk12Xg/IkThIOFUfIJb9XfgNYxCmMI8iWNLTXZNVx2zYEGTiwOUKe/wZ4xAJOIhIbXAdQuo2/dacB6i8gP7X0dA43hSsEJ+eJST9UtZv2fIdyr5d1wMyRsMkcBSd6y2WCRT5C0RrgZKN6K4C3x1FfcFw1QSFvYP4sWk4SE1F426gyRyVo/mbqzdUgiK6BoEcBkv35yAsBcEUoGRIZ8uwA+PYAQHeNgPsHzUv1MjYyfT0lwZ1S4Cz6DNNG8LoMX8+XLfz/9XZhXwUOaMUJTQJ8OYnRvSqs1VpAyCEaTu++T5p7aa7AgXGTzlfmRsq93cCKbHHE1qjt7FAAORvZidyqwm1E7BuNlORtoRoNou8iK0INi1DQ+emhWqBhpqQdm5HKK8JoWTVhB8o5wv02k+bA7moFX5ICfKmV7cQfErdDBys6MNTpLAzeS4AynirLoLagQ+jyLOw7G3PaI9lbsT0FQfuOwMkpwwmS8KkW6N1Vv6wDJ67NwfDjebPaxr9C/L5kV5GthWj/Cjrt2jlwkrGXiyUZUGPZIjYcWOgeGhrqxSHnGaAFKqVE5rq/sXqOa1ysK923pFahSF/u9Oaf3yS2wJsvm/2szhRrCuhBfjGzV6xyZ6Gr6Tm0eT8YLwYON8HAjbhhrH9/Y97Y+eE4KFEzOqlNgCvHmg2dK0ebjci1pI76DXn9d/OdkNa9sGdNOOrbOXGC1wciC1lRTus1sNIT40ZJwIHjU0VrkcE1IPu93D2f063wMbkB4ukWTU1uJAbUvr6+kAvpP44PhyllDdWfJcGVkbauepJngCehS7Mw/MgsNtnvg5GLrcumiBjwuFPgqUopq3dHAjwG6Mw/xzPStEeF8OkWCG6vNWhuP/TRmOMPJQM8x8zkrbVGWqzyNHYQ6oQELGbrFWTgKhGJDGh5LWLi5ofFbtEzC6sxej/WwZICQ6P7zsSMiNXpjAFO0nXkE/jX18DoyyTOniXgJDtb78B0ah281raNsV5DTU9xMXCR9QAl1HExbL82WT8rKr7ou7Tx3H+gASOvgqt3E8Y7azHyyge7baDUrbi8A+nXpAsdiC57IWHX8PN/ATxkB3dkoNyCrEA0Bj5a0ZUMN5ADAfsFokLgQXb+j3JxKrjnB9nvBpFTpLmjnM77ZzhG2fH+X/5t+SnAAE+HjvApIyOGAAAAAElFTkSuQmCC"; return Utilities.base64Decode(catPngBase64); } ================================================ FILE: gmail/markup/Code.gs ================================================ // [START gmail_send_email_with_markup] /** * Send an email with schemas in order to test email markup. */ function testSchemas() { try { const htmlBody = HtmlService.createHtmlOutputFromFile("mail_template").getContent(); MailApp.sendEmail({ to: Session.getActiveUser().getEmail(), subject: `Test Email markup - ${new Date()}`, htmlBody: htmlBody, }); } catch (err) { console.log(err.message); } } // [END gmail_send_email_with_markup] ================================================ FILE: gmail/markup/mail_template.html ================================================

This a test for a Go-To action in Gmail.

================================================ FILE: gmail/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START gmail_quickstart] /** * Lists all labels in the user's mailbox * @see https://developers.google.com/gmail/api/reference/rest/v1/users.labels/list */ function listLabels() { try { // Gmail.Users.Labels.list() API returns the list of all Labels in user's mailbox const response = Gmail.Users.Labels.list("me"); if (!response || response.labels.length === 0) { // TODO (developer) - No labels are returned from the response console.log("No labels found."); return; } // Print the Labels that are available. console.log("Labels:"); for (const label of response.labels) { console.log("- %s", label.name); } } catch (err) { // TODO (developer) - Handle exception on Labels.list() API console.log("Labels.list() API failed with error %s", err.toString()); } } // [END gmail_quickstart] ================================================ FILE: gmail/sendingEmails/sendingEmails.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START gmail_send_emails] /** * Sends emails with data from the current spreadsheet. */ function sendEmails() { try { const sheet = SpreadsheetApp.getActiveSheet(); // Get the active sheet in spreadsheet const startRow = 2; // First row of data to process const numRows = 2; // Number of rows to process const dataRange = sheet.getRange(startRow, 1, numRows, 2); // Fetch the range of cells A2:B3 const data = dataRange.getValues(); // Fetch values for each row in the Range. for (const row of data) { const emailAddress = row[0]; // First column const message = row[1]; // Second column const subject = "Sending emails from a Spreadsheet"; MailApp.sendEmail(emailAddress, subject, message); // Send emails to emailAddresses which are presents in First column } } catch (err) { console.log(err); } } // [END gmail_send_emails] // [START gmail_send_non_duplicate_emails] /** * Sends non-duplicate emails with data from the current spreadsheet. */ function sendNonDuplicateEmails() { const EMAIL_SENT = "email sent"; //This constant is used to write the message in Column C of Sheet try { const sheet = SpreadsheetApp.getActiveSheet(); // Get the active sheet in spreadsheet const startRow = 2; // First row of data to process const numRows = 2; // Number of rows to process const dataRange = sheet.getRange(startRow, 1, numRows, 3); // Fetch the range of cells A2:B3 const data = dataRange.getValues(); // Fetch values for each row in the Range. for (let i = 0; i < data.length; ++i) { const row = data[i]; const emailAddress = row[0]; // First column const message = row[1]; // Second column const emailSent = row[2]; // Third column if (emailSent === EMAIL_SENT) { console.log("Email already sent"); return; } const subject = "Sending emails from a Spreadsheet"; MailApp.sendEmail(emailAddress, subject, message); // Send emails to emailAddresses which are presents in First column sheet.getRange(startRow + i, 3).setValue(EMAIL_SENT); SpreadsheetApp.flush(); // Make sure the cell is updated right away in case the script is interrupted } } catch (err) { console.log(err); } } // [END gmail_send_non_duplicate_emails] ================================================ FILE: gmail-sentiment-analysis/.clasp.json ================================================ { "scriptId": "1Z2gfvr0oYn68ppDtQbv0qIuKKVWhvwOTr-gCE0GFKVjNk8NDlpfJAGAr" } ================================================ FILE: gmail-sentiment-analysis/Cards.gs ================================================ /* Copyright 2024-2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Builds the main card displayed on the Gmail homepage. * * @returns {Card} - The homepage card. */ function buildHomepageCard() { // Create a new card builder const cardBuilder = CardService.newCardBuilder(); // Create a card header const cardHeader = CardService.newCardHeader(); cardHeader.setImageUrl( "https://fonts.gstatic.com/s/i/googlematerialicons/mail/v6/black-24dp/1x/gm_mail_black_24dp.png", ); cardHeader.setImageStyle(CardService.ImageStyle.CIRCLE); cardHeader.setTitle("Analyze your Gmail"); // Add the header to the card cardBuilder.setHeader(cardHeader); // Create a card section const cardSection = CardService.newCardSection(); // Create buttons for generating sample emails and analyzing sentiment const buttonSet = CardService.newButtonSet(); // Create "Generate sample emails" button const generateButton = createFilledButton( "Generate sample emails", "generateSampleEmails", "#34A853", ); buttonSet.addButton(generateButton); // Create "Analyze emails" button const analyzeButton = createFilledButton( "Analyze emails", "analyzeSentiment", "#FF0000", ); buttonSet.addButton(analyzeButton); // Add the button set to the section cardSection.addWidget(buttonSet); // Add the section to the card cardBuilder.addSection(cardSection); // Build and return the card return cardBuilder.build(); } /** * Creates a filled text button with the specified text, function, and color. * * @param {string} text - The text to display on the button. * @param {string} functionName - The name of the function to call when the button is clicked. * @param {string} color - The background color of the button. * @returns {TextButton} - The created text button. */ function createFilledButton(text, functionName, color) { // Create a new text button const textButton = CardService.newTextButton(); // Set the button text textButton.setText(text); // Set the action to perform when the button is clicked const action = CardService.newAction(); action.setFunctionName(functionName); textButton.setOnClickAction(action); // Set the button style to filled textButton.setTextButtonStyle(CardService.TextButtonStyle.FILLED); // Set the background color textButton.setBackgroundColor(color); return textButton; } /** * Creates a notification response with the specified text. * * @param {string} notificationText - The text to display in the notification. * @returns {ActionResponse} - The created action response. */ function buildNotificationResponse(notificationText) { // Create a new notification const notification = CardService.newNotification(); notification.setText(notificationText); // Create a new action response builder const actionResponseBuilder = CardService.newActionResponseBuilder(); // Set the notification for the action response actionResponseBuilder.setNotification(notification); // Build and return the action response return actionResponseBuilder.build(); } ================================================ FILE: gmail-sentiment-analysis/Code.gs ================================================ /* Copyright 2024 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Triggered when the add-on is opened from the Gmail homepage. * * @param {Object} e - The event object. * @returns {Card} - The homepage card. */ function onHomepageTrigger(e) { return buildHomepageCard(); } ================================================ FILE: gmail-sentiment-analysis/Gmail.gs ================================================ /* Copyright 2024-2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Analyzes the sentiment of the first 10 threads in the inbox * and labels them accordingly. * * @returns {ActionResponse} - A notification confirming completion. */ function analyzeSentiment() { // Analyze and label emails analyzeAndLabelEmailSentiment(); // Return a notification return buildNotificationResponse("Successfully completed sentiment analysis"); } /** * Analyzes the sentiment of emails and applies appropriate labels. */ function analyzeAndLabelEmailSentiment() { // Define label names const labelNames = ["HAPPY TONE 😊", "NEUTRAL TONE 😐", "UPSET TONE 😡"]; // Get or create labels for each sentiment const positiveLabel = GmailApp.getUserLabelByName(labelNames[0]) || GmailApp.createLabel(labelNames[0]); const neutralLabel = GmailApp.getUserLabelByName(labelNames[1]) || GmailApp.createLabel(labelNames[1]); const negativeLabel = GmailApp.getUserLabelByName(labelNames[2]) || GmailApp.createLabel(labelNames[2]); // Get the first 10 threads in the inbox const threads = GmailApp.getInboxThreads(0, 10); // Iterate through each thread for (const thread of threads) { // Iterate through each message in the thread const messages = thread.getMessages(); for (const message of messages) { // Get the plain text body of the message const emailBody = message.getPlainBody(); // Analyze the sentiment of the email body const sentiment = processSentiment(emailBody); // Apply the appropriate label based on the sentiment if (sentiment === "positive") { thread.addLabel(positiveLabel); } else if (sentiment === "neutral") { thread.addLabel(neutralLabel); } else if (sentiment === "negative") { thread.addLabel(negativeLabel); } } } } /** * Generates sample emails for testing the sentiment analysis. * * @returns {ActionResponse} - A notification confirming email generation. */ function generateSampleEmails() { // Get the current user's email address const userEmail = Session.getActiveUser().getEmail(); // Define sample emails const sampleEmails = [ { subject: "Thank you for amazing service!", body: "Hi, I really enjoyed working with you. Thank you again!", name: "Customer A", }, { subject: "Request for information", body: "Hello, I need more information on your recent product launch. Thank you.", name: "Customer B", }, { subject: "Complaint!", body: "", htmlBody: `

Hello, You are late in delivery, again.

Please contact me ASAP before I cancel our subscription.

`, name: "Customer C", }, ]; // Send each sample email for (const email of sampleEmails) { GmailApp.sendEmail(userEmail, email.subject, email.body, { name: email.name, htmlBody: email.htmlBody, }); } // Return a notification return buildNotificationResponse("Successfully generated sample emails"); } ================================================ FILE: gmail-sentiment-analysis/README.md ================================================ # Gmail Sentiment Analysis with Gemini and Vertex AI This project guides you through building a Google Workspace Add-on that leverages Gemini and Vertex AI for conducting sentiment analysis on emails in Gmail. The add-on automatically identifies emails with different tones and labels them accordingly, helping prioritize customer service responses or identify potentially sensitive emails. > [!NOTE] You can also run this lab on [https://www.cloudskillsboost.google/catalog_lab/31942](Cloud Skills Boost). ## What you'll learn * Build a Google Workspace Add-on * Integrate Vertex AI with Google Workspace * Implement OAuth2 authentication * Apply sentiment analysis * Utilize Apps Script ## Setup and Requirements * **Web Browser:** Chrome (recommended) * **Dedicated Time:** Set aside uninterrupted time. * **Incognito/Private Window:** **Important:** Use an incognito or private browsing window to prevent conflicts with your personal accounts. ## Steps ### Setup Google Cloud Platform 1. Create a new project. 2. Associate a billing account with the project. 3. Enable the Vertex AI API. ### Setup an Apps Script Project 1. Navigate to [https://script.google.com](Apps Script homepage). 2. Click **New project**. 3. Rename the project to "Gmail Sentiment Analysis with Gemini and Vertex AI". 4. In Project Settings (gear icon), select "Show 'appsscript.json' manifest file in editor". 5. In Project Settings, under Google Cloud Platform (GCP) Project, click **Change project**. 6. Copy the **Project number** (numerical value, not Project ID) from Cloud Console. 7. Paste the Project number into the Apps Script project settings and click **Set project**. 8. Click the **OAuth Consent details** link in the error message. 9. Click **Configure Consent Screen**. 10. Click on **Get started** and follow the prompts to configure the consent screen as follows: 1. Set the App name to "Gmail Sentiment Analysis with Gemini and Vertex AI". 2. Set the User support email to your email. 3. Select **Internal** for Audience. 4. Set the email address under Contact Information to your email. 5. Review and agree to the "Google API Services: User Data Policy". 6. Click on **Create**. 11. Return to the Apps Script tab and set the project number again. You should not get an error this time. ### Populate the Apps Script project with code 1. Replace the content of `appsscript.json` and `Code.gs` with the code from this repo. 2. Create new files (`Cards`, `Gmail`, `Vertex`) and replace the contect with the relevant code from this repo. 3. Open the `Vertex.gs` file and replace the `PROJECT_ID` value with your Google Cloud project ID. 4. Make sure to save the content before proceeding. ### Deploy the Add-on 1. On the Apps Script screen, click **Deploy > Test deployments**. 2. Confirm **Gmail** is listed under Application(s) and click **Install**. 3. Click **Done**. ### Verify Installation Open [https://mail.google.com/](Gmail) and expand the right side panel. You should see a new add-on icon in the right side panel. **Troubleshooting:** * Refresh the browser if the add-on isn't visible. * Uninstall and reinstall the add-on from the Test deployments window if it's still missing. ### Run the Add-on 1. **Open the Add-on:** Click the add-on icon in the Gmail side panel. 2. **Authorize the Add-on:** Grant the necessary permissions for the add-on to access your inbox and connect with Vertex AI. 3. **Generate sample emails:** Click the green "Generate sample emails" button. 4. **Wait for emails:** Wait for the sample emails to appear in your inbox, or refresh your inbox. 5. **Start the analysis:** Click the red "Analyze emails" button. 6. **Wait for labels:** Wait for the "UPSET TONE 😡" label to appear on negative emails, or refresh. 7. **Close the Add-on:** Click the X in the top right corner of the side panel. ## Congratulations! You've completed the Gmail Sentiment Analysis with Gemini and Vertex AI lab! You now have a functional Gmail add-on for prioritizing emails. Experiment further by customizing the sentiment analysis or adding new features! ================================================ FILE: gmail-sentiment-analysis/Vertex.gs ================================================ /* Copyright 2024-2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Replace with your project ID const PROJECT_ID = "[ADD YOUR GCP PROJECT ID HERE]"; // Location for your Vertex AI model const VERTEX_AI_LOCATION = "us-central1"; // Model ID to use for sentiment analysis const MODEL_ID = "gemini-2.5-flash"; /** * Sends the email text to Vertex AI for sentiment analysis. * * @param {string} emailText - The text of the email to analyze. * @returns {string} - The sentiment of the email ('positive', 'negative', or 'neutral'). */ function processSentiment(emailText) { // Construct the API endpoint URL const apiUrl = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent`; // Prepare the request payload const payload = { contents: [ { role: "user", parts: [ { text: `Analyze the sentiment of the following message: ${emailText}`, }, ], }, ], generationConfig: { temperature: 0.9, maxOutputTokens: 1024, responseMimeType: "application/json", // Expected response format for simpler parsing. responseSchema: { type: "object", properties: { response: { type: "string", enum: ["positive", "negative", "neutral"], }, }, }, }, }; // Prepare the request options const options = { method: "POST", headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, }, contentType: "application/json", muteHttpExceptions: true, // Set to true to inspect the error response payload: JSON.stringify(payload), }; // Make the API request const response = UrlFetchApp.fetch(apiUrl, options); // Parse the response. There are two levels of JSON responses to parse. const parsedResponse = JSON.parse(response.getContentText()); const sentimentResponse = JSON.parse( parsedResponse.candidates[0].content.parts[0].text, ).response; // Return the sentiment return sentimentResponse; } ================================================ FILE: gmail-sentiment-analysis/appsscript.json ================================================ { "timeZone": "America/Toronto", "oauthScopes": [ "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/gmail.labels", "https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/userinfo.email" ], "addOns": { "common": { "name": "Sentiment Analysis", "logoUrl": "https://fonts.gstatic.com/s/i/googlematerialicons/sentiment_extremely_dissatisfied/v6/black-24dp/1x/gm_sentiment_extremely_dissatisfied_black_24dp.png" }, "gmail": { "homepageTrigger": { "runFunction": "onHomepageTrigger", "enabled": true } } }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: mashups/sheets2calendar.gs ================================================ /** * Create a new calendar event for every row in a spreadsheet. This code assumes * that the data is in the first sheet (workbook) in the spreadsheet and has the * columns "Title", "Description", and "Emails" in that order, with multiple * email addresses separated by a comma. */ function createEventsFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; /** @type {string[][]} */ const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Create an event for each row. for (const row of data) { const title = row[0]; const description = row[1]; const emailsStr = row[2]; // Split the emails into an array and remove extra whitespace. const emails = emailsStr.split(",").map((email) => email.trim()); const now = new Date(); // Start the event at the next hour mark. const start = new Date(now); start.setHours(start.getHours() + 1); start.setMinutes(0); start.setSeconds(0); start.setMilliseconds(0); // End the event after 30 minutes. const end = new Date(start); end.setMinutes(end.getMinutes() + 30); // Create the calendar event and invite the guests. const event = CalendarApp.createEvent(title, start, end).setDescription( description, ); for (const email of emails) { event.addGuest(email); } // Add yourself as a guest and mark yourself as attending. event.addGuest(Session.getActiveUser().getEmail()); event.setMyStatus(CalendarApp.GuestStatus.YES); } } ================================================ FILE: mashups/sheets2chat.gs ================================================ /** * @typedef {Object} SheetEditEvent * @property {string} oldValue The old value of the cell. * @property {string} value The new value of the cell. */ /** * Posts a message to a Hangouts Chat room every time the spreadsheet is edited. * This script must be attached to the spreadsheet (created in Google Sheets under * "Tools > Script editor") and installed as a trigger: * - Click "Edit > Current project's triggers" in the Apps Script UI. * - Click "Add a new trigger". * - Select the function "sendChatMessageOnEdit" and the event * "From spreadsheet", "On edit". * - Click "Save". * * @param {SheetEditEvent} e The onEdit event object. */ function sendChatMessageOnEdit(e) { const range = SpreadsheetApp.getActiveRange(); const value = range.getValue(); const oldValue = e.oldValue; const ss = range.getSheet().getParent(); // Construct the message to send, based on the old and new value of the cell. let changeMessage; if (oldValue && value) { changeMessage = Utilities.formatString( 'changed from "%s" to "%s"', oldValue, value, ); } else if (value) { changeMessage = Utilities.formatString('set to "%s"', value); } else { changeMessage = "cleared"; } const message = Utilities.formatString( "The range %s was %s. <%s|Open spreadsheet>.", range.getA1Notation(), changeMessage, ss.getUrl(), ); // Follow these steps to create an incomming webhook URL for your chat room: // https://developers.google.com/hangouts/chat/how-tos/webhooks#define_an_incoming_webhook const webhookUrl = "ENTER INCOMMING WEBHOOK URL HERE"; // Use the spreadsheet's ID as a thread key, so that all messages go into the // same thread. const url = `${webhookUrl}&threadKey=${ss.getId()}`; // Send the message. UrlFetchApp.fetch(url, { method: "post", contentType: "application/json", payload: JSON.stringify({ text: message, }), }); } ================================================ FILE: mashups/sheets2contacts.gs ================================================ /** * Create a new contact for every row in a spreadsheet. This code assumes that * the data is in the first sheet (workbook) in the spreadsheet and has the * columns "First Name", "Last Name", and "Email" in that order. */ function createContactsFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Send a contact for each row. for (const row of data) { const firstName = row[0]; const lastName = row[1]; const email = row[2]; ContactsApp.createContact(firstName, lastName, email); } } ================================================ FILE: mashups/sheets2docs.gs ================================================ /** * Create a new document for every row in a spreadsheet. This code assumes that * the data is in the first sheet (workbook) in the spreadsheet and has the * columns "Title", "Content", and "Emails" in that order, with multiple email * addresses separated by a comma. */ function createDocsFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; /** @type {string[][]} */ const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Create a document for each row. for (const row of data) { const title = row[0]; const content = row[1]; const emailsStr = row[2]; // Split the emails into an array and remove extra whitespace. const emails = emailsStr.split(",").map((email) => email.trim()); // Create the document, append the content, and share it out. const doc = DocumentApp.create(title); doc.getBody().appendParagraph(content); doc.addEditors(emails); } } ================================================ FILE: mashups/sheets2drive.gs ================================================ /** * Create a PDF file in Google Drive for every row in a spreadsheet. This * code assumes that the data is in the first sheet (workbook) in the * spreadsheet and has the columns "File Name", "HTML Content", and "Emails" in that * order, with multiple email addresses separated by a comma. */ function createDriveFilesFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; /** @type {string[][]} */ const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Create a PDF in Google Drive for each row. for (const row of data) { const fileName = row[0]; const htmlContent = row[1]; const emailsStr = row[2]; // Split the emails into an array and remove extra whitespace. const emails = emailsStr.split(",").map((email) => email.trim()); // Convert the HTML content to PDF. const html = Utilities.newBlob(htmlContent, "text/html"); const pdf = html.getAs("application/pdf"); // Create the Drive file and share it out. const file = DriveApp.createFile(pdf).setName(fileName); file.addEditors(emails); } } ================================================ FILE: mashups/sheets2forms.gs ================================================ /** * Create a new form for every row in a spreadsheet. This code assumes that the * data is in the first sheet (workbook) in the spreadsheet and has the * columns "Title", "Question", and "Emails" in that order, with multiple email * addresses separated by a comma. */ function createFormsFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; /** @type {string[][]} */ const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Create a form for each row. for (const row of data) { const title = row[0]; const question = row[1]; const emailsStr = row[2]; // Split the emails into an array and remove extra whitespace. const emails = emailsStr.split(",").map((email) => email.trim()); // Create the form, append the question, and share it out. const form = FormApp.create(title); form.addTextItem().setTitle(question); form.addEditors(emails); } } ================================================ FILE: mashups/sheets2gmail.gs ================================================ /** * Sends an email for every row in a spreadsheet. This code assumes that the * data is in the first sheet (workbook) in the spreadsheet and has the columns * "Subject", "HTML Message", and "Emails" in that order, with multiple email * addresses separated by a comma. */ function sendEmailsFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; /** @type {string[][]} */ const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Send an email for each row. for (const row of data) { const subject = row[0]; const htmlMessage = row[1]; const emails = row[2]; // Send the email. GmailApp.sendEmail(emails, subject, "", { htmlBody: htmlMessage, }); } } ================================================ FILE: mashups/sheets2maps.gs ================================================ /** * A custom function that gets the county (or equivalent administrative * district) that an address lies within. Use within a cell like: * * =COUNTY("76 9th Ave, New York NY") * * This script must be attached to the spreadsheet (created in Google Sheets * under "Tools > Script editor"). * * @param {String} address The address to lookup. * @return {String} The county (or equivalent) the address is within. * @customFunction */ function COUNTY(address) { const results = Maps.newGeocoder().geocode(address).results; if (!results || results.length === 0) { throw new Error("Unknown address"); } /** @type {{long_name: string, types: string[]}[]} */ const addressComponents = results[0].address_components; const counties = addressComponents.filter( (component) => component.types.indexOf("administrative_area_level_2") >= 0, ); if (!counties.length) { throw new Error("Unable to determine county"); } return counties[0].long_name; } ================================================ FILE: mashups/sheets2slides.gs ================================================ /** * Create a new presentation for every row in a spreadsheet. This code assumes * that the data is in the first sheet (workbook) in the spreadsheet and has the * columns "Title", "Content", and "Emails" in that order, with multiple email * addresses separated by a comma. */ function createPresentationsFromSpreadsheet() { // Open the spreadsheet and get the data. const ss = SpreadsheetApp.openByUrl("ENTER SPREADSHEET URL HERE"); const sheet = ss.getSheets()[0]; /** @type {string[][]} */ const data = sheet.getDataRange().getValues(); // Remove any frozen rows from the data, since they contain headers. data.splice(sheet.getFrozenRows()); // Create a presentation for each row. for (const row of data) { const title = row[0]; const content = row[1]; const emailsStr = row[2]; // Split the emails into an array and remove extra whitespace. const emails = emailsStr.split(",").map((email) => email.trim()); // Create the presentation, insert a new slide at the start, append the content, // and share it out. const presentation = SlidesApp.create(title); const slide = presentation.insertSlide( 0, SlidesApp.PredefinedLayout.MAIN_POINT, ); const textBox = slide.getShapes()[0]; textBox.getText().appendParagraph(content); presentation.addEditors(emails); } } ================================================ FILE: mashups/sheets2translate.gs ================================================ /** * Whenever a cell is edited and it's value is a string, add a note to the cell * with the English translation of the cell's content. * * For example, type "la gato" into a cell and this script will add a note * with the text "the cat". * * This script must be attached to the spreadsheet (created in Google Sheets * under "Tools > Script editor"). */ function onEdit() { const range = SpreadsheetApp.getActiveRange(); const value = range.getValue(); if (typeof value === "string") { const translated = LanguageApp.translate(value, "", "en"); range.setNote(translated); } } ================================================ FILE: package.json ================================================ { "name": "googleworkspace-apps-script-samples", "version": "1.0.0", "description": "Apps Script samples for [Google Workspace](https://developers.google.com/apps-script/) docs.", "license": "MIT", "private": true, "keywords": [ "Google Workspace", "Apps Script", "Calendar", "Drive", "Sheets", "Slides", "API" ], "devDependencies": { "@biomejs/biome": "1.9.4", "@types/google-apps-script": "^2.0.8", "@types/node": "^24.10.1", "tsx": "^4.20.6", "typescript": "^5.9.3" }, "scripts": { "lint": "tsx .github/scripts/biome-gs.ts lint", "format": "tsx .github/scripts/biome-gs.ts format", "check": "tsx .github/scripts/check-gs.ts" }, "type": "module", "packageManager": "pnpm@10.15.1", "engines": { "node": ">=20" } } ================================================ FILE: people/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START people_quickstart] /** * @typedef {Object} EmailAddress * @see https://developers.google.com/people/api/rest/v1/people#Person * @property {string} value * Note: This is a partial definition. */ /** * @typedef {Object} Name * @see https://developers.google.com/people/api/rest/v1/people#Person * @property {string} displayName * Note: This is a partial definition. */ /** * @typedef {Object} Person * @see https://developers.google.com/people/api/rest/v1/people#Person * @property {Name[]} names * @property {EmailAddress[]} [emailAddresses] * Note: This is a partial definition. */ /** * @typedef {Object} Connection * @see https://developers.google.com/people/api/rest/v1/people.connections/list * @property {Person[]} connections * Note: This is a partial definition. */ /** * Print the display name if available for 10 connections. */ function listConnectionNames() { // Use the People API to list the connections of the logged in user. // See: https://developers.google.com/people/api/rest/v1/people.connections/list if (!People || !People.People || !People.People.Connections) { throw new Error("People service not enabled."); } const connections = People.People.Connections.list("people/me", { pageSize: 10, personFields: "names,emailAddresses", }); if (!connections.connections) { console.log("No connections found."); return; } for (const person of connections.connections) { if ( person.names && person.names.length > 0 && person.names[0].displayName ) { console.log(person.names[0].displayName); } else { console.log("No display name found for connection."); } } } // [END people_quickstart] ================================================ FILE: picker/README.md ================================================ # File Picker Sample This sample shows how to create a "file-open" dialog in Google Sheets thatallows the user to select a file from their Drive. It does so by loading [Google Picker](https://developers.google.com/picker/), for this purpose. More information is available in the Apps Script guide [Dialogs and Sidebars in Google Workspace Documents](https://developers.google.com/apps-script/guides/dialogs#file-open_dialogs). Note that this sample expects to be [bound](https://developers.google.com/apps-script/guides/bound) to a spreadsheet. ================================================ FILE: picker/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/script.container.ui", "https://www.googleapis.com/auth/drive.file" ], "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v3", "serviceId": "drive" } ] } } ================================================ FILE: picker/code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START picker_code] /** * Creates a custom menu in Google Sheets when the spreadsheet opens. */ function onOpen() { SpreadsheetApp.getUi() .createMenu("Picker") .addItem("Start", "showPicker") .addToUi(); } /** * Displays an HTML-service dialog in Google Sheets that contains client-side * JavaScript code for the Google Picker API. */ function showPicker() { const html = HtmlService.createHtmlOutputFromFile("dialog.html") .setWidth(800) .setHeight(600) .setSandboxMode(HtmlService.SandboxMode.IFRAME); SpreadsheetApp.getUi().showModalDialog(html, "Select a file"); } // Ensure the Drive API is enabled. if (!Drive) { throw new Error("Please enable the Drive advanced service."); } /** * Checks that the file can be accessed. * @param {string} fileId The ID of the file. * @return {Object} The file resource. */ function getFile(fileId) { return Drive.Files.get(fileId, { fields: "*" }); } /** * Gets the user's OAuth 2.0 access token so that it can be passed to Picker. * This technique keeps Picker from needing to show its own authorization * dialog, but is only possible if the OAuth scope that Picker needs is * available in Apps Script. In this case, the function includes an unused call * to a DriveApp method to ensure that Apps Script requests access to all files * in the user's Drive. * * @return {string} The user's OAuth 2.0 access token. */ function getOAuthToken() { return ScriptApp.getOAuthToken(); } // [END picker_code] ================================================ FILE: picker/dialog.html ================================================
================================================ FILE: pnpm-workspace.yaml ================================================ # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. onlyBuiltDependencies: - '@biomejs/biome' - esbuild ================================================ FILE: service/jdbc.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Replace the variables in this block with real values. * You can find the "Instance connection name" in the Google Cloud * Platform Console, on the instance Overview page. */ const connectionName = "Instance_connection_name"; const rootPwd = "root_password"; const user = "user_name"; const userPwd = "user_password"; const db = "database_name"; const root = "root"; const instanceUrl = `jdbc:google:mysql://${connectionName}`; const dbUrl = `${instanceUrl}/${db}`; // [START apps_script_jdbc_create] /** * Create a new database within a Cloud SQL instance. */ function createDatabase() { try { const conn = Jdbc.getCloudSqlConnection(instanceUrl, root, rootPwd); conn.createStatement().execute(`CREATE DATABASE ${db}`); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } /** * Create a new user for your database with full privileges. */ function createUser() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, root, rootPwd); const stmt = conn.prepareStatement("CREATE USER ? IDENTIFIED BY ?"); stmt.setString(1, user); stmt.setString(2, userPwd); stmt.execute(); conn.createStatement().execute(`GRANT ALL ON \`%\`.* TO ${user}`); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } /** * Create a new table in the database. */ function createTable() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, user, userPwd); conn .createStatement() .execute( "CREATE TABLE entries " + "(guestName VARCHAR(255), content VARCHAR(255), " + "entryID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(entryID));", ); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } // [END apps_script_jdbc_create] // [START apps_script_jdbc_write] /** * Write one row of data to a table. */ function writeOneRecord() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, user, userPwd); const stmt = conn.prepareStatement( "INSERT INTO entries " + "(guestName, content) values (?, ?)", ); stmt.setString(1, "First Guest"); stmt.setString(2, "Hello, world"); stmt.execute(); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } /** * Write 500 rows of data to a table in a single batch. */ function writeManyRecords() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, user, userPwd); conn.setAutoCommit(false); const start = new Date(); const stmt = conn.prepareStatement( "INSERT INTO entries " + "(guestName, content) values (?, ?)", ); for (let i = 0; i < 500; i++) { stmt.setString(1, `Name ${i}`); stmt.setString(2, `Hello, world ${i}`); stmt.addBatch(); } const batch = stmt.executeBatch(); conn.commit(); conn.close(); const end = new Date(); console.log("Time elapsed: %sms for %s rows.", end - start, batch.length); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } /** * Write 500 rows of data to a table in a single batch. * Recommended for faster writes */ function writeManyRecordsUsingExecuteBatch() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, user, userPwd); conn.setAutoCommit(false); const start = new Date(); const stmt = conn.prepareStatement( "INSERT INTO entries " + "(guestName, content) values (?, ?)", ); const params = []; for (let i = 0; i < 500; i++) { params.push([`Name ${i}`, `Hello, world ${i}`]); } const batch = stmt.executeBatch(params); conn.commit(); conn.close(); const end = new Date(); console.log("Time elapsed: %sms for %s rows.", end - start, batch.length); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } // [END apps_script_jdbc_write] // [START apps_script_jdbc_read] /** * Read up to 1000 rows of data from the table and log them. */ function readFromTable() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, user, userPwd); const start = new Date(); const stmt = conn.createStatement(); stmt.setMaxRows(1000); const results = stmt.executeQuery("SELECT * FROM entries"); const numCols = results.getMetaData().getColumnCount(); while (results.next()) { let rowString = ""; for (let col = 0; col < numCols; col++) { rowString += `${results.getString(col + 1)}\t`; } console.log(rowString); } results.close(); stmt.close(); const end = new Date(); console.log("Time elapsed: %sms", end - start); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } /** * Read up to 1000 rows of data from the table and log them. * Recommended for faster reads */ function readFromTableUsingGetRows() { try { const conn = Jdbc.getCloudSqlConnection(dbUrl, user, userPwd); const start = new Date(); const stmt = conn.createStatement(); stmt.setMaxRows(1000); const results = stmt.executeQuery("SELECT * FROM entries"); const numCols = results.getMetaData().getColumnCount(); const getRowArgs = []; for (let col = 0; col < numCols; col++) { getRowArgs.push(`getString(${col + 1})`); } const rows = results.getRows(getRowArgs.join(",")); for (let i = 0; i < rows.length; i++) { console.log(rows[i].join("\t")); } results.close(); stmt.close(); const end = new Date(); console.log("Time elapsed: %sms", end - start); } catch (err) { // TODO(developer) - Handle exception from the API console.log("Failed with an error %s", err.message); } } // [END apps_script_jdbc_read] ================================================ FILE: service/propertyService.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // @see- https://developers.google.com/apps-script/guides/properties /** * Save or set the property in each three property store. */ function saveSingleProperty() { // [START apps_script_property_service_save_data_single_value] try { // Set a property in each of the three property stores. const scriptProperties = PropertiesService.getScriptProperties(); const userProperties = PropertiesService.getUserProperties(); const documentProperties = PropertiesService.getDocumentProperties(); scriptProperties.setProperty("SERVER_URL", "http://www.example.com/"); userProperties.setProperty("DISPLAY_UNITS", "metric"); documentProperties.setProperty( "SOURCE_DATA_ID", "1j3GgabZvXUF177W0Zs_2v--H6SPCQb4pmZ6HsTZYT5k", ); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_save_data_single_value] } /** * Save the multiple script properties. */ function saveMultipleProperties() { // [START apps_script_property_service_save_data_multiple_value] try { // Set multiple script properties in one call. const scriptProperties = PropertiesService.getScriptProperties(); scriptProperties.setProperties({ cow: "moo", sheep: "baa", chicken: "cluck", }); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_save_data_multiple_value] } /** * Read single value for user property. */ function readSingleProperty() { // [START apps_script_property_service_read_data_single_value] try { // Get the value for the user property 'DISPLAY_UNITS'. const userProperties = PropertiesService.getUserProperties(); const units = userProperties.getProperty("DISPLAY_UNITS"); console.log("values of units %s", units); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_read_data_single_value] } /** * Read the multiple script properties. */ function readAllProperties() { // [START apps_script_property_service_read_multiple_data_value] try { // Get multiple script properties in one call, then log them all. const scriptProperties = PropertiesService.getScriptProperties(); const data = scriptProperties.getProperties(); for (const key in data) { console.log("Key: %s, Value: %s", key, data[key]); } } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_read_multiple_data_value] } /** * Update the user property value. */ function updateProperty() { // [START apps_script_property_service_modify_data] try { // Change the unit type in the user property 'DISPLAY_UNITS'. const userProperties = PropertiesService.getUserProperties(); let units = userProperties.getProperty("DISPLAY_UNITS"); units = "imperial"; // Only changes local value, not stored value. userProperties.setProperty("DISPLAY_UNITS", units); // Updates stored value. } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_modify_data] } /** * Delete the single user property. */ function deleteSingleProperty() { // [START apps_script_property_service_delete_data_single_value] try { // Delete the user property 'DISPLAY_UNITS'. const userProperties = PropertiesService.getUserProperties(); userProperties.deleteProperty("DISPLAY_UNITS"); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_delete_data_single_value] } /** * Delete all user properties in the current script. */ function deleteAllUserProperties() { // [START apps_script_property_service_delete_all_data] try { // Get user properties in the current script. const userProperties = PropertiesService.getUserProperties(); // Delete all user properties in the current script. userProperties.deleteAllProperties(); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } // [END apps_script_property_service_delete_all_data] } ================================================ FILE: service/test_jdbc.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests createDatabase function of jdbc.gs */ function itShouldCreateDatabase() { console.log("itShouldCreateDatabase"); createDatabase(); } /** * Tests createUser function of jdbc.gs */ function itShouldCreateUser() { console.log("itShouldCreateUser"); createUser(); } /** * Tests createTable function of jdbc.gs */ function itShouldCreateTable() { console.log("itShouldCreateTable"); createTable(); } /** * Tests writeOneRecord function of jdbc.gs */ function itShouldWriteOneRecord() { console.log("itShouldWriteOneRecord"); writeOneRecord(); } /** * Tests writeManyRecords function of jdbc.gs */ function itShouldWriteManyRecords() { console.log("itShouldWriteManyRecords"); writeManyRecords(); } /** * Tests writeManyRecordsUsingExecuteBatch function of jdbc.gs */ function itShouldWriteManyRecordsUsingExecuteBatch() { console.log("itShouldWriteManyRecordsUsingExecuteBatch"); writeManyRecordsUsingExecuteBatch(); } /** * Tests readFromTable function of jdbc.gs */ function itShouldReadFromTable() { console.log("itShouldReadFromTable"); readFromTable(); } /** * Tests readFromTableUsingGetRows function of jdbc.gs */ function itShouldReadFromTableUsingGetRows() { console.log("itShouldReadFromTableUsingGetRows"); readFromTableUsingGetRows(); } /** * Runs all the tests */ function RUN_ALL_TESTS() { itShouldCreateDatabase(); itShouldCreateUser(); itShouldCreateTable(); itShouldWriteOneRecord(); itShouldWriteManyRecords(); itShouldReadFromTable(); itShouldReadFromTableUsingGetRows(); itShouldWriteManyRecordsUsingExecuteBatch(); } ================================================ FILE: service/test_propertyServices.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Run all tests for propertyService.gs */ function RUN_ALL_TESTS() { console.log("> itShouldSaveSingleProperty"); saveSingleProperty(); console.log("> itShouldSaveMultipleProperties"); saveMultipleProperties(); console.log("> itShouldReadSingleProperty"); readSingleProperty(); console.log("> itShouldReadAllProperties"); readAllProperties(); // The tests below are successful if they run without any extra output console.log("> itShouldUpdateProperty"); updateProperty(); console.log("> itShouldDeleteSingleProperty"); deleteSingleProperty(); console.log("> itShouldDeleteAllUserProperties"); deleteAllUserProperties(); } ================================================ FILE: sheets/README.md ================================================ # Quickstart: Apps Scripts for Google Sheets Sample Google Apps Script add-ons and menus, and custom functions for Google Sheets. ## Date Add and Subtract Date Add and Subtract is a sample add-on for Google Sheets that provides custom functions for date manipulation. ## [Managing Responses for Google Forms](https://developers.google.com/apps-script/quickstart/forms) Create a Google Form based on data in a spreadsheet that emails Google Calendar invites and a personalized Google Doc to everyone who responds. ![Quickstart Forms](https://developers.google.com/apps-script/images/quickstart-forms.png) ## [Menus and Custom Functions](https://developers.google.com/apps-script/quickstart/custom-functions) Create a spreadsheet with custom functions, menu items, and automated procedures. ![Quickstart Custom Functions](https://developers.google.com/apps-script/images/quickstart-custom-functions.png) ## [Bracket Maker](https://developers.google.com/apps-script/articles/bracket_maker) This tutorial shows you how to use the Spreadsheets service to create Tournament Brackets similar to College Basketball's March Madness. You can use this tutorial to easily create your own brackets. ## [Removing Duplicates](https://developers.google.com/apps-script/articles/removing_duplicates) This tutorial shows how to avoid duplicates when you want to automate the process of copying data in Google Workspace and specifically how to remove duplicate rows in spreadsheet data. ================================================ FILE: sheets/api/helpers.gs ================================================ const filesToDelete = []; /** * Helper methods for Google Sheets tests. */ function Helpers() { this.filesToDelete = []; } Helpers.prototype.reset = function () { this.filesToDelete = []; }; Helpers.prototype.deleteFileOnCleanup = function (id) { this.filesToDelete.push(id); }; Helpers.prototype.cleanup = () => { filesToDelete.forEach(Drive.Files.remove); }; Helpers.prototype.createTestSpreadsheet = function () { const spreadsheet = SpreadsheetApp.create("Test Spreadsheet"); for (let i = 0; i < 3; ++i) { spreadsheet.appendRow([1, 2, 3]); } this.deleteFileOnCleanup(spreadsheet.getId()); return spreadsheet.getId(); }; Helpers.prototype.populateValues = (spreadsheetId) => { const batchUpdateRequest = Sheets.newBatchUpdateSpreadsheetRequest(); const repeatCellRequest = Sheets.newRepeatCellRequest(); const values = []; for (let i = 0; i < 10; ++i) { values[i] = []; for (let j = 0; j < 10; ++j) { values[i].push("Hello"); } } const range = "A1:J10"; SpreadsheetApp.openById(spreadsheetId).getRange(range).setValues(values); SpreadsheetApp.flush(); }; ================================================ FILE: sheets/api/spreadsheet_snippets.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Google Sheets API Snippets. */ function Snippets() {} // [START sheets_create] /** * Creates a new sheet using the sheets advanced services * @param {string} title the name of the sheet to be created * @returns {string} the spreadsheet ID */ Snippets.prototype.create = (title) => { // This code uses the Sheets Advanced Service, but for most use cases // the built-in method SpreadsheetApp.create() is more appropriate. try { const sheet = Sheets.newSpreadsheet(); sheet.properties = Sheets.newSpreadsheetProperties(); sheet.properties.title = title; const spreadsheet = Sheets.Spreadsheets.create(sheet); return spreadsheet.spreadsheetId; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_create] // [START sheets_batch_update] /** * Updates the specified sheet using advanced sheet services * @param {string} spreadsheetId id of the spreadsheet to be updated * @param {string} title name of the sheet in the spreadsheet to be updated * @param {string} find string to be replaced * @param {string} replacement the string to replace the old data * @returns {*} the updated spreadsheet */ Snippets.prototype.batchUpdate = (spreadsheetId, title, find, replacement) => { // This code uses the Sheets Advanced Service, but for most use cases // the built-in method SpreadsheetApp.getActiveSpreadsheet() // .getRange(range).setValues(values) is more appropriate. try { // Change the spreadsheet's title. const updateSpreadsheetPropertiesRequest = Sheets.newUpdateSpreadsheetPropertiesRequest(); updateSpreadsheetPropertiesRequest.properties = Sheets.newSpreadsheetProperties(); updateSpreadsheetPropertiesRequest.properties.title = title; updateSpreadsheetPropertiesRequest.fields = "title"; // Find and replace text. const findReplaceRequest = Sheets.newFindReplaceRequest(); findReplaceRequest.find = find; findReplaceRequest.replacement = replacement; findReplaceRequest.allSheets = true; const requests = [Sheets.newRequest(), Sheets.newRequest()]; requests[0].updateSpreadsheetProperties = updateSpreadsheetPropertiesRequest; requests[1].findReplace = findReplaceRequest; const batchUpdateRequest = Sheets.newBatchUpdateSpreadsheetRequest(); batchUpdateRequest.requests = requests; // Add additional requests (operations) const result = Sheets.Spreadsheets.batchUpdate( batchUpdateRequest, spreadsheetId, ); return result; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_batch_update] // [START sheets_get_values] /** * Gets the values of the cells in the specified range * @param {string} spreadsheetId id of the spreadsheet * @param {string} range specifying the start and end cells of the range * @returns {*} Values in the range */ Snippets.prototype.getValues = (spreadsheetId, range) => { // This code uses the Sheets Advanced Service, but for most use cases // the built-in method SpreadsheetApp.getActiveSpreadsheet() // .getRange(range).getValues(values) is more appropriate. try { const result = Sheets.Spreadsheets.Values.get(spreadsheetId, range); const numRows = result.values ? result.values.length : 0; return result; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_get_values] // [START sheets_batch_get_values] /** * Get the values in the specified ranges * @param {string} spreadsheetId spreadsheet's ID * @param {list} _ranges The span of ranges * @returns {*} spreadsheet information and values */ Snippets.prototype.batchGetValues = (spreadsheetId, _ranges) => { // This code uses the Sheets Advanced Service, but for most use cases // the built-in method SpreadsheetApp.getActiveSpreadsheet() // .getRange(range).getValues(values) is more appropriate. let ranges = [ //Range names ... ]; // [START_EXCLUDE silent] ranges = _ranges; // [END_EXCLUDE] try { const result = Sheets.Spreadsheets.Values.batchGet(spreadsheetId, { ranges: ranges, }); return result; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_batch_get_values] // [START sheets_update_values] /** * Updates the values in the specified range * @param {string} spreadsheetId spreadsheet's ID * @param {string} range the range of cells in spreadsheet * @param {string} valueInputOption determines how the input should be interpreted * @see * https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption * @param {list>} _values list of string lists to input * @returns {*} spreadsheet with updated values */ Snippets.prototype.updateValues = ( spreadsheetId, range, valueInputOption, _values, ) => { // This code uses the Sheets Advanced Service, but for most use cases // the built-in method SpreadsheetApp.getActiveSpreadsheet() // .getRange(range).setValues(values) is more appropriate. let values = [ [ // Cell values ... ], // Additional rows ... ]; // [START_EXCLUDE silent] values = _values; // [END_EXCLUDE] try { const valueRange = Sheets.newValueRange(); valueRange.values = values; const result = Sheets.Spreadsheets.Values.update( valueRange, spreadsheetId, range, { valueInputOption: valueInputOption }, ); return result; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_update_values] // [START sheets_batch_update_values] /** * Updates the values in the specified range * @param {string} spreadsheetId spreadsheet's ID * @param {string} range range of cells of the spreadsheet * @param {string} valueInputOption determines how the input should be interpreted * @see * https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption * @param {list>} _values list of string values to input * @returns {*} spreadsheet with updated values */ Snippets.prototype.batchUpdateValues = ( spreadsheetId, range, valueInputOption, _values, ) => { // This code uses the Sheets Advanced Service, but for most use cases // the built-in method SpreadsheetApp.getActiveSpreadsheet() // .getRange(range).setValues(values) is more appropriate. let values = [ [ // Cell values ... ], // Additional rows ... ]; // [START_EXCLUDE silent] values = _values; // [END_EXCLUDE] try { const valueRange = Sheets.newValueRange(); valueRange.range = range; valueRange.values = values; const batchUpdateRequest = Sheets.newBatchUpdateValuesRequest(); batchUpdateRequest.data = valueRange; batchUpdateRequest.valueInputOption = valueInputOption; const result = Sheets.Spreadsheets.Values.batchUpdate( batchUpdateRequest, spreadsheetId, ); return result; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_batch_update_values] // [START sheets_append_values] /** * Appends values to the specified range * @param {string} spreadsheetId spreadsheet's ID * @param {string} range range of cells in the spreadsheet * @param valueInputOption determines how the input should be interpreted * @see * https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption * @param {list} _values list of rows of values to input * @returns {*} spreadsheet with appended values */ Snippets.prototype.appendValues = ( spreadsheetId, range, valueInputOption, _values, ) => { let values = [ [ // Cell values ... ], // Additional rows ... ]; // [START_EXCLUDE silent] values = _values; // [END_EXCLUDE] try { const valueRange = Sheets.newRowData(); valueRange.values = values; const appendRequest = Sheets.newAppendCellsRequest(); appendRequest.sheetId = spreadsheetId; appendRequest.rows = [valueRange]; const result = Sheets.Spreadsheets.Values.append( valueRange, spreadsheetId, range, { valueInputOption: valueInputOption }, ); return result; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_append_values] // [START sheets_pivot_tables] /** * Create pivot table * @param {string} spreadsheetId spreadsheet ID * @returns {*} pivot table's spreadsheet */ Snippets.prototype.pivotTable = (spreadsheetId) => { try { const spreadsheet = SpreadsheetApp.openById(spreadsheetId); // Create two sheets for our pivot table, assume we have one. const sheet = spreadsheet.getSheets()[0]; sheet.copyTo(spreadsheet); const sourceSheetId = spreadsheet.getSheets()[0].getSheetId(); const targetSheetId = spreadsheet.getSheets()[1].getSheetId(); // Create pivot table const pivotTable = Sheets.newPivotTable(); const gridRange = Sheets.newGridRange(); gridRange.sheetId = sourceSheetId; gridRange.startRowIndex = 0; gridRange.startColumnIndex = 0; gridRange.endRowIndex = 20; gridRange.endColumnIndex = 7; pivotTable.source = gridRange; const pivotRows = Sheets.newPivotGroup(); pivotRows.sourceColumnOffset = 1; pivotRows.showTotals = true; pivotRows.sortOrder = "ASCENDING"; pivotTable.rows = pivotRows; const pivotColumns = Sheets.newPivotGroup(); pivotColumns.sourceColumnOffset = 4; pivotColumns.sortOrder = "ASCENDING"; pivotColumns.showTotals = true; pivotTable.columns = pivotColumns; const pivotValue = Sheets.newPivotValue(); pivotValue.summarizeFunction = "COUNTA"; pivotValue.sourceColumnOffset = 4; pivotTable.values = [pivotValue]; // Create other metadata for the updateCellsRequest const cellData = Sheets.newCellData(); cellData.pivotTable = pivotTable; const rows = Sheets.newRowData(); rows.values = cellData; const start = Sheets.newGridCoordinate(); start.sheetId = targetSheetId; start.rowIndex = 0; start.columnIndex = 0; const updateCellsRequest = Sheets.newUpdateCellsRequest(); updateCellsRequest.rows = rows; updateCellsRequest.start = start; updateCellsRequest.fields = "pivotTable"; // Batch update our spreadsheet const batchUpdate = Sheets.newBatchUpdateSpreadsheetRequest(); const updateCellsRawRequest = Sheets.newRequest(); updateCellsRawRequest.updateCells = updateCellsRequest; batchUpdate.requests = [updateCellsRawRequest]; const response = Sheets.Spreadsheets.batchUpdate( batchUpdate, spreadsheetId, ); return response; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_pivot_tables] // [START sheets_conditional_formatting] /** * conditional formatting * @param {string} spreadsheetId spreadsheet ID * @returns {*} spreadsheet */ Snippets.prototype.conditionalFormatting = (spreadsheetId) => { try { const myRange = Sheets.newGridRange(); myRange.sheetId = 0; myRange.startRowIndex = 0; myRange.endRowIndex = 11; myRange.startColumnIndex = 0; myRange.endColumnIndex = 4; // Request 1 const rule1ConditionalValue = Sheets.newConditionValue(); rule1ConditionalValue.userEnteredValue = "=GT($D2,median($D$2:$D$11))"; const rule1ConditionFormat = Sheets.newCellFormat(); rule1ConditionFormat.textFormat = Sheets.newTextFormat(); rule1ConditionFormat.textFormat.foregroundColor = Sheets.newColor(); rule1ConditionFormat.textFormat.foregroundColor.red = 0.8; const rule1Condition = Sheets.newBooleanCondition(); rule1Condition.type = "CUSTOM_FORMULA"; rule1Condition.values = [rule1ConditionalValue]; const rule1BooleanRule = Sheets.newBooleanRule(); rule1BooleanRule.condition = rule1Condition; rule1BooleanRule.format = rule1ConditionFormat; const rule1 = Sheets.newConditionalFormatRule(); rule1.ranges = [myRange]; rule1.booleanRule = rule1BooleanRule; const request1 = Sheets.newRequest(); const addConditionalFormatRuleRequest1 = Sheets.newAddConditionalFormatRuleRequest(); addConditionalFormatRuleRequest1.rule = rule1; addConditionalFormatRuleRequest1.index = 0; request1.addConditionalFormatRule = addConditionalFormatRuleRequest1; // Request 2 const rule2ConditionalValue = Sheets.newConditionValue(); rule2ConditionalValue.userEnteredValue = "=LT($D2,median($D$2:$D$11))"; const rule2ConditionFormat = Sheets.newCellFormat(); rule2ConditionFormat.textFormat = Sheets.newTextFormat(); rule2ConditionFormat.textFormat.foregroundColor = Sheets.newColor(); rule2ConditionFormat.textFormat.foregroundColor.red = 1; rule2ConditionFormat.textFormat.foregroundColor.green = 0.4; rule2ConditionFormat.textFormat.foregroundColor.blue = 0.4; const rule2Condition = Sheets.newBooleanCondition(); rule2Condition.type = "CUSTOM_FORMULA"; rule2Condition.values = [rule2ConditionalValue]; const rule2BooleanRule = Sheets.newBooleanRule(); rule2BooleanRule.condition = rule2Condition; rule2BooleanRule.format = rule2ConditionFormat; const rule2 = Sheets.newConditionalFormatRule(); rule2.ranges = [myRange]; rule2.booleanRule = rule2BooleanRule; const request2 = Sheets.newRequest(); const addConditionalFormatRuleRequest2 = Sheets.newAddConditionalFormatRuleRequest(); addConditionalFormatRuleRequest2.rule = rule2; addConditionalFormatRuleRequest2.index = 0; request2.addConditionalFormatRule = addConditionalFormatRuleRequest2; // Batch send the requests const requests = [request1, request2]; const batchUpdate = Sheets.newBatchUpdateSpreadsheetRequest(); batchUpdate.requests = requests; const response = Sheets.Spreadsheets.batchUpdate( batchUpdate, spreadsheetId, ); return response; } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } }; // [END sheets_conditional_formatting] ================================================ FILE: sheets/api/test_spreadsheet_snippets.gs ================================================ const snippets = new Snippets(); const helpers = new Helpers(); /** * A simple exists assertion check. Expects a value to exist. Errors if DNE. * @param {any} value A value that is expected to exist. */ function expectToExist(value) { if (value) { console.log("TEST: Exists"); } else { throw new Error("TEST: DNE"); } } /** * A simple exists assertion check for primatives (no nested objects). * Expects actual to equal expected. Logs the output. * @param {any} actual The actual value. * @param {any} expected The expected value. */ function expectToEqual(actual, expected) { console.log("TEST: actual: %s = expected: %s", actual, expected); if (actual !== expected) { console.log("TEST: actual: %s expected: %s", actual, expected); } } /** * Runs all tests. */ function RUN_ALL_TESTS() { itShouldCreateASpreadsheet(); itShouldBatchUpdateASpreadsheet(); itShouldGetSpreadsheetValues(); itShouldBatchGetSpreadsheetValues(); itShouldUpdateSpreadsheetValues(); itShouldBatchUpdateSpreadsheetValues(); itShouldAppendValuesToASpreadsheet(); itShouldCreatePivotTables(); itShouldConditionallyFormat(); } /** * Tests creating a spreadsheet. */ function itShouldCreateASpreadsheet() { const spreadsheetId = snippets.create("Title"); expectToExist(spreadsheetId); helpers.deleteFileOnCleanup(spreadsheetId); } /** * Tests updating a spreadsheet. */ function itShouldBatchUpdateASpreadsheet() { const spreadsheetId = helpers.createTestSpreadsheet(); helpers.populateValues(spreadsheetId); const result = snippets.batchUpdate( spreadsheetId, "New Title", "Hello", "Goodbye", ); const replies = result.replies; expectToEqual(replies.length, 2); const findReplaceResponse = replies[1].findReplace; expectToEqual(findReplaceResponse.occurrencesChanged, 100); } /** * Tests getting a spreadsheet value. */ function itShouldGetSpreadsheetValues() { const spreadsheetId = helpers.createTestSpreadsheet(); helpers.populateValues(spreadsheetId); const result = snippets.getValues(spreadsheetId, "A1:C2"); const values = result.values; expectToEqual(values.length, 2); expectToEqual(values[0].length, 3); } /** * Tests batch getting spreadsheet values. */ function itShouldBatchGetSpreadsheetValues() { const spreadsheetId = helpers.createTestSpreadsheet(); helpers.populateValues(spreadsheetId); const result = snippets.batchGetValues(spreadsheetId, ["A1:A3", "B1:C1"]); expectToExist(result); expectToEqual(result.valueRanges.length, 2); expectToEqual(result.valueRanges[0].values.length, 3); } /** * Tests updating spreadsheet values. */ function itShouldUpdateSpreadsheetValues() { const spreadsheetId = helpers.createTestSpreadsheet(); const result = snippets.updateValues(spreadsheetId, "A1:B2", "USER_ENTERED", [ ["A", "B"], ["C", "D"], ]); expectToEqual(result.updatedRows, 2); expectToEqual(result.updatedColumns, 2); expectToEqual(result.updatedCells, 4); } /** * Test batch updating spreadsheet values. */ function itShouldBatchUpdateSpreadsheetValues() { const spreadsheetId = helpers.createTestSpreadsheet(); const result = snippets.batchUpdateValues( spreadsheetId, "A1:B2", "USER_ENTERED", [ ["A", "B"], ["C", "D"], ], ); expectToEqual(result.totalUpdatedRows, 2); expectToEqual(result.totalUpdatedColumns, 2); expectToEqual(result.totalUpdatedCells, 4); } /** * Test appending values to a spreadsheet. */ function itShouldAppendValuesToASpreadsheet() { const spreadsheetId = helpers.createTestSpreadsheet(); helpers.populateValues(spreadsheetId); const result = snippets.appendValues( spreadsheetId, "Sheet1", "USER_ENTERED", [ ["A", "B"], ["C", "D"], ], ); const updates = result.updates; expectToEqual(updates.updatedRows, 2); expectToEqual(updates.updatedColumns, 2); expectToEqual(updates.updatedCells, 4); } /** * Test creating pivot tables. */ function itShouldCreatePivotTables() { const spreadsheetId = helpers.createTestSpreadsheet(); helpers.populateValues(spreadsheetId); const result = snippets.pivotTable(spreadsheetId); expectToExist(result); } /** * Test conditionally formatting spreadsheets. */ function itShouldConditionallyFormat() { const spreadsheetId = helpers.createTestSpreadsheet(); helpers.populateValues(spreadsheetId); const result = snippets.conditionalFormatting(spreadsheetId); expectToExist(spreadsheetId); expectToEqual(result.replies.length, 2); } ================================================ FILE: sheets/customFunctions/btc.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // See https://support.coinbase.com/customer/en/portal/articles/1914910-how-can-i-generate-api-keys-within-coinbase-commerce- const COINBASE_API_TOKEN = ""; // TODO /** * Get's the bitcoin price at a date. * * @param {string} date The date in yyyy-MM-dd format. Defaults to today. * @return The value of BTC in USD at the date. From Coinbase's API * @customfunction */ function BTC(date) { let dateObject = new Date(); if (date) { dateObject = new Date(date); } const dateString = Utilities.formatDate(dateObject, "GMT", "yyyy-MM-dd"); const res = UrlFetchApp.fetch( `https://api.coinbase.com/v2/prices/BTC-USD/spot?date=${dateString}`, { headers: { "CB-VERSION": "2016-10-10", Authorization: `Bearer ${COINBASE_API_TOKEN}`, }, }, ); const json = JSON.parse(res.getContentText()); return json.data.amount; } ================================================ FILE: sheets/customFunctions/customFunctions.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_sheets_custom_functions_quickstart] /** * @OnlyCurrentDoc Limits the script to only accessing the current sheet. */ /** * A special function that runs when the spreadsheet is open, used to add a * custom menu to the spreadsheet. */ function onOpen() { try { const spreadsheet = SpreadsheetApp.getActive(); const menuItems = [ { name: "Prepare sheet...", functionName: "prepareSheet_" }, { name: "Generate step-by-step...", functionName: "generateStepByStep_" }, ]; spreadsheet.addMenu("Directions", menuItems); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * A custom function that converts meters to miles. * * @param {Number} meters The distance in meters. * @return {Number} The distance in miles. */ function metersToMiles(meters) { if (typeof meters !== "number") { return null; } return (meters / 1000) * 0.621371; } /** * A custom function that gets the driving distance between two addresses. * * @param {String} origin The starting address. * @param {String} destination The ending address. * @return {Number} The distance in meters. */ function drivingDistance(origin, destination) { const directions = getDirections_(origin, destination); return directions.routes[0].legs[0].distance.value; } /** * A function that adds headers and some initial data to the spreadsheet. */ function prepareSheet_() { try { const sheet = SpreadsheetApp.getActiveSheet().setName("Settings"); const headers = [ "Start Address", "End Address", "Driving Distance (meters)", "Driving Distance (miles)", ]; const initialData = [ "350 5th Ave, New York, NY 10118", "405 Lexington Ave, New York, NY 10174", ]; sheet.getRange("A1:D1").setValues([headers]).setFontWeight("bold"); sheet.getRange("A2:B2").setValues([initialData]); sheet.setFrozenRows(1); sheet.autoResizeColumns(1, 4); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Creates a new sheet containing step-by-step directions between the two * addresses on the "Settings" sheet that the user selected. */ function generateStepByStep_() { try { const spreadsheet = SpreadsheetApp.getActive(); const settingsSheet = spreadsheet.getSheetByName("Settings"); settingsSheet.activate(); // Prompt the user for a row number. const selectedRow = Browser.inputBox( "Generate step-by-step", "Please enter the row number of" + " the" + " addresses to use" + ' (for example, "2"):', Browser.Buttons.OK_CANCEL, ); if (selectedRow === "cancel") { return; } const rowNumber = Number(selectedRow); if ( Number.isNaN(rowNumber) || rowNumber < 2 || rowNumber > settingsSheet.getLastRow() ) { Browser.msgBox( "Error", Utilities.formatString('Row "%s" is not valid.', selectedRow), Browser.Buttons.OK, ); return; } // Retrieve the addresses in that row. const row = settingsSheet.getRange(rowNumber, 1, 1, 2); const rowValues = row.getValues(); const origin = rowValues[0][0]; const destination = rowValues[0][1]; if (!origin || !destination) { Browser.msgBox( "Error", "Row does not contain two addresses.", Browser.Buttons.OK, ); return; } // Get the raw directions information. const directions = getDirections_(origin, destination); // Create a new sheet and append the steps in the directions. const sheetName = `Driving Directions for Row ${rowNumber}`; let directionsSheet = spreadsheet.getSheetByName(sheetName); if (directionsSheet) { directionsSheet.clear(); directionsSheet.activate(); } else { directionsSheet = spreadsheet.insertSheet( sheetName, spreadsheet.getNumSheets(), ); } const sheetTitle = Utilities.formatString( "Driving Directions from %s to %s", origin, destination, ); const headers = [ [sheetTitle, "", ""], ["Step", "Distance (Meters)", "Distance (Miles)"], ]; const newRows = []; for (const step of directions.routes[0].legs[0].steps) { // Remove HTML tags from the instructions. const instructions = step.html_instructions .replace(/
|/g, "\n") .replace(/<.*?>/g, ""); newRows.push([instructions, step.distance.value]); } directionsSheet.getRange(1, 1, headers.length, 3).setValues(headers); directionsSheet .getRange(headers.length + 1, 1, newRows.length, 2) .setValues(newRows); directionsSheet .getRange(headers.length + 1, 3, newRows.length, 1) .setFormulaR1C1("=METERSTOMILES(R[0]C[-1])"); // Format the new sheet. directionsSheet.getRange("A1:C1").merge().setBackground("#ddddee"); directionsSheet.getRange("A1:2").setFontWeight("bold"); directionsSheet.setColumnWidth(1, 500); directionsSheet.getRange("B2:C").setVerticalAlignment("top"); directionsSheet.getRange("C2:C").setNumberFormat("0.00"); const stepsRange = directionsSheet .getDataRange() .offset(2, 0, directionsSheet.getLastRow() - 2); setAlternatingRowBackgroundColors_(stepsRange, "#ffffff", "#eeeeee"); directionsSheet.setFrozenRows(2); SpreadsheetApp.flush(); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Sets the background colors for alternating rows within the range. * @param {Range} range The range to change the background colors of. * @param {string} oddColor The color to apply to odd rows (relative to the * start of the range). * @param {string} evenColor The color to apply to even rows (relative to the * start of the range). */ function setAlternatingRowBackgroundColors_(range, oddColor, evenColor) { const backgrounds = []; for (let row = 1; row <= range.getNumRows(); row++) { const rowBackgrounds = []; for (let column = 1; column <= range.getNumColumns(); column++) { if (row % 2 === 0) { rowBackgrounds.push(evenColor); } else { rowBackgrounds.push(oddColor); } } backgrounds.push(rowBackgrounds); } range.setBackgrounds(backgrounds); } /** * A shared helper function used to obtain the full set of directions * information between two addresses. Uses the Apps Script Maps Service. * * @param {String} origin The starting address. * @param {String} destination The ending address. * @return {Object} The directions response object. */ function getDirections_(origin, destination) { const directionFinder = Maps.newDirectionFinder(); directionFinder.setOrigin(origin); directionFinder.setDestination(destination); const directions = directionFinder.getDirections(); if (directions.status !== "OK") { throw directions.error_message; } return directions; } // [END apps_script_sheets_custom_functions_quickstart] ================================================ FILE: sheets/dateAddAndSubtract/README.md ================================================ # Date Add and Subtract Date Add and Subtract is a sample [add-on for Google Sheets](https://developers.google.com/apps-script/add-ons) that provides custom functions for date manipulation. The script uses the [Moment.js](http://momentjs.com/) JavaScript library, which is included directly in the Apps Script project. ![Date Add and Subtract screenshot](screenshot.png) ## Try it out For your convience we have published the script to the Google Sheets [add-ons store](https://chrome.google.com/webstore/detail/date-add-and-subtract/mhdmhddjinipgjhpicaidhpimlmgnflb). Install the add-on via the store and follow the instructions to get started. ================================================ FILE: sheets/dateAddAndSubtract/dateAddAndSubtract.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Provides the custom functions DATEADD and DATESUBTRACT and * the helper functions that they use. * @OnlyCurrentDoc */ /** * The list of valid unit identifiers. */ const VALID_UNITS = [ "year", "month", "week", "day", "hour", "minute", "second", "millisecond", ]; /** * Runs when the add-on is installed. */ function onInstall() { onOpen(); } /** * Runs when the document is opened, creating the add-on's menu. Custom function * add-ons need at least one menu item, since the add-on is only enabled in the * current spreadsheet when a function is run. */ function onOpen() { SpreadsheetApp.getUi() .createAddonMenu() .addItem("Use in this spreadsheet", "use") .addToUi(); } /** * Enables the add-on on for the current spreadsheet (simply by running) and * shows a popup informing the user of the new functions that are available. */ function use() { const title = "Date Custom Functions"; const message = "The functions DATEADD and DATESUBTRACT are now available in " + "this spreadsheet. More information is available in the function help " + "box that appears when you start using them in a formula."; const ui = SpreadsheetApp.getUi(); ui.alert(title, message, ui.ButtonSet.OK); } /** * Adds some amount of time to a date. * @param {Date|Range} date The date to add to, or a range of dates. * @param {string|Range} unit The unit of time to add, or a range of units. * Possible values include: * `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, and * `milliseconds`. You can also use the shorthand notation for these units * which are `y`, `M`, `w`, `d`, `h`, `m`, `s`, `ms` respectively. * @param {number|Range} amount The amount of the specified unit to add, or a * range of amounts. * @return {Date} The new date. * @customFunction */ function DATEADD(date, unit, amount) { const args = [date, unit, amount]; return multimap(args, (date, unit, amount) => { validateParameters(date, unit, amount); return moment(date).add(unit, amount).toDate(); }); } /** * @customFunction * A test function for DATEADD * @param {string|Range} date The date to add to. * @param {string|Range} unit The unit of time to add. * @param {number|Range} amount The amount of the specified unit to add. * @return {string} The date in a string. */ function DATETEST(date, unit, amount) { return JSON.stringify(DATEADD(date, unit, amount)); // eslint-disable-line new-cap } /** * Subtracts some amount of time from a date. * @param {Date|Range} date The date to subtract from, or a range of dates. * @param {string|Range} unit The unit of time to subtract, or a range of units. * Possible values include: * `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, and * `milliseconds`. You can also use the shorthand notation for these units * which are `y`, `M`, `w`, `d`, `h`, `m`, `s`, `ms` respectively. * @param {number|Range} amount The amount of the specified unit to subtract, or * a range of amounts. * @return {Date} The new date. * @customFunction */ function DATESUBTRACT(date, unit, amount) { const args = [date, unit, amount]; return multimap(args, (date, unit, amount) => { validateParameters(date, unit, amount); return moment(date).subtract(unit, amount).toDate(); }); } /** * Validates that the date, unit, and amount supplied are compatible with * Moment, throwing an exception if any of the parameters are invalid. * @param {Date} date The date to add to or subtract from. * @param {string} unit The unit of time to add/subtract. * @param {number} amount The amount of the specified unit to add/subtract. */ function validateParameters(date, unit, amount) { if ( date === undefined || typeof date === "number" || !moment(date).isValid() ) { throw Utilities.formatString( 'Parameter 1 expects a date value, but "%s" ' + "cannot be coerced to a date.", date, ); } if (VALID_UNITS.indexOf(moment.normalizeUnits(unit)) < 0) { throw Utilities.formatString( "Parameter 2 expects a unit identifier, but " + '"%s" is not a valid identifier.', unit, ); } if (Number.isNaN(Number(amount))) { throw Utilities.formatString( "Parameter 3 expects a number value, but " + '"%s" cannot be coerced to a number.', amount, ); } } /** * Applies a function to a set of arguments, looping over arrays in those * arguments. Similar to Array.map, except that it can map the function across * multiple arrays, passing forward non-array values. * @param {Array} args The arguments to map against. * @param {Function} func The function to apply. * @return {Array} The results of the mapping. */ function multimap(args, func) { // Determine the length of the arrays. const lengths = args.map((arg) => { if (Array.isArray(arg)) { return arg.length; } return 0; }); const max = Math.max.apply(null, lengths); // If there aren't any arrays, just call the function. if (max === 0) { return func(...args); } // Ensure all the arrays are the same length. // Arrays of length 1 are exempted, since they are assumed to be rows/columns // that should apply to each row/column in the other sets. for (const length of lengths) { if (length !== max && length > 1) { throw new Error(`All input ranges must be the same size: ${max}`); } } // Recursively apply the map function to each element in the arrays. const result = []; for (let i = 0; i < max; i++) { const params = args.map((arg) => { if (Array.isArray(arg)) { return arg.length === 1 ? arg[0] : arg[i]; } return arg; }); result.push(multimap(params, func)); } return result; } /** * Convert the array-like arguments object into a real array. * @param {Arguments} args The arguments object to convert. * @return {Array} The equivalent array. */ function toArray(args) { return Array.prototype.slice.call(args); } ================================================ FILE: sheets/dateAddAndSubtract/moment.gs ================================================ // ! moment.js // ! version : 2.10.6 // ! authors : Tim Wood, Iskren Chernev, Moment.js contributors // ! license : MIT // ! momentjs.com /* Copyright (c) 2011-2015 Tim Wood, Iskren Chernev, Moment.js contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ !function(a, b) { 'object'==typeof exports&&'undefined'!=typeof module?module.exports=b():'function'==typeof define&&define.amd?define(b):a.moment=b(); }(this, function() { 'use strict'; function a() { return Hc(...arguments); } function b(a) { Hc=a; } function c(a) { return '[object Array]'===Object.prototype.toString.call(a); } function d(a) { return a instanceof Date||'[object Date]'===Object.prototype.toString.call(a); } function e(a, b) { var c, d=[]; for (c=0; c0) for (c in Jc)d=Jc[c], e=b[d], 'undefined'!=typeof e&&(a[d]=e); return a; } function n(b) { m(this, b), this._d=new Date(null!=b._d?b._d.getTime():NaN), Kc===!1&&(Kc=!0, a.updateOffset(this), Kc=!1); } function o(a) { return a instanceof n||null!=a&&null!=a._isAMomentObject; } function p(a) { return 0>a?Math.ceil(a):Math.floor(a); } function q(a) { var b=+a, c=0; return 0!==b&&isFinite(b)&&(c=p(b)), c; } function r(a, b, c) { var d, e=Math.min(a.length, b.length), f=Math.abs(a.length-b.length), g=0; for (d=0; e>d; d++)(c&&a[d]!==b[d]||!c&&q(a[d])!==q(b[d]))&&g++; return g+f; } function s() {} function t(a) { return a?a.toLowerCase().replace('_', '-'):a; } function u(a) { for (var b, c, d, e, f=0; f0;) { if (d=v(e.slice(0, b).join('-'))) return d; if (c&&c.length>=b&&r(e, c, !0)>=b-1) break; b--; }f++; } return null; } function v(a) { var b=null; if (!Lc[a]&&'undefined'!=typeof module&&module&&module.exports) { try { b=Ic._abbr, require('./locale/'+a), w(b); } catch (c) {} } return Lc[a]; } function w(a, b) { var c; return a&&(c='undefined'==typeof b?y(a):x(a, b), c&&(Ic=c)), Ic._abbr; } function x(a, b) { return null!==b?(b.abbr=a, Lc[a]=Lc[a]||new s, Lc[a].set(b), w(a), Lc[a]):(delete Lc[a], null); } function y(a) { var b; if (a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr), !a) return Ic; if (!c(a)) { if (b=v(a)) return b; a=[a]; } return u(a); } function z(a, b) { var c=a.toLowerCase(); Mc[c]=Mc[c+'s']=Mc[b]=a; } function A(a) { return 'string'==typeof a?Mc[a]||Mc[a.toLowerCase()]:void 0; } function B(a) { var b, c, d={}; for (c in a)f(a, c)&&(b=A(c), b&&(d[b]=a[c])); return d; } function C(b, c) { return function(d) { return null!=d?(E(this, b, d), a.updateOffset(this, c), this):D(this, b); }; } function D(a, b) { return a._d['get'+(a._isUTC?'UTC':'')+b](); } function E(a, b, c) { return a._d['set'+(a._isUTC?'UTC':'')+b](c); } function F(a, b) { var c; if ('object'==typeof a) for (c in a) this.set(c, a[c]); else if (a=A(a), 'function'==typeof this[a]) return this[a](b); return this; } function G(a, b, c) { var d=''+Math.abs(a), e=b-d.length, f=a>=0; return (f?c?'+':'':'-')+Math.pow(10, Math.max(0, e)).toString().substr(1)+d; } function H(a, b, c, d) { var e=d; 'string'==typeof d&&(e=function() { return this[d](); }), a&&(Qc[a]=e), b&&(Qc[b[0]]=function() { return G(e.apply(this, arguments), b[1], b[2]); }), c&&(Qc[c]=function() { return this.localeData().ordinal(e.apply(this, arguments), a); }); } function I(a) { return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g, ''):a.replace(/\\/g, ''); } function J(a) { var b, c, d=a.match(Nc); for (b=0, c=d.length; c>b; b++)Qc[d[b]]?d[b]=Qc[d[b]]:d[b]=I(d[b]); return function(e) { var f=''; for (b=0; c>b; b++)f+=d[b]instanceof Function?d[b].call(e, a):d[b]; return f; }; } function K(a, b) { return a.isValid()?(b=L(b, a.localeData()), Pc[b]=Pc[b]||J(b), Pc[b](a)):a.localeData().invalidDate(); } function L(a, b) { function c(a) { return b.longDateFormat(a)||a; } var d=5; for (Oc.lastIndex=0; d>=0&&Oc.test(a);)a=a.replace(Oc, c), Oc.lastIndex=0, d-=1; return a; } function M(a) { return 'function'==typeof a&&'[object Function]'===Object.prototype.toString.call(a); } function N(a, b, c) { dd[a]=M(b)?b:function(a) { return a&&c?c:b; }; } function O(a, b) { return f(dd, a)?dd[a](b._strict, b._locale):new RegExp(P(a)); } function P(a) { return a.replace('\\', '').replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function(a, b, c, d, e) { return b||c||d||e; }).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } function Q(a, b) { var c, d=b; for ('string'==typeof a&&(a=[a]), 'number'==typeof b&&(d=function(a, c) { c[b]=q(a); }), c=0; cd; d++) { if (e=h([2e3, d]), c&&!this._longMonthsParse[d]&&(this._longMonthsParse[d]=new RegExp('^'+this.months(e, '').replace('.', '')+'$', 'i'), this._shortMonthsParse[d]=new RegExp('^'+this.monthsShort(e, '').replace('.', '')+'$', 'i')), c||this._monthsParse[d]||(f='^'+this.months(e, '')+'|^'+this.monthsShort(e, ''), this._monthsParse[d]=new RegExp(f.replace('.', ''), 'i')), c&&'MMMM'===b&&this._longMonthsParse[d].test(a)) return d; if (c&&'MMM'===b&&this._shortMonthsParse[d].test(a)) return d; if (!c&&this._monthsParse[d].test(a)) return d; } } function X(a, b) { var c; return 'string'==typeof b&&(b=a.localeData().monthsParse(b), 'number'!=typeof b)?a:(c=Math.min(a.date(), T(a.year(), b)), a._d['set'+(a._isUTC?'UTC':'')+'Month'](b, c), a); } function Y(b) { return null!=b?(X(this, b), a.updateOffset(this, !0), this):D(this, 'Month'); } function Z() { return T(this.year(), this.month()); } function $(a) { var b, c=a._a; return c&&-2===j(a).overflow&&(b=c[gd]<0||c[gd]>11?gd:c[hd]<1||c[hd]>T(c[fd], c[gd])?hd:c[id]<0||c[id]>24||24===c[id]&&(0!==c[jd]||0!==c[kd]||0!==c[ld])?id:c[jd]<0||c[jd]>59?jd:c[kd]<0||c[kd]>59?kd:c[ld]<0||c[ld]>999?ld:-1, j(a)._overflowDayOfYear&&(fd>b||b>hd)&&(b=hd), j(a).overflow=b), a; } function _(b) { a.suppressDeprecationWarnings===!1&&'undefined'!=typeof console&&console.warn&&console.warn('Deprecation warning: '+b); } function aa(a, b) { var c=!0; return g(function() { return c&&(_(a+'\n'+(new Error).stack), c=!1), b.apply(this, arguments); }, b); } function ba(a, b) { od[a]||(_(b), od[a]=!0); } function ca(a) { var b, c, d=a._i, e=pd.exec(d); if (e) { for (j(a).iso=!0, b=0, c=qd.length; c>b; b++) { if (qd[b][1].exec(d)) { a._f=qd[b][0]; break; } } for (b=0, c=rd.length; c>b; b++) { if (rd[b][1].exec(d)) { a._f+=(e[6]||' ')+rd[b][0]; break; } }d.match(ad)&&(a._f+='Z'), va(a); } else a._isValid=!1; } function da(b) { var c=sd.exec(b._i); return null!==c?void(b._d=new Date(+c[1])):(ca(b), void(b._isValid===!1&&(delete b._isValid, a.createFromInputFallback(b)))); } function ea(a, b, c, d, e, f, g) { var h=new Date(a, b, c, d, e, f, g); return 1970>a&&h.setFullYear(a), h; } function fa(a) { var b=new Date(Date.UTC.apply(null, arguments)); return 1970>a&&b.setUTCFullYear(a), b; } function ga(a) { return ha(a)?366:365; } function ha(a) { return a%4===0&&a%100!==0||a%400===0; } function ia() { return ha(this.year()); } function ja(a, b, c) { var d, e=c-b, f=c-a.day(); return f>e&&(f-=7), e-7>f&&(f+=7), d=Da(a).add(f, 'd'), {week: Math.ceil(d.dayOfYear()/7), year: d.year()}; } function ka(a) { return ja(a, this._week.dow, this._week.doy).week; } function la() { return this._week.dow; } function ma() { return this._week.doy; } function na(a) { var b=this.localeData().week(this); return null==a?b:this.add(7*(a-b), 'd'); } function oa(a) { var b=ja(this, 1, 4).week; return null==a?b:this.add(7*(a-b), 'd'); } function pa(a, b, c, d, e) { var f, g=6+e-d, h=fa(a, 0, 1+g), i=h.getUTCDay(); return e>i&&(i+=7), c=null!=c?1*c:e, f=1+g+7*(b-1)-i+c, {year: f>0?a:a-1, dayOfYear: f>0?f:ga(a-1)+f}; } function qa(a) { var b=Math.round((this.clone().startOf('day')-this.clone().startOf('year'))/864e5)+1; return null==a?b:this.add(a-b, 'd'); } function ra(a, b, c) { return null!=a?a:null!=b?b:c; } function sa(a) { var b=new Date; return a._useUTC?[b.getUTCFullYear(), b.getUTCMonth(), b.getUTCDate()]:[b.getFullYear(), b.getMonth(), b.getDate()]; } function ta(a) { var b, c, d, e, f=[]; if (!a._d) { for (d=sa(a), a._w&&null==a._a[hd]&&null==a._a[gd]&&ua(a), a._dayOfYear&&(e=ra(a._a[fd], d[fd]), a._dayOfYear>ga(e)&&(j(a)._overflowDayOfYear=!0), c=fa(e, 0, a._dayOfYear), a._a[gd]=c.getUTCMonth(), a._a[hd]=c.getUTCDate()), b=0; 3>b&&null==a._a[b]; ++b)a._a[b]=f[b]=d[b]; for (;7>b; b++)a._a[b]=f[b]=null==a._a[b]?2===b?1:0:a._a[b]; 24===a._a[id]&&0===a._a[jd]&&0===a._a[kd]&&0===a._a[ld]&&(a._nextDay=!0, a._a[id]=0), a._d=(a._useUTC?fa:ea)(...f), null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()-a._tzm), a._nextDay&&(a._a[id]=24); } } function ua(a) { var b, c, d, e, f, g, h; b=a._w, null!=b.GG||null!=b.W||null!=b.E?(f=1, g=4, c=ra(b.GG, a._a[fd], ja(Da(), 1, 4).year), d=ra(b.W, 1), e=ra(b.E, 1)):(f=a._locale._week.dow, g=a._locale._week.doy, c=ra(b.gg, a._a[fd], ja(Da(), f, g).year), d=ra(b.w, 1), null!=b.d?(e=b.d, f>e&&++d):e=null!=b.e?b.e+f:f), h=pa(c, d, e, g, f), a._a[fd]=h.year, a._dayOfYear=h.dayOfYear; } function va(b) { if (b._f===a.ISO_8601) return void ca(b); b._a=[], j(b).empty=!0; var c, d, e, f, g, h=''+b._i, i=h.length, k=0; for (e=L(b._f, b._locale).match(Nc)||[], c=0; c0&&j(b).unusedInput.push(g), h=h.slice(h.indexOf(d)+d.length), k+=d.length), Qc[f]?(d?j(b).empty=!1:j(b).unusedTokens.push(f), S(f, d, b)):b._strict&&!d&&j(b).unusedTokens.push(f); j(b).charsLeftOver=i-k, h.length>0&&j(b).unusedInput.push(h), j(b).bigHour===!0&&b._a[id]<=12&&b._a[id]>0&&(j(b).bigHour=void 0), b._a[id]=wa(b._locale, b._a[id], b._meridiem), ta(b), $(b); } function wa(a, b, c) { var d; return null==c?b:null!=a.meridiemHour?a.meridiemHour(b, c):null!=a.isPM?(d=a.isPM(c), d&&12>b&&(b+=12), d||12!==b||(b=0), b):b; } function xa(a) { var b, c, d, e, f; if (0===a._f.length) return j(a).invalidFormat=!0, void(a._d=new Date(NaN)); for (e=0; ef)&&(d=f, c=b)); g(a, c||b); } function ya(a) { if (!a._d) { var b=B(a._i); a._a=[b.year, b.month, b.day||b.date, b.hour, b.minute, b.second, b.millisecond], ta(a); } } function za(a) { var b=new n($(Aa(a))); return b._nextDay&&(b.add(1, 'd'), b._nextDay=void 0), b; } function Aa(a) { var b=a._i, e=a._f; return a._locale=a._locale||y(a._l), null===b||void 0===e&&''===b?l({nullInput: !0}):('string'==typeof b&&(a._i=b=a._locale.preparse(b)), o(b)?new n($(b)):(c(e)?xa(a):e?va(a):d(b)?a._d=b:Ba(a), a)); } function Ba(b) { var f=b._i; void 0===f?b._d=new Date:d(f)?b._d=new Date(+f):'string'==typeof f?da(b):c(f)?(b._a=e(f.slice(0), function(a) { return parseInt(a, 10); }), ta(b)):'object'==typeof f?ya(b):'number'==typeof f?b._d=new Date(f):a.createFromInputFallback(b); } function Ca(a, b, c, d, e) { var f={}; return 'boolean'==typeof c&&(d=c, c=void 0), f._isAMomentObject=!0, f._useUTC=f._isUTC=e, f._l=c, f._i=a, f._f=b, f._strict=d, za(f); } function Da(a, b, c, d) { return Ca(a, b, c, d, !1); } function Ea(a, b) { var d, e; if (1===b.length&&c(b[0])&&(b=b[0]), !b.length) return Da(); for (d=b[0], e=1; ea&&(a=-a, c='-'), c+G(~~(a/60), 2)+b+G(~~a%60, 2); }); } function Ka(a) { var b=(a||'').match(ad)||[], c=b[b.length-1]||[], d=(c+'').match(xd)||['-', 0, 0], e=+(60*d[1])+q(d[2]); return '+'===d[0]?e:-e; } function La(b, c) { var e, f; return c._isUTC?(e=c.clone(), f=(o(b)||d(b)?+b:+Da(b))-+e, e._d.setTime(+e._d+f), a.updateOffset(e, !1), e):Da(b).local(); } function Ma(a) { return 15*-Math.round(a._d.getTimezoneOffset()/15); } function Na(b, c) { var d, e=this._offset||0; return null!=b?('string'==typeof b&&(b=Ka(b)), Math.abs(b)<16&&(b=60*b), !this._isUTC&&c&&(d=Ma(this)), this._offset=b, this._isUTC=!0, null!=d&&this.add(d, 'm'), e!==b&&(!c||this._changeInProgress?bb(this, Ya(b-e, 'm'), 1, !1):this._changeInProgress||(this._changeInProgress=!0, a.updateOffset(this, !0), this._changeInProgress=null)), this):this._isUTC?e:Ma(this); } function Oa(a, b) { return null!=a?('string'!=typeof a&&(a=-a), this.utcOffset(a, b), this):-this.utcOffset(); } function Pa(a) { return this.utcOffset(0, a); } function Qa(a) { return this._isUTC&&(this.utcOffset(0, a), this._isUTC=!1, a&&this.subtract(Ma(this), 'm')), this; } function Ra() { return this._tzm?this.utcOffset(this._tzm):'string'==typeof this._i&&this.utcOffset(Ka(this._i)), this; } function Sa(a) { return a=a?Da(a).utcOffset():0, (this.utcOffset()-a)%60===0; } function Ta() { return this.utcOffset()> this.clone().month(0).utcOffset()||this.utcOffset()> this.clone().month(5).utcOffset(); } function Ua() { if ('undefined'!=typeof this._isDSTShifted) return this._isDSTShifted; var a={}; if (m(a, this), a=Aa(a), a._a) { var b=a._isUTC?h(a._a):Da(a._a); this._isDSTShifted=this.isValid()&&r(a._a, b.toArray())>0; } else this._isDSTShifted=!1; return this._isDSTShifted; } function Va() { return !this._isUTC; } function Wa() { return this._isUTC; } function Xa() { return this._isUTC&&0===this._offset; } function Ya(a, b) { var c, d, e, g=a, h=null; return Ia(a)?g={ms: a._milliseconds, d: a._days, M: a._months}:'number'==typeof a?(g={}, b?g[b]=a:g.milliseconds=a):(h=yd.exec(a))?(c='-'===h[1]?-1:1, g={y: 0, d: q(h[hd])*c, h: q(h[id])*c, m: q(h[jd])*c, s: q(h[kd])*c, ms: q(h[ld])*c}):(h=zd.exec(a))?(c='-'===h[1]?-1:1, g={y: Za(h[2], c), M: Za(h[3], c), d: Za(h[4], c), h: Za(h[5], c), m: Za(h[6], c), s: Za(h[7], c), w: Za(h[8], c)}):null==g?g={}:'object'==typeof g&&('from'in g||'to'in g)&&(e=_a(Da(g.from), Da(g.to)), g={}, g.ms=e.milliseconds, g.M=e.months), d=new Ha(g), Ia(a)&&f(a, '_locale')&&(d._locale=a._locale), d; } function Za(a, b) { var c=a&&parseFloat(a.replace(',', '.')); return (isNaN(c)?0:c)*b; } function $a(a, b) { var c={milliseconds: 0, months: 0}; return c.months=b.month()-a.month()+12*(b.year()-a.year()), a.clone().add(c.months, 'M').isAfter(b)&&--c.months, c.milliseconds=+b-+a.clone().add(c.months, 'M'), c; } function _a(a, b) { var c; return b=La(b, a), a.isBefore(b)?c=$a(a, b):(c=$a(b, a), c.milliseconds=-c.milliseconds, c.months=-c.months), c; } function ab(a, b) { return function(c, d) { var e, f; return null===d||isNaN(+d)||(ba(b, 'moment().'+b+'(period, number) is deprecated. Please use moment().'+b+'(number, period).'), f=c, c=d, d=f), c='string'==typeof c?+c:c, e=Ya(c, d), bb(this, e, a), this; }; } function bb(b, c, d, e) { var f=c._milliseconds, g=c._days, h=c._months; e=null==e?!0:e, f&&b._d.setTime(+b._d+f*d), g&&E(b, 'Date', D(b, 'Date')+g*d), h&&X(b, D(b, 'Month')+h*d), e&&a.updateOffset(b, g||h); } function cb(a, b) { var c=a||Da(), d=La(c, this).startOf('day'), e=this.diff(d, 'days', !0), f=-6>e?'sameElse':-1>e?'lastWeek':0>e?'lastDay':1>e?'sameDay':2>e?'nextDay':7>e?'nextWeek':'sameElse'; return this.format(b&&b[f]||this.localeData().calendar(f, this, Da(c))); } function db() { return new n(this); } function eb(a, b) { var c; return b=A('undefined'!=typeof b?b:'millisecond'), 'millisecond'===b?(a=o(a)?a:Da(a), +this>+a):(c=o(a)?+a:+Da(a), c<+this.clone().startOf(b)); } function fb(a, b) { var c; return b=A('undefined'!=typeof b?b:'millisecond'), 'millisecond'===b?(a=o(a)?a:Da(a), +a>+this):(c=o(a)?+a:+Da(a), +this.clone().endOf(b)b-f?(c=a.clone().add(e-1, 'months'), d=(b-f)/(f-c)):(c=a.clone().add(e+1, 'months'), d=(b-f)/(c-f)), -(e+d); } function kb() { return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); } function lb() { var a=this.clone().utc(); return 0b; b++) if (this._weekdaysParse[b]||(c=Da([2e3, 1]).day(b), d='^'+this.weekdays(c, '')+'|^'+this.weekdaysShort(c, '')+'|^'+this.weekdaysMin(c, ''), this._weekdaysParse[b]=new RegExp(d.replace('.', ''), 'i')), this._weekdaysParse[b].test(a)) return b; } function Pb(a) { var b=this._isUTC?this._d.getUTCDay():this._d.getDay(); return null!=a?(a=Kb(a, this.localeData()), this.add(a-b, 'd')):b; } function Qb(a) { var b=(this.day()+7-this.localeData()._week.dow)%7; return null==a?b:this.add(a-b, 'd'); } function Rb(a) { return null==a?this.day()||7:this.day(this.day()%7?a:a-7); } function Sb(a, b) { H(a, 0, 0, function() { return this.localeData().meridiem(this.hours(), this.minutes(), b); }); } function Tb(a, b) { return b._meridiemParse; } function Ub(a) { return 'p'===(a+'').toLowerCase().charAt(0); } function Vb(a, b, c) { return a>11?c?'pm':'PM':c?'am':'AM'; } function Wb(a, b) { b[ld]=q(1e3*('0.'+a)); } function Xb() { return this._isUTC?'UTC':''; } function Yb() { return this._isUTC?'Coordinated Universal Time':''; } function Zb(a) { return Da(1e3*a); } function $b() { return Da(...arguments).parseZone(); } function _b(a, b, c) { var d=this._calendar[a]; return 'function'==typeof d?d.call(b, c):d; } function ac(a) { var b=this._longDateFormat[a], c=this._longDateFormat[a.toUpperCase()]; return b||!c?b:(this._longDateFormat[a]=c.replace(/MMMM|MM|DD|dddd/g, function(a) { return a.slice(1); }), this._longDateFormat[a]); } function bc() { return this._invalidDate; } function cc(a) { return this._ordinal.replace('%d', a); } function dc(a) { return a; } function ec(a, b, c, d) { var e=this._relativeTime[c]; return 'function'==typeof e?e(a, b, c, d):e.replace(/%d/i, a); } function fc(a, b) { var c=this._relativeTime[a>0?'future':'past']; return 'function'==typeof c?c(b):c.replace(/%s/i, b); } function gc(a) { var b, c; for (c in a)b=a[c], 'function'==typeof b?this[c]=b:this['_'+c]=b; this._ordinalParseLenient=new RegExp(this._ordinalParse.source+'|'+/\d{1,2}/.source); } function hc(a, b, c, d) { var e=y(), f=h().set(d, b); return e[c](f, a); } function ic(a, b, c, d, e) { if ('number'==typeof a&&(b=a, a=void 0), a=a||'', null!=b) return hc(a, b, c, e); var f, g=[]; for (f=0; d>f; f++)g[f]=hc(a, f, c, e); return g; } function jc(a, b) { return ic(a, b, 'months', 12, 'month'); } function kc(a, b) { return ic(a, b, 'monthsShort', 12, 'month'); } function lc(a, b) { return ic(a, b, 'weekdays', 7, 'day'); } function mc(a, b) { return ic(a, b, 'weekdaysShort', 7, 'day'); } function nc(a, b) { return ic(a, b, 'weekdaysMin', 7, 'day'); } function oc() { var a=this._data; return this._milliseconds=Wd(this._milliseconds), this._days=Wd(this._days), this._months=Wd(this._months), a.milliseconds=Wd(a.milliseconds), a.seconds=Wd(a.seconds), a.minutes=Wd(a.minutes), a.hours=Wd(a.hours), a.months=Wd(a.months), a.years=Wd(a.years), this; } function pc(a, b, c, d) { var e=Ya(b, c); return a._milliseconds+=d*e._milliseconds, a._days+=d*e._days, a._months+=d*e._months, a._bubble(); } function qc(a, b) { return pc(this, a, b, 1); } function rc(a, b) { return pc(this, a, b, -1); } function sc(a) { return 0>a?Math.floor(a):Math.ceil(a); } function tc() { var a, b, c, d, e, f=this._milliseconds, g=this._days, h=this._months, i=this._data; return f>=0&&g>=0&&h>=0||0>=f&&0>=g&&0>=h||(f+=864e5*sc(vc(h)+g), g=0, h=0), i.milliseconds=f%1e3, a=p(f/1e3), i.seconds=a%60, b=p(a/60), i.minutes=b%60, c=p(b/60), i.hours=c%24, g+=p(c/24), e=p(uc(g)), h+=e, g-=sc(vc(e)), d=p(h/12), h%=12, i.days=g, i.months=h, i.years=d, this; } function uc(a) { return 4800*a/146097; } function vc(a) { return 146097*a/4800; } function wc(a) { var b, c, d=this._milliseconds; if (a=A(a), 'month'===a||'year'===a) return b=this._days+d/864e5, c=this._months+uc(b), 'month'===a?c:c/12; switch (b=this._days+Math.round(vc(this._months)), a) { case 'week': return b/7+d/6048e5; case 'day': return b+d/864e5; case 'hour': return 24*b+d/36e5; case 'minute': return 1440*b+d/6e4; case 'second': return 86400*b+d/1e3; case 'millisecond': return Math.floor(864e5*b)+d; default: throw new Error('Unknown unit '+a); } } function xc() { return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*q(this._months/12); } function yc(a) { return function() { return this.as(a); }; } function zc(a) { return a=A(a), this[a+'s'](); } function Ac(a) { return function() { return this._data[a]; }; } function Bc() { return p(this.days()/7); } function Cc(a, b, c, d, e) { return e.relativeTime(b||1, !!c, a, d); } function Dc(a, b, c) { var d=Ya(a).abs(), e=ke(d.as('s')), f=ke(d.as('m')), g=ke(d.as('h')), h=ke(d.as('d')), i=ke(d.as('M')), j=ke(d.as('y')), k=e0, k[4]=c, Cc(...k); } function Ec(a, b) { return void 0===le[a]?!1:void 0===b?le[a]:(le[a]=b, !0); } function Fc(a) { var b=this.localeData(), c=Dc(this, !a, b); return a&&(c=b.pastFuture(+this, c)), b.postformat(c); } function Gc() { var a, b, c, d=me(this._milliseconds)/1e3, e=me(this._days), f=me(this._months); a=p(d/60), b=p(a/60), d%=60, a%=60, c=p(f/12), f%=12; var g=c, h=f, i=e, j=b, k=a, l=d, m=this.asSeconds(); return m?(0>m?'-':'')+'P'+(g?g+'Y':'')+(h?h+'M':'')+(i?i+'D':'')+(j||k||l?'T':'')+(j?j+'H':'')+(k?k+'M':'')+(l?l+'S':''):'P0D'; } var Hc, Ic, Jc=a.momentProperties=[], Kc=!1, Lc={}, Mc={}, Nc=/(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g, Oc=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, Pc={}, Qc={}, Rc=/\d/, Sc=/\d\d/, Tc=/\d{3}/, Uc=/\d{4}/, Vc=/[+-]?\d{6}/, Wc=/\d\d?/, Xc=/\d{1,3}/, Yc=/\d{1,4}/, Zc=/[+-]?\d{1,6}/, $c=/\d+/, _c=/[+-]?\d+/, ad=/Z|[+-]\d\d:?\d\d/gi, bd=/[+-]?\d+(\.\d{1,3})?/, cd=/[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i, dd={}, ed={}, fd=0, gd=1, hd=2, id=3, jd=4, kd=5, ld=6; H('M', ['MM', 2], 'Mo', function() { return this.month()+1; }), H('MMM', 0, 0, function(a) { return this.localeData().monthsShort(this, a); }), H('MMMM', 0, 0, function(a) { return this.localeData().months(this, a); }), z('month', 'M'), N('M', Wc), N('MM', Wc, Sc), N('MMM', cd), N('MMMM', cd), Q(['M', 'MM'], function(a, b) { b[gd]=q(a)-1; }), Q(['MMM', 'MMMM'], function(a, b, c, d) { var e=c._locale.monthsParse(a, d, c._strict); null!=e?b[gd]=e:j(c).invalidMonth=a; }); var md='January_February_March_April_May_June_July_August_September_October_November_December'.split('_'), nd='Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'), od={}; a.suppressDeprecationWarnings=!1; var pd=/^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/, qd=[['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/], ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/], ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/], ['GGGG-[W]WW', /\d{4}-W\d{2}/], ['YYYY-DDD', /\d{4}-\d{3}/]], rd=[['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d+/], ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/], ['HH:mm', /(T| )\d\d:\d\d/], ['HH', /(T| )\d\d/]], sd=/^\/?Date\((\-?\d+)/i; a.createFromInputFallback=aa('moment construction falls back to js Date. This is discouraged and will be removed in upcoming major release. Please refer to https://github.com/moment/moment/issues/1407 for more info.', function(a) { a._d=new Date(a._i+(a._useUTC?' UTC':'')); }), H(0, ['YY', 2], 0, function() { return this.year()%100; }), H(0, ['YYYY', 4], 0, 'year'), H(0, ['YYYYY', 5], 0, 'year'), H(0, ['YYYYYY', 6, !0], 0, 'year'), z('year', 'y'), N('Y', _c), N('YY', Wc, Sc), N('YYYY', Yc, Uc), N('YYYYY', Zc, Vc), N('YYYYYY', Zc, Vc), Q(['YYYYY', 'YYYYYY'], fd), Q('YYYY', function(b, c) { c[fd]=2===b.length?a.parseTwoDigitYear(b):q(b); }), Q('YY', function(b, c) { c[fd]=a.parseTwoDigitYear(b); }), a.parseTwoDigitYear=function(a) { return q(a)+(q(a)>68?1900:2e3); }; var td=C('FullYear', !1); H('w', ['ww', 2], 'wo', 'week'), H('W', ['WW', 2], 'Wo', 'isoWeek'), z('week', 'w'), z('isoWeek', 'W'), N('w', Wc), N('ww', Wc, Sc), N('W', Wc), N('WW', Wc, Sc), R(['w', 'ww', 'W', 'WW'], function(a, b, c, d) { b[d.substr(0, 1)]=q(a); }); var ud={dow: 0, doy: 6}; H('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear'), z('dayOfYear', 'DDD'), N('DDD', Xc), N('DDDD', Tc), Q(['DDD', 'DDDD'], function(a, b, c) { c._dayOfYear=q(a); }), a.ISO_8601=function() {}; var vd=aa('moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548', function() { var a=Da(...arguments); return this>a?this:a; }), wd=aa('moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548', function() { var a=Da(...arguments); return a> this?this:a; }); Ja('Z', ':'), Ja('ZZ', ''), N('Z', ad), N('ZZ', ad), Q(['Z', 'ZZ'], function(a, b, c) { c._useUTC=!0, c._tzm=Ka(a); }); var xd=/([\+\-]|\d\d)/gi; a.updateOffset=function() {}; var yd=/(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/, zd=/^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/; Ya.fn=Ha.prototype; var Ad=ab(1, 'add'), Bd=ab(-1, 'subtract'); a.defaultFormat='YYYY-MM-DDTHH:mm:ssZ'; var Cd=aa('moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', function(a) { return void 0===a?this.localeData():this.locale(a); }); H(0, ['gg', 2], 0, function() { return this.weekYear()%100; }), H(0, ['GG', 2], 0, function() { return this.isoWeekYear()%100; }), Db('gggg', 'weekYear'), Db('ggggg', 'weekYear'), Db('GGGG', 'isoWeekYear'), Db('GGGGG', 'isoWeekYear'), z('weekYear', 'gg'), z('isoWeekYear', 'GG'), N('G', _c), N('g', _c), N('GG', Wc, Sc), N('gg', Wc, Sc), N('GGGG', Yc, Uc), N('gggg', Yc, Uc), N('GGGGG', Zc, Vc), N('ggggg', Zc, Vc), R(['gggg', 'ggggg', 'GGGG', 'GGGGG'], function(a, b, c, d) { b[d.substr(0, 2)]=q(a); }), R(['gg', 'GG'], function(b, c, d, e) { c[e]=a.parseTwoDigitYear(b); }), H('Q', 0, 0, 'quarter'), z('quarter', 'Q'), N('Q', Rc), Q('Q', function(a, b) { b[gd]=3*(q(a)-1); }), H('D', ['DD', 2], 'Do', 'date'), z('date', 'D'), N('D', Wc), N('DD', Wc, Sc), N('Do', function(a, b) { return a?b._ordinalParse:b._ordinalParseLenient; }), Q(['D', 'DD'], hd), Q('Do', function(a, b) { b[hd]=q(a.match(Wc)[0], 10); }); var Dd=C('Date', !0); H('d', 0, 'do', 'day'), H('dd', 0, 0, function(a) { return this.localeData().weekdaysMin(this, a); }), H('ddd', 0, 0, function(a) { return this.localeData().weekdaysShort(this, a); }), H('dddd', 0, 0, function(a) { return this.localeData().weekdays(this, a); }), H('e', 0, 0, 'weekday'), H('E', 0, 0, 'isoWeekday'), z('day', 'd'), z('weekday', 'e'), z('isoWeekday', 'E'), N('d', Wc), N('e', Wc), N('E', Wc), N('dd', cd), N('ddd', cd), N('dddd', cd), R(['dd', 'ddd', 'dddd'], function(a, b, c) { var d=c._locale.weekdaysParse(a); null!=d?b.d=d:j(c).invalidWeekday=a; }), R(['d', 'e', 'E'], function(a, b, c, d) { b[d]=q(a); }); var Ed='Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'), Fd='Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'), Gd='Su_Mo_Tu_We_Th_Fr_Sa'.split('_'); H('H', ['HH', 2], 0, 'hour'), H('h', ['hh', 2], 0, function() { return this.hours()%12||12; }), Sb('a', !0), Sb('A', !1), z('hour', 'h'), N('a', Tb), N('A', Tb), N('H', Wc), N('h', Wc), N('HH', Wc, Sc), N('hh', Wc, Sc), Q(['H', 'HH'], id), Q(['a', 'A'], function(a, b, c) { c._isPm=c._locale.isPM(a), c._meridiem=a; }), Q(['h', 'hh'], function(a, b, c) { b[id]=q(a), j(c).bigHour=!0; }); var Hd=/[ap]\.?m?\.?/i, Id=C('Hours', !0); H('m', ['mm', 2], 0, 'minute'), z('minute', 'm'), N('m', Wc), N('mm', Wc, Sc), Q(['m', 'mm'], jd); var Jd=C('Minutes', !1); H('s', ['ss', 2], 0, 'second'), z('second', 's'), N('s', Wc), N('ss', Wc, Sc), Q(['s', 'ss'], kd); var Kd=C('Seconds', !1); H('S', 0, 0, function() { return ~~(this.millisecond()/100); }), H(0, ['SS', 2], 0, function() { return ~~(this.millisecond()/10); }), H(0, ['SSS', 3], 0, 'millisecond'), H(0, ['SSSS', 4], 0, function() { return 10*this.millisecond(); }), H(0, ['SSSSS', 5], 0, function() { return 100*this.millisecond(); }), H(0, ['SSSSSS', 6], 0, function() { return 1e3*this.millisecond(); }), H(0, ['SSSSSSS', 7], 0, function() { return 1e4*this.millisecond(); }), H(0, ['SSSSSSSS', 8], 0, function() { return 1e5*this.millisecond(); }), H(0, ['SSSSSSSSS', 9], 0, function() { return 1e6*this.millisecond(); }), z('millisecond', 'ms'), N('S', Xc, Rc), N('SS', Xc, Sc), N('SSS', Xc, Tc); var Ld; for (Ld='SSSS'; Ld.length<=9; Ld+='S')N(Ld, $c); for (Ld='S'; Ld.length<=9; Ld+='S')Q(Ld, Wb); var Md=C('Milliseconds', !1); H('z', 0, 0, 'zoneAbbr'), H('zz', 0, 0, 'zoneName'); var Nd=n.prototype; Nd.add=Ad, Nd.calendar=cb, Nd.clone=db, Nd.diff=ib, Nd.endOf=ub, Nd.format=mb, Nd.from=nb, Nd.fromNow=ob, Nd.to=pb, Nd.toNow=qb, Nd.get=F, Nd.invalidAt=Cb, Nd.isAfter=eb, Nd.isBefore=fb, Nd.isBetween=gb, Nd.isSame=hb, Nd.isValid=Ab, Nd.lang=Cd, Nd.locale=rb, Nd.localeData=sb, Nd.max=wd, Nd.min=vd, Nd.parsingFlags=Bb, Nd.set=F, Nd.startOf=tb, Nd.subtract=Bd, Nd.toArray=yb, Nd.toObject=zb, Nd.toDate=xb, Nd.toISOString=lb, Nd.toJSON=lb, Nd.toString=kb, Nd.unix=wb, Nd.valueOf=vb, Nd.year=td, Nd.isLeapYear=ia, Nd.weekYear=Fb, Nd.isoWeekYear=Gb, Nd.quarter=Nd.quarters=Jb, Nd.month=Y, Nd.daysInMonth=Z, Nd.week=Nd.weeks=na, Nd.isoWeek=Nd.isoWeeks=oa, Nd.weeksInYear=Ib, Nd.isoWeeksInYear=Hb, Nd.date=Dd, Nd.day=Nd.days=Pb, Nd.weekday=Qb, Nd.isoWeekday=Rb, Nd.dayOfYear=qa, Nd.hour=Nd.hours=Id, Nd.minute=Nd.minutes=Jd, Nd.second=Nd.seconds=Kd, Nd.millisecond=Nd.milliseconds=Md, Nd.utcOffset=Na, Nd.utc=Pa, Nd.local=Qa, Nd.parseZone=Ra, Nd.hasAlignedHourOffset=Sa, Nd.isDST=Ta, Nd.isDSTShifted=Ua, Nd.isLocal=Va, Nd.isUtcOffset=Wa, Nd.isUtc=Xa, Nd.isUTC=Xa, Nd.zoneAbbr=Xb, Nd.zoneName=Yb, Nd.dates=aa('dates accessor is deprecated. Use date instead.', Dd), Nd.months=aa('months accessor is deprecated. Use month instead', Y), Nd.years=aa('years accessor is deprecated. Use year instead', td), Nd.zone=aa('moment().zone is deprecated, use moment().utcOffset instead. https://github.com/moment/moment/issues/1779', Oa); var Od=Nd, Pd={sameDay: '[Today at] LT', nextDay: '[Tomorrow at] LT', nextWeek: 'dddd [at] LT', lastDay: '[Yesterday at] LT', lastWeek: '[Last] dddd [at] LT', sameElse: 'L'}, Qd={LTS: 'h:mm:ss A', LT: 'h:mm A', L: 'MM/DD/YYYY', LL: 'MMMM D, YYYY', LLL: 'MMMM D, YYYY h:mm A', LLLL: 'dddd, MMMM D, YYYY h:mm A'}, Rd='Invalid date', Sd='%d', Td=/\d{1,2}/, Ud={future: 'in %s', past: '%s ago', s: 'a few seconds', m: 'a minute', mm: '%d minutes', h: 'an hour', hh: '%d hours', d: 'a day', dd: '%d days', M: 'a month', MM: '%d months', y: 'a year', yy: '%d years'}, Vd=s.prototype; Vd._calendar=Pd, Vd.calendar=_b, Vd._longDateFormat=Qd, Vd.longDateFormat=ac, Vd._invalidDate=Rd, Vd.invalidDate=bc, Vd._ordinal=Sd, Vd.ordinal=cc, Vd._ordinalParse=Td, Vd.preparse=dc, Vd.postformat=dc, Vd._relativeTime=Ud, Vd.relativeTime=ec, Vd.pastFuture=fc, Vd.set=gc, Vd.months=U, Vd._months=md, Vd.monthsShort=V, Vd._monthsShort=nd, Vd.monthsParse=W, Vd.week=ka, Vd._week=ud, Vd.firstDayOfYear=ma, Vd.firstDayOfWeek=la, Vd.weekdays=Lb, Vd._weekdays=Ed, Vd.weekdaysMin=Nb, Vd._weekdaysMin=Gd, Vd.weekdaysShort=Mb, Vd._weekdaysShort=Fd, Vd.weekdaysParse=Ob, Vd.isPM=Ub, Vd._meridiemParse=Hd, Vd.meridiem=Vb, w('en', {ordinalParse: /\d{1,2}(th|st|nd|rd)/, ordinal: function(a) { var b=a%10, c=1===q(a%100/10)?'th':1===b?'st':2===b?'nd':3===b?'rd':'th'; return a+c; }}), a.lang=aa('moment.lang is deprecated. Use moment.locale instead.', w), a.langData=aa('moment.langData is deprecated. Use moment.localeData instead.', y); var Wd=Math.abs, Xd=yc('ms'), Yd=yc('s'), Zd=yc('m'), $d=yc('h'), _d=yc('d'), ae=yc('w'), be=yc('M'), ce=yc('y'), de=Ac('milliseconds'), ee=Ac('seconds'), fe=Ac('minutes'), ge=Ac('hours'), he=Ac('days'), ie=Ac('months'), je=Ac('years'), ke=Math.round, le={s: 45, m: 45, h: 22, d: 26, M: 11}, me=Math.abs, ne=Ha.prototype; ne.abs=oc, ne.add=qc, ne.subtract=rc, ne.as=wc, ne.asMilliseconds=Xd, ne.asSeconds=Yd, ne.asMinutes=Zd, ne.asHours=$d, ne.asDays=_d, ne.asWeeks=ae, ne.asMonths=be, ne.asYears=ce, ne.valueOf=xc, ne._bubble=tc, ne.get=zc, ne.milliseconds=de, ne.seconds=ee, ne.minutes=fe, ne.hours=ge, ne.days=he, ne.weeks=Bc, ne.months=ie, ne.years=je, ne.humanize=Fc, ne.toISOString=Gc, ne.toString=Gc, ne.toJSON=Gc, ne.locale=rb, ne.localeData=sb, ne.toIsoString=aa('toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', Gc), ne.lang=Cd, H('X', 0, 0, 'unix'), H('x', 0, 0, 'valueOf'), N('x', _c), N('X', bd), Q('X', function(a, b, c) { c._d=new Date(1e3*parseFloat(a, 10)); }), Q('x', function(a, b, c) { c._d=new Date(q(a)); }), a.version='2.10.6', b(Da), a.fn=Od, a.min=Fa, a.max=Ga, a.utc=h, a.unix=Zb, a.months=jc, a.isDate=d, a.locale=w, a.invalid=l, a.duration=Ya, a.isMoment=o, a.weekdays=lc, a.parseZone=$b, a.localeData=y, a.isDuration=Ia, a.monthsShort=kc, a.weekdaysMin=nc, a.defineLocale=x, a.weekdaysShort=mc, a.normalizeUnits=A, a.relativeTimeThreshold=Ec; var oe=a; return oe; }); ================================================ FILE: sheets/forms/forms.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_sheets_custom_form_responses_quickstart] /** * A special function that inserts a custom menu when the spreadsheet opens. */ function onOpen() { const menu = [ { name: "Set up conference", functionName: "setUpConference_" }, ]; try { SpreadsheetApp.getActive().addMenu("Conference", menu); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * A set-up function that uses the conference data in the spreadsheet to create * Google Calendar events, a Google Form, and a trigger that allows the script * to react to form responses. */ function setUpConference_() { if (ScriptProperties.getProperty("calId")) { Browser.msgBox("Your conference is already set up. Look in Google Drive!"); } try { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName("Conference Setup"); const range = sheet.getDataRange(); const values = range.getValues(); setUpCalendar_(values, range); setUpForm_(ss, values); ScriptApp.newTrigger("onFormSubmit") .forSpreadsheet(ss) .onFormSubmit() .create(); ss.removeMenu("Conference"); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Creates a Google Calendar with events for each conference session in the * spreadsheet, then writes the event IDs to the spreadsheet for future use. * @param {Array} values Cell values for the spreadsheet range. * @param {Range} range A spreadsheet range that contains conference data. */ function setUpCalendar_(values, range) { try { const cal = CalendarApp.createCalendar("Conference Calendar"); for (let i = 1; i < values.length; i++) { const session = values[i]; const title = session[0]; const start = joinDateAndTime_(session[1], session[2]); const end = joinDateAndTime_(session[1], session[3]); const options = { location: session[4], sendInvites: true }; const event = cal .createEvent(title, start, end, options) .setGuestsCanSeeGuests(false); session[5] = event.getId(); } range.setValues(values); // Store the ID for the Calendar, which is needed to retrieve events by ID. ScriptProperties.setProperty("calId", cal.getId()); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Creates a single Date object from separate date and time cells. * * @param {Date} date A Date object from which to extract the date. * @param {Date} time A Date object from which to extract the time. * @return {Date} A Date object representing the combined date and time. */ function joinDateAndTime_(date, time) { const newDate = new Date(date); newDate.setHours(time.getHours()); newDate.setMinutes(time.getMinutes()); return newDate; } /** * Creates a Google Form that allows respondents to select which conference * sessions they would like to attend, grouped by date and start time. * * @param {Spreadsheet} ss The spreadsheet that contains the conference data. * @param {Array} values Cell values for the spreadsheet range. */ function setUpForm_(ss, values) { // Group the sessions by date and time so that they can be passed to the form. const schedule = {}; for (let i = 1; i < values.length; i++) { const session = values[i]; const day = session[1].toLocaleDateString(); const time = session[2].toLocaleTimeString(); if (!schedule[day]) { schedule[day] = {}; } if (!schedule[day][time]) { schedule[day][time] = []; } schedule[day][time].push(session[0]); } try { // Create the form and add a multiple-choice question for each timeslot. const form = FormApp.create("Conference Form"); form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); form.addTextItem().setTitle("Name").setRequired(true); form.addTextItem().setTitle("Email").setRequired(true); for (const day of schedule) { const header = form .addSectionHeaderItem() .setTitle(`Sessions for ${day}`); for (const time of schedule[day]) { const item = form .addMultipleChoiceItem() .setTitle(`${time} ${day}`) .setChoiceValues(schedule[day][time]); } } } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * A trigger-driven function that sends out calendar invitations and a * personalized Google Docs itinerary after a user responds to the form. * * @param {Object} e The event parameter for form submission to a spreadsheet; * see https://developers.google.com/apps-script/understanding_events */ function onFormSubmit(e) { const user = { name: e.namedValues.Name[0], email: e.namedValues.Email[0], }; // Grab the session data again so that we can match it to the user's choices. const response = []; try { values = SpreadsheetApp.getActive() .getSheetByName("Conference Setup") .getDataRange() .getValues(); for (let i = 1; i < values.length; i++) { const session = values[i]; const title = session[0]; const day = session[1].toLocaleDateString(); const time = session[2].toLocaleTimeString(); const timeslot = `${time} ${day}`; // For every selection in the response, find the matching timeslot and // title in the spreadsheet and add the session data to the response array. if (e.namedValues[timeslot] && e.namedValues[timeslot] === title) { response.push(session); } } sendInvites_(user, response); sendDoc_(user, response); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Add the user as a guest for every session he or she selected. * @param {object} user An object that contains the user's name and email. * @param {Array} response An array of data for the user's session choices. */ function sendInvites_(user, response) { try { const id = ScriptProperties.getProperty("calId"); const cal = CalendarApp.getCalendarById(id); for (let i = 0; i < response.length; i++) { cal.getEventSeriesById(response[i][5]).addGuest(user.email); } } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Create and share a personalized Google Doc that shows the user's itinerary. * @param {object} user An object that contains the user's name and email. * @param {Array} response An array of data for the user's session choices. */ function sendDoc_(user, response) { try { const doc = DocumentApp.create( `Conference Itinerary for ${user.name}`, ).addEditor(user.email); const body = doc.getBody(); let table = [["Session", "Date", "Time", "Location"]]; for (let i = 0; i < response.length; i++) { table.push([ response[i][0], response[i][1].toLocaleDateString(), response[i][2].toLocaleTimeString(), response[i][4], ]); } body .insertParagraph(0, doc.getName()) .setHeading(DocumentApp.ParagraphHeading.HEADING1); table = body.appendTable(table); table.getRow(0).editAsText().setBold(true); doc.saveAndClose(); // Email a link to the Doc as well as a PDF copy. MailApp.sendEmail({ to: user.email, subject: doc.getName(), body: `Thanks for registering! Here's your itinerary: ${doc.getUrl()}`, attachments: doc.getAs(MimeType.PDF), }); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } // [END apps_script_sheets_custom_form_responses_quickstart] ================================================ FILE: sheets/maps/maps.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_sheets_restaurant_locations_map] /** * Returns restaurant locations on a map. */ function restaurantLocationsMap() { // Get the sheet named 'restaurants' const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("restaurants"); // Store the restaurant name and address data in a 2-dimensional array called // restaurantInfo. This is the data in cells A2:B4 const restaurantInfo = sheet .getRange(2, 1, sheet.getLastRow() - 1, 2) .getValues(); // Create a new StaticMap const restaurantMap = Maps.newStaticMap(); // Create a new UI Application, which we use to display the map const ui = UiApp.createApplication(); // Create a grid widget to use for displaying the text of the restaurant names // and addresses. Start by populating the header row in the grid. const grid = ui.createGrid(restaurantInfo.length + 1, 3); grid.setWidget( 0, 0, ui.createLabel("Store #").setStyleAttribute("fontWeight", "bold"), ); grid.setWidget( 0, 1, ui.createLabel("Store Name").setStyleAttribute("fontWeight", "bold"), ); grid.setWidget( 0, 2, ui.createLabel("Address").setStyleAttribute("fontWeight", "bold"), ); // For each entry in restaurantInfo, create a map marker with the address and // the style we want. Also add the address info for this restaurant to the // grid widget. for (let i = 0; i < restaurantInfo.length; i++) { restaurantMap.setMarkerStyle( Maps.StaticMap.MarkerSize.MID, Maps.StaticMap.Color.GREEN, i + 1, ); restaurantMap.addMarker(restaurantInfo[i][1]); grid.setWidget(i + 1, 0, ui.createLabel((i + 1).toString())); grid.setWidget(i + 1, 1, ui.createLabel(restaurantInfo[i][0])); grid.setWidget(i + 1, 2, ui.createLabel(restaurantInfo[i][1])); } // Create a Flow Panel widget. We add the map and the grid to this panel. // The height needs to be able to accomodate the number of restaurants, so we // use a calculation to scale it based on the number of restaurants. const panel = ui .createFlowPanel() .setSize("500px", `${515 + restaurantInfo.length * 25}px`); // Get the URL of the restaurant map and use that to create an image and add // it to the panel. Next add the grid to the panel. panel.add(ui.createImage(restaurantMap.getMapUrl())); panel.add(grid); // Finally, add the panel widget to our UI instance, and set its height, // width, and title. ui.add(panel); ui.setHeight(515 + restaurantInfo.length * 25); ui.setWidth(500); ui.setTitle("Restaurant Locations"); // Make the UI visible in the spreadsheet. SpreadsheetApp.getActiveSpreadsheet().show(ui); } // [END apps_script_sheets_restaurant_locations_map] // [START apps_script_sheets_driving_directions] /** * Gets driving directions from Mountain View to San Francisco. * Displays a map inside Google Spreadsheets. */ function getDrivingDirections() { // Set starting and ending addresses const start = "1600 Amphitheatre Pkwy, Mountain View, CA 94043"; const end = "345 Spear St, San Francisco, CA 94105"; // These regular expressions will be used to strip out // unneeded HTML tags const r1 = //g; const r2 = /<\/b>/g; const r3 = /
/g; const r4 = /<\/div>/g; // points is used for storing the points in the step-by-step directions let points = []; // currentLabel is used for number the steps in the directions let currentLabel = 0; // This will be the map on which we display the path const map = Maps.newStaticMap().setSize(500, 350); // Create a new UI Application, which we use to display the map const ui = UiApp.createApplication(); // Create a Flow Panel widget, which we use for the directions text const directionsPanel = ui.createFlowPanel(); // Create a new DirectionFinder with our start and end addresses, and request the directions // The response is a JSON object, which contains the directions const directions = Maps.newDirectionFinder() .setOrigin(start) .setDestination(end) .getDirections(); // Much of this code is based on the template referenced in // http://googleappsdeveloper.blogspot.com/2010/06/automatically-generate-maps-and.html for (const i in directions.routes) { for (const j in directions.routes[i].legs) { for (const k in directions.routes[i].legs[j].steps) { // Parse out the current step in the directions const step = directions.routes[i].legs[j].steps[k]; // Call Maps.decodePolyline() to decode the polyline for // this step into an array of latitudes and longitudes const path = Maps.decodePolyline(step.polyline.points); points = points.concat(path); // Pull out the direction information from step.html_instructions // Because we only want to display text, we will strip out the // HTML tags that are present in the html_instructions let text = step.html_instructions; text = text.replace(r1, " "); text = text.replace(r2, " "); text = text.replace(r3, " "); text = text.replace(r4, " "); // Add each step in the directions to the directionsPanel directionsPanel.add(ui.createLabel(`${++currentLabel} - ${text}`)); } } } // be conservative and only sample 100 times to create our polyline path let lpoints = []; if (points.length < 200) { lpoints = points; } else { const pCount = points.length / 2; const step = Number.parseInt(pCount / 100); for (let i = 0; i < 100; ++i) { lpoints.push(points[i * step * 2]); lpoints.push(points[i * step * 2 + 1]); } } // make the polyline if (lpoints.length > 0) { // Maps.encodePolyline turns an array of latitudes and longitudes // into an encoded polyline const pline = Maps.encodePolyline(lpoints); // Once we have the encoded polyline, add that path to the map map.addPath(pline); } // Create a FlowPanel to hold the map const panel = ui.createFlowPanel().setSize("500px", "350px"); // Get the URL of the map and use that to create an image and add // it to the panel. panel.add(ui.createImage(map.getMapUrl())); // Add both the map panel and the directions panel to the UI instance ui.add(panel); ui.add(directionsPanel); // Next set the title, height, and width of the UI instance ui.setTitle("Driving Directions"); ui.setHeight(525); ui.setWidth(500); // Finally, display the UI within the spreadsheet SpreadsheetApp.getActiveSpreadsheet().show(ui); } // [END apps_script_sheets_driving_directions] // [START apps_script_sheets_analyze_locations] /** * Analyzes locations of Google offices. */ function analyzeLocations() { // Select the sheet named 'geocoder and elevation' const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( "geocoder and elevation", ); // Store the address data in an array called // locationInfo. This is the data in cells A2:A20 const locationInfo = sheet .getRange(2, 1, sheet.getLastRow() - 1, 1) .getValues(); // Set up some values to use for comparisons. // latitudes run from -90 to 90, so we start with a max of -90 for comparison let maxLatitude = -90; let indexOfMaxLatitude = 0; // Set the starting max elevation to 0, or sea level let maxElevation = 0; let indexOfMaxElevation = 0; // geoResults will hold the JSON results array that we get when calling geocode() let geoResults; // elevationResults will hold the results object that we get when calling sampleLocation() let elevationResults; // lat and lng will temporarily hold the latitude and longitude of each // address let lat; let lng; for (let i = 0; i < locationInfo.length; i++) { // Get the latitude and longitude for an address. For more details on // the JSON results array, geoResults, see // http://code.google.com/apis/maps/documentation/geocoding/#Results geoResults = Maps.newGeocoder().geocode(locationInfo[i]); // Get the latitude and longitude lat = geoResults.results[0].geometry.location.lat; lng = geoResults.results[0].geometry.location.lng; // Use the latitude and longitude to call sampleLocation and get the // elevation. For more details on the JSON-formatted results object, // elevationResults, see // http://code.google.com/apis/maps/documentation/elevation/#ElevationResponses elevationResults = Maps.newElevationSampler().sampleLocation( Number.parseFloat(lat), Number.parseFloat(lng), ); // Check to see if the current latitude is greater than our max latitude // so far. If so, set maxLatitude and indexOfMaxLatitude if (lat > maxLatitude) { maxLatitude = lat; indexOfMaxLatitude = i; } // Check if elevationResults has a good status and also if the current // elevation is greater than the max elevation so far. If so, set // maxElevation and indexOfMaxElevation if ( elevationResults.status === "OK" && elevationResults.results[0].elevation > maxElevation ) { maxElevation = elevationResults.results[0].elevation; indexOfMaxElevation = i; } } // User Browser.msgBox as a simple way to display the info about highest // elevation and northernmost office. Browser.msgBox( `The US Google office with the highest elevation is: ${locationInfo[indexOfMaxElevation]}. The northernmost US Google office is: ${locationInfo[indexOfMaxLatitude]}`, ); } // [END apps_script_sheets_analyze_locations] ================================================ FILE: sheets/next18/.claspignore ================================================ README.md ================================================ FILE: sheets/next18/Constants.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* Salesforce config */ // Salesforce OAuth configuration, which you get by creating a developer project // with OAuth authentication on Salesforce. const SALESFORCE_CLIENT_ID = ""; const SALESFORCE_CLIENT_SECRET = ""; // The Salesforce instance to talk to. const SALESFORCE_INSTANCE = "na1"; /* Invoice generation config */ // The ID of a Google Doc that is used as a template. Defaults to // https://docs.google.com/document/d/1awKvXXMOQomdD68PGMpP5j1kNZwk_2Z0wBbwUgjKKws/view const INVOICE_TEMPLATE = "1awKvXXMOQomdD68PGMpP5j1kNZwk_2Z0wBbwUgjKKws"; // The ID of a Drive folder that the generated invoices are created in. Create // a new folder that your Google account has edit access to. const INVOICES_FOLDER = ""; ================================================ FILE: sheets/next18/Invoice.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Generates invoices based on the selected rows in the spreadsheet. Assumes * that the Salesforce accountId is in the first selected column and the * amount owed is the 4th selected column. */ function generateInvoices() { const range = SpreadsheetApp.getActiveRange(); const values = range.getDisplayValues(); const sheet = SpreadsheetApp.getActiveSheet(); for (let i = 0; i < values.length; i++) { const row = values[i]; const accountId = row[0]; const amount = row[3]; const invoiceUrl = generateInvoice(accountId, amount); sheet .getRange(range.getRow() + i, range.getLastColumn() + 1) .setValue(invoiceUrl); } } /** * Generates a single invoice in Google Docs for a given Salesforce account and * an owed amount. * * @param {string} accountId The Salesforce account Id to invoice * @param {string} amount The owed amount to invoice * @return {string} the url of the created invoice */ function generateInvoice(accountId, amount) { const folder = DriveApp.getFolderById(INVOICES_FOLDER); const copied = DriveApp.getFileById(INVOICE_TEMPLATE).makeCopy( `Invoice for ${accountId}`, folder, ); const invoice = DocumentApp.openById(copied.getId()); const results = fetchSoqlResults( `select Name, BillingAddress from Account where Id = '${accountId}'`, ); const account = results.records[0]; invoice.getBody().replaceText("{{account name}}", account.Name); invoice .getBody() .replaceText("{{account address}}", account.BillingAddress.street); invoice .getBody() .replaceText( "{{date}}", Utilities.formatDate(new Date(), "GMT", "yyyy-MM-dd"), ); invoice.getBody().replaceText("{{amount}}", amount); invoice.saveAndClose(); return invoice.getUrl(); } /** * Generates a report in Google Slides with a chart generated from the sheet. */ function generateReport() { const sheet = SpreadsheetApp.getActiveSheet(); const chart = sheet .newChart() .asColumnChart() .addRange(sheet.getRange("A:A")) .addRange(sheet.getRange("C:D")) .setNumHeaders(1) .setMergeStrategy(Charts.ChartMergeStrategy.MERGE_COLUMNS) .setOption("useFirstColumnAsDomain", true) .setOption("isStacked", "absolute") .setOption("title", "Expected Payments") .setOption("treatLabelsAsText", false) .setXAxisTitle("AccountId") .setPosition(3, 1, 114, 138) .build(); sheet.insertChart(chart); // Force the chart to be created before adding it to the presentation SpreadsheetApp.flush(); const preso = SlidesApp.create("Invoicing Report"); const titleSlide = preso.getSlides()[0]; const titleShape = titleSlide .getPlaceholder(SlidesApp.PlaceholderType.CENTERED_TITLE) .asShape(); titleShape.getText().setText("Invoicing Report"); const newSlide = preso.appendSlide(SlidesApp.PredefinedLayout.BLANK); newSlide.insertSheetsChart(chart); showLinkDialog(preso.getUrl(), "Open report", "Report created"); } ================================================ FILE: sheets/next18/LinkDialog.html ================================================
================================================ FILE: sheets/next18/README.md ================================================ # Invoicing Demo for Google Sheets This sample was created for a talk for Google Cloud NEXT'18 entitled "Building on the Docs Editors: APIs and Apps Script". It is an implementation of a Google Sheets add-on that: * Authenticates with Salesforce via OAuth2, using the [Apps Script OAuth2 library](https://github.com/googleworkspace/apps-script-oauth2). * Runs [SOQL](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_sosl_intro.htm) queries against Salesforce and outputs the results into a new sheet * Creates invoices in Google Docs and a sample presentation in Google Slides using the imported data. ![Demo gif](demo.gif?raw=true "Demo") ## Getting started * Install [clasp](https://github.com/google/clasp) * Run `clasp create ================================================ FILE: slides/translate/translate.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_slides_translate_quickstart] /** * @OnlyCurrentDoc Limits the script to only accessing the current presentation. */ /** * Create a open translate menu item. * @param {Event} event The open event. */ function onOpen(event) { SlidesApp.getUi() .createAddonMenu() .addItem("Open Translate", "showSidebar") .addToUi(); } /** * Open the Add-on upon install. * @param {Event} event The install event. */ function onInstall(event) { onOpen(event); } /** * Opens a sidebar in the document containing the add-on's user interface. */ function showSidebar() { const ui = HtmlService.createHtmlOutputFromFile("sidebar").setTitle("Translate"); SlidesApp.getUi().showSidebar(ui); } /** * Recursively gets child text elements a list of elements. * @param {PageElement[]} elements The elements to get text from. * @return {Text[]} An array of text elements. */ function getElementTexts(elements) { let texts = []; for (const element of elements) { switch (element.getPageElementType()) { case SlidesApp.PageElementType.GROUP: for (const child of element.asGroup().getChildren()) { texts = texts.concat(getElementTexts(child)); } break; case SlidesApp.PageElementType.TABLE: { const table = element.asTable(); for (let r = 0; r < table.getNumRows(); ++r) { for (let c = 0; c < table.getNumColumns(); ++c) { texts.push(table.getCell(r, c).getText()); } } break; } case SlidesApp.PageElementType.SHAPE: texts.push(element.asShape().getText()); break; } } return texts; } /** * Translates selected slide elements to the target language using Apps Script's Language service. * * @param {string} targetLanguage The two-letter short form for the target language. (ISO 639-1) * @return {number} The number of elements translated. */ function translateSelectedElements(targetLanguage) { // Get selected elements. const selection = SlidesApp.getActivePresentation().getSelection(); const selectionType = selection.getSelectionType(); let texts = []; switch (selectionType) { case SlidesApp.SelectionType.PAGE: for (const page of selection.getPageRange().getPages()) { texts = texts.concat(getElementTexts(page.getPageElements())); } break; case SlidesApp.SelectionType.PAGE_ELEMENT: { const pageElements = selection.getPageElementRange().getPageElements(); texts = texts.concat(getElementTexts(pageElements)); break; } case SlidesApp.SelectionType.TABLE_CELL: for (const cell of selection.getTableCellRange().getTableCells()) { texts.push(cell.getText()); } break; case SlidesApp.SelectionType.TEXT: for (const element of selection.getPageElementRange().getPageElements()) { texts.push(element.asShape().getText()); } break; } // Translate all elements in-place. for (const text of texts) { text.setText( LanguageApp.translate(text.asRenderedString(), "", targetLanguage), ); } return texts.length; } // [END apps_script_slides_translate_quickstart] ================================================ FILE: solutions/add-on/book-smartchip/.clasp.json ================================================ { "scriptId": "14tK6PD4C624ivRyGk-S6eYCbYJnDfA24xeP0Jhb1U8sPgAvZXeZm5gpb" } ================================================ FILE: solutions/add-on/book-smartchip/Code.js ================================================ /** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ function getBook(id) { const apiKey = "YOUR_API_KEY"; // Replace with your API key const apiEndpoint = `https://www.googleapis.com/books/v1/volumes/${id}?key=${apiKey}&country=US`; const response = UrlFetchApp.fetch(apiEndpoint); return JSON.parse(response); } function bookLinkPreview(event) { if (event.docs.matchedUrl.url) { const segments = event.docs.matchedUrl.url.split("/"); const volumeID = segments[segments.length - 1]; const bookData = getBook(volumeID); const bookTitle = bookData.volumeInfo.title; const bookDescription = bookData.volumeInfo.description; const bookImage = bookData.volumeInfo.imageLinks.small; const bookAuthors = bookData.volumeInfo.authors; const bookPageCount = bookData.volumeInfo.pageCount; const previewHeader = CardService.newCardHeader() .setSubtitle(`By ${bookAuthors}`) .setTitle(bookTitle); const previewPages = CardService.newDecoratedText() .setTopLabel("Page count") .setText(bookPageCount); const previewDescription = CardService.newDecoratedText() .setTopLabel("About this book") .setText(bookDescription) .setWrapText(true); const previewImage = CardService.newImage() .setAltText("Image of book cover") .setImageUrl(bookImage); const buttonBook = CardService.newTextButton() .setText("View book") .setOpenLink(CardService.newOpenLink().setUrl(event.docs.matchedUrl.url)); const cardSectionBook = CardService.newCardSection() .addWidget(previewImage) .addWidget(previewPages) .addWidget(CardService.newDivider()) .addWidget(previewDescription) .addWidget(buttonBook); return CardService.newCardBuilder() .setHeader(previewHeader) .addSection(cardSectionBook) .build(); } } ================================================ FILE: solutions/add-on/book-smartchip/README.md ================================================ # Preview links from Google Books with smart chips See https://developers.google.com/workspace/add-ons/samples/preview-links-google-books for additional details. ================================================ FILE: solutions/add-on/book-smartchip/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/workspace.linkpreview", "https://www.googleapis.com/auth/script.external_request" ], "urlFetchWhitelist": ["https://www.googleapis.com/books/v1/volumes/"], "addOns": { "common": { "name": "Preview Books Add-on", "logoUrl": "https://developers.google.com/workspace/add-ons/images/library-icon.png", "layoutProperties": { "primaryColor": "#dd4b39" } }, "docs": { "linkPreviewTriggers": [ { "runFunction": "bookLinkPreview", "patterns": [ { "hostPattern": "*.google.*", "pathPrefix": "books" }, { "hostPattern": "*.google.*", "pathPrefix": "books/edition" } ], "labelText": "Book", "logoUrl": "https://developers.google.com/workspace/add-ons/images/book-icon.png", "localizedLabelText": { "es": "Libros" } } ] } } } ================================================ FILE: solutions/add-on/share-macro/.clasp.json ================================================ { "scriptId": "1BsbWOAbLADGoLtp5P9oqctZMiqT5EFh_R-CufxAV9y1hvVSAMO35Azu9" } ================================================ FILE: solutions/add-on/share-macro/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.devsite.corp.google.com/apps-script/add-ons/share-macro /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Uses Apps Script API to copy source Apps Script project * to destination Google Spreadsheet container. * * @param {string} sourceScriptId - Script ID of the source project. * @param {string} targetSpreadsheetUrl - URL if the target spreadsheet. */ function shareMacro_(sourceScriptId, targetSpreadsheetUrl) { // Gets the source project content using the Apps Script API. const sourceProject = APPS_SCRIPT_API.get(sourceScriptId); const sourceFiles = APPS_SCRIPT_API.getContent(sourceScriptId); // Opens the target spreadsheet and gets its ID. const parentSSId = SpreadsheetApp.openByUrl(targetSpreadsheetUrl).getId(); // Creates an Apps Script project that's bound to the target spreadsheet. const targetProjectObj = APPS_SCRIPT_API.create( sourceProject.title, parentSSId, ); // Updates the Apps Script project with the source project content. APPS_SCRIPT_API.updateContent(targetProjectObj.scriptId, sourceFiles); } /** * Function that encapsulates Apps Script API project manipulation. */ const APPS_SCRIPT_API = { accessToken: ScriptApp.getOAuthToken(), /* APPS_SCRIPT_API.get * Gets Apps Script source project. * @param {string} scriptId - Script ID of the source project. * @return {Object} - JSON representation of source project. */ get: function (scriptId) { const url = `https://script.googleapis.com/v1/projects/${scriptId}`; const options = { method: "get", headers: { Authorization: `Bearer ${this.accessToken}`, }, muteHttpExceptions: true, }; const res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() === 200) { return JSON.parse(res); } console.log("An error occurred gettting the project details"); console.log(res.getResponseCode()); console.log(res.getContentText()); console.log(res); return false; }, /* APPS_SCRIPT_API.create * Creates new Apps Script project in the target spreadsheet. * @param {string} title - Name of Apps Script project. * @param {string} parentId - Internal ID of target spreadsheet. * @return {Object} - JSON representation completed project creation. */ create: function (title, parentId) { const url = "https://script.googleapis.com/v1/projects"; const options = { headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json", }, muteHttpExceptions: true, method: "POST", payload: { title: title }, }; if (parentId) { options.payload.parentId = parentId; } options.payload = JSON.stringify(options.payload); let res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() === 200) { res = JSON.parse(res); return res; } console.log("An error occurred while creating the project"); console.log(res.getResponseCode()); console.log(res.getContentText()); console.log(res); return false; }, /* APPS_SCRIPT_API.getContent * Gets the content of the source Apps Script project. * @param {string} scriptId - Script ID of the source project. * @return {Object} - JSON representation of Apps Script project content. */ getContent: function (scriptId) { const url = `https://script.googleapis.com/v1/projects/${scriptId}/content`; const options = { method: "get", headers: { Authorization: `Bearer ${this.accessToken}`, }, muteHttpExceptions: true, }; let res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() === 200) { res = JSON.parse(res); return res.files; } console.log( "An error occurred obtaining the content from the source script", ); console.log(res.getResponseCode()); console.log(res.getContentText()); console.log(res); return false; }, /* APPS_SCRIPT_API.updateContent * Updates (copies) content from source to target Apps Script project. * @param {string} scriptId - Script ID of the source project. * @param {Object} files - JSON representation of Apps Script project content. * @return {boolean} - Result status of the function. */ updateContent: function (scriptId, files) { const url = `https://script.googleapis.com/v1/projects/${scriptId}/content`; const options = { method: "put", headers: { Authorization: `Bearer ${this.accessToken}`, }, contentType: "application/json", payload: JSON.stringify({ files: files }), muteHttpExceptions: true, }; const res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() === 200) { return true; } console.log(`An error occurred updating content of script ${scriptId}`); console.log(res.getResponseCode()); console.log(res.getContentText()); console.log(res); return false; }, }; ================================================ FILE: solutions/add-on/share-macro/README.md ================================================ # Copy macros to other spreadsheets See [developers.google.com](https://developers.google.com/apps-script/add-ons/share-macro) for additional details. ================================================ FILE: solutions/add-on/share-macro/UI.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Change application logo here (and in manifest) as desired. const ADDON_LOGO = "https://www.gstatic.com/images/branding/product/2x/apps_script_48dp.png"; /** * Callback function for rendering the main card. * @return {CardService.Card} The card to show the user. */ function onHomepage(e) { return createSelectionCard(e); } /** * Builds the primary card interface used to collect user inputs. * * @param {Object} e - Add-on event object. * @param {string} sourceScriptId - Script ID of the source project. * @param {string} targetSpreadsheetUrl - URL of the target spreadsheet. * @param {string[]} errors - Array of error messages. * * @return {CardService.Card} The card to show to the user for inputs. */ function createSelectionCard(e, sourceScriptId, targetSpreadsheetUrl, errors) { // Configures card header. const cardHeader = CardService.newCardHeader() .setTitle("Share macros with other spreadheets!") .setImageUrl(ADDON_LOGO) .setImageStyle(CardService.ImageStyle.SQUARE); // If form errors exist, configures section with error messages. let showErrors = false; if (errors?.length) { showErrors = true; let msg = errors.reduce((str, err) => `${str}• ${err}
`, ""); msg = `Form submission errors:
${msg}`; // Builds error message section. sectionErrors = CardService.newCardSection().addWidget( CardService.newDecoratedText().setText(msg).setWrapText(true), ); } // Configures source project section. const sectionSource = CardService.newCardSection() .addWidget( CardService.newDecoratedText().setText( "Source macro
The Apps Script project to copy", ), ) .addWidget( CardService.newTextInput() .setFieldName("sourceScriptId") .setValue(sourceScriptId || "") .setTitle("Script ID of the source macro") .setHint( "You must have at least edit permission for the source spreadsheet to access its script project", ), ) .addWidget( CardService.newTextButton() .setText("Find the script ID") .setOpenLink( CardService.newOpenLink() .setUrl( "https://developers.google.com/apps-script/api/samples/execute", ) .setOpenAs(CardService.OpenAs.FULL_SIZE) .setOnClose(CardService.OnClose.NOTHING), ), ); // Configures target spreadsheet section. const sectionTarget = CardService.newCardSection() .addWidget( CardService.newDecoratedText().setText("Target spreadsheet"), ) .addWidget( CardService.newTextInput() .setFieldName("targetSpreadsheetUrl") .setValue(targetSpreadsheetUrl || "") .setHint( "You must have at least edit permission for the target spreadsheet", ) .setTitle("Target spreadsheet URL"), ); // Configures help section. const sectionHelp = CardService.newCardSection() .addWidget( CardService.newDecoratedText() .setText( "NOTE: " + "The Apps Script API must be turned on.", ) .setWrapText(true), ) .addWidget( CardService.newTextButton() .setText("Turn on Apps Script API") .setOpenLink( CardService.newOpenLink() .setUrl("https://script.google.com/home/usersettings") .setOpenAs(CardService.OpenAs.FULL_SIZE) .setOnClose(CardService.OnClose.NOTHING), ), ); // Configures card footer with action to copy the macro. const cardFooter = CardService.newFixedFooter().setPrimaryButton( CardService.newTextButton() .setText("Share macro") .setOnClickAction( CardService.newAction().setFunctionName("onClickFunction_"), ), ); // Begins building the card. const builder = CardService.newCardBuilder().setHeader(cardHeader); // Adds error section if applicable. if (showErrors) { builder.addSection(sectionErrors); } // Adds final sections & footer. builder .addSection(sectionSource) .addSection(sectionTarget) .addSection(sectionHelp) .setFixedFooter(cardFooter); return builder.build(); } /** * Action handler that validates user inputs and calls shareMacro_ * function to copy Apps Script project to target spreadsheet. * * @param {Object} e - Add-on event object. * * @return {CardService.Card} Responds with either a success or error card. */ function onClickFunction_(e) { const sourceScriptId = e.formInput.sourceScriptId; const targetSpreadsheetUrl = e.formInput.targetSpreadsheetUrl; // Validates inputs for errors. const errors = []; // Pushes an error message if the Script ID parameter is missing. if (!sourceScriptId) { errors.push("Missing script ID"); } else { // Gets the Apps Script project if the Script ID parameter is valid. const sourceProject = APPS_SCRIPT_API.get(sourceScriptId); if (!sourceProject) { // Pushes an error message if the Script ID parameter isn't valid. errors.push("Invalid script ID"); } } // Pushes an error message if the spreadsheet URL is missing. if (!targetSpreadsheetUrl) { errors.push("Missing Spreadsheet URL"); } else try { // Tests for valid spreadsheet URL to get the spreadsheet ID. const ssId = SpreadsheetApp.openByUrl(targetSpreadsheetUrl).getId(); } catch (err) { // Pushes an error message if the spreadsheet URL parameter isn't valid. errors.push("Invalid spreadsheet URL"); } if (errors?.length) { // Redisplays form if inputs are missing or invalid. return createSelectionCard(e, sourceScriptId, targetSpreadsheetUrl, errors); } // Calls shareMacro function to copy the project. shareMacro_(sourceScriptId, targetSpreadsheetUrl); // Creates a success card to display to users. return buildSuccessCard(e, targetSpreadsheetUrl); } /** * Builds success card to inform user & let them open the spreadsheet. * * @param {Object} e - Add-on event object. * @param {string} targetSpreadsheetUrl - URL of the target spreadsheet. * * @return {CardService.Card} Returns success card. */ function buildSuccessCard(e, targetSpreadsheetUrl) { // Configures card header. const cardHeader = CardService.newCardHeader() .setTitle("Share macros with other spreadsheets!") .setImageUrl(ADDON_LOGO) .setImageStyle(CardService.ImageStyle.SQUARE); // Configures card body section with success message and open button. const sectionBody1 = CardService.newCardSection() .addWidget( CardService.newTextParagraph().setText("Sharing process is complete!"), ) .addWidget( CardService.newTextButton() .setText("Open spreadsheet") .setOpenLink( CardService.newOpenLink() .setUrl(targetSpreadsheetUrl) .setOpenAs(CardService.OpenAs.FULL_SIZE) .setOnClose(CardService.OnClose.RELOAD_ADD_ON), ), ); const sectionBody2 = CardService.newCardSection() .addWidget( CardService.newTextParagraph().setText( "If you don't see the copied project in your target spreadsheet," + " make sure you turned on the Apps Script API in the Apps Script dashboard.", ), ) .addWidget( CardService.newTextButton() .setText("Check API") .setOpenLink( CardService.newOpenLink() .setUrl("https://script.google.com/home/usersettings") .setOpenAs(CardService.OpenAs.FULL_SIZE) .setOnClose(CardService.OnClose.RELOAD_ADD_ON), ), ); // Configures the card footer with action to start new process. const cardFooter = CardService.newFixedFooter().setPrimaryButton( CardService.newTextButton() .setText("Share another") .setOnClickAction(CardService.newAction().setFunctionName("onHomepage")), ); const builder = CardService.newCardBuilder() .setHeader(cardHeader) .addSection(sectionBody1) .addSection(sectionBody2) .setFixedFooter(cardFooter); return builder.build(); } ================================================ FILE: solutions/add-on/share-macro/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [ "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/script.projects" ], "urlFetchWhitelist": ["https://script.googleapis.com/"], "addOns": { "common": { "name": "Share Macro", "logoUrl": "https://www.gstatic.com/images/branding/product/2x/apps_script_48dp.png", "layoutProperties": { "primaryColor": "#188038", "secondaryColor": "#34a853" }, "homepageTrigger": { "runFunction": "onHomepage" } }, "sheets": {} } } ================================================ FILE: solutions/automations/agenda-maker/.clasp.json ================================================ { "scriptId": "147xVWUWmw8b010zbiDMIa3eeKATo3P2q5rJCZmY3meirC-yA_XucdZlp" } ================================================ FILE: solutions/automations/agenda-maker/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/agenda-maker /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Checks if the folder for Agenda docs exists, and creates it if it doesn't. * * @return {*} Drive folder ID for the app. */ function checkFolder() { const folders = DriveApp.getFoldersByName("Agenda Maker - App"); // Finds the folder if it exists while (folders.hasNext()) { const folder = folders.next(); if ( folder.getDescription() === "Apps Script App - Do not change this description" && folder.getOwner().getEmail() === Session.getActiveUser().getEmail() ) { return folder.getId(); } } // If the folder doesn't exist, creates one const folder = DriveApp.createFolder("Agenda Maker - App"); folder.setDescription("Apps Script App - Do not change this description"); return folder.getId(); } /** * Finds the template agenda doc, or creates one if it doesn't exist. */ function getTemplateId(folderId) { const folder = DriveApp.getFolderById(folderId); const files = folder.getFilesByName("Agenda TEMPLATE##"); // If there is a file, returns the ID. while (files.hasNext()) { const file = files.next(); return file.getId(); } // Otherwise, creates the agenda template. // You can adjust the default template here const doc = DocumentApp.create("Agenda TEMPLATE##"); const body = doc.getBody(); body .appendParagraph("##Attendees##") .setHeading(DocumentApp.ParagraphHeading.HEADING1) .editAsText() .setBold(true); body.appendParagraph(" ").editAsText().setBold(false); body .appendParagraph("Overview") .setHeading(DocumentApp.ParagraphHeading.HEADING1) .editAsText() .setBold(true); body.appendParagraph(" "); body.appendParagraph("- Topic 1: ").editAsText().setBold(true); body.appendParagraph(" ").editAsText().setBold(false); body.appendParagraph("- Topic 2: ").editAsText().setBold(true); body.appendParagraph(" ").editAsText().setBold(false); body.appendParagraph("- Topic 3: ").editAsText().setBold(true); body.appendParagraph(" ").editAsText().setBold(false); body .appendParagraph("Next Steps") .setHeading(DocumentApp.ParagraphHeading.HEADING1) .editAsText() .setBold(true); body.appendParagraph("- Takeaway 1: ").editAsText().setBold(true); body.appendParagraph("- Responsible: ").editAsText().setBold(false); body.appendParagraph("- Accountable: "); body.appendParagraph("- Consult: "); body.appendParagraph("- Inform: "); body.appendParagraph(" "); body.appendParagraph("- Takeaway 2: ").editAsText().setBold(true); body.appendParagraph("- Responsible: ").editAsText().setBold(false); body.appendParagraph("- Accountable: "); body.appendParagraph("- Consult: "); body.appendParagraph("- Inform: "); body.appendParagraph(" "); body.appendParagraph("- Takeaway 3: ").editAsText().setBold(true); body.appendParagraph("- Responsible: ").editAsText().setBold(false); body.appendParagraph("- Accountable: "); body.appendParagraph("- Consult: "); body.appendParagraph("- Inform: "); doc.saveAndClose(); folder.addFile(DriveApp.getFileById(doc.getId())); return doc.getId(); } /** * When there is a change to the calendar, searches for events that include "#agenda" * in the decrisption. * */ function onCalendarChange() { // Gets recent events with the #agenda tag const now = new Date(); const events = CalendarApp.getEvents( now, new Date(now.getTime() + 2 * 60 * 60 * 1000000), { search: "#agenda" }, ); const folderId = checkFolder(); const templateId = getTemplateId(folderId); const folder = DriveApp.getFolderById(folderId); // Loops through any events found for (i = 0; i < events.length; i++) { const event = events[i]; // Confirms whether the event has the #agenda tag let description = event.getDescription(); if (description.search("#agenda") === -1) continue; // Only works with events created by the owner of this calendar if (event.isOwnedByMe()) { // Creates a new document from the template for an agenda for this event const newDoc = DriveApp.getFileById(templateId).makeCopy(); newDoc.setName(`Agenda for ${event.getTitle()}`); const file = DriveApp.getFileById(newDoc.getId()); folder.addFile(file); const doc = DocumentApp.openById(newDoc.getId()); const body = doc.getBody(); // Fills in the template with information about the attendees from the // calendar event const conf = body.findText("##Attendees##"); if (conf) { const ref = conf.getStartOffset(); for (const i in event.getGuestList()) { const guest = event.getGuestList()[i]; body.insertParagraph(ref + 2, guest.getEmail()); } body.replaceText("##Attendees##", "Attendees"); } // Replaces the tag with a link to the agenda document const agendaUrl = `https://docs.google.com/document/d/${newDoc.getId()}`; description = description.replace( "#agenda", `Agenda Doc`, ); event.setDescription(description); // Invites attendees to the Google doc so they automatically receive access to the agenda newDoc.addEditor(newDoc.getOwner()); for (const i in event.getGuestList()) { const guest = event.getGuestList()[i]; newDoc.addEditor(guest.getEmail()); } } } return; } /** * Creates an event-driven trigger that fires whenever there's a change to the calendar. */ function setUp() { const email = Session.getActiveUser().getEmail(); ScriptApp.newTrigger("onCalendarChange") .forUserCalendar(email) .onEventUpdated() .create(); } ================================================ FILE: solutions/automations/agenda-maker/README.md ================================================ # Make an agenda for meetings See [developers.google.com](https://developers.google.com/apps-script/samples/automations/agenda-maker) for additional details. ================================================ FILE: solutions/automations/agenda-maker/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/aggregate-document-content/.clasp.json ================================================ { "scriptId": "1YGstQLxmTcAQlSHfm0yke12Y2UgT8eVfCxrG_jGpG1dHDmFdOaHQfQZJ" } ================================================ FILE: solutions/automations/aggregate-document-content/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/aggregate-document-content /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This file containts the main application functions that import data from * summary documents into the body of the main document. */ // Application constants const APP_TITLE = "Document summary importer"; // Application name const PROJECT_FOLDER_NAME = "Project statuses"; // Drive folder for the source files. // Below are the parameters used to identify which content to import from the source documents // and which content has already been imported. const FIND_TEXT_KEYWORDS = "Summary"; // String that must be found in the heading above the table (case insensitive). const APP_STYLE = DocumentApp.ParagraphHeading.HEADING3; // Style that must be applied to heading above the table. const TEXT_COLOR = "#2e7d32"; // Color applied to heading after import to avoid duplication. /** * Updates the main document, importing content from the source files. * Uses the above parameters to locate content to be imported. * * Called from menu option. */ function performImport() { // Gets the folder in Drive associated with this application. const folder = getFolderByName_(PROJECT_FOLDER_NAME); // Gets the Google Docs files found in the folder. const files = getFiles(folder); // Warns the user if the folder is empty. const ui = DocumentApp.getUi(); if (files.length === 0) { const msg = `No files found in the folder '${PROJECT_FOLDER_NAME}'. Run '${MENU.SETUP}' | '${MENU.SAMPLES}' from the menu if you'd like to create samples files.`; ui.alert(APP_TITLE, msg, ui.ButtonSet.OK); return; } /** Processes main document */ // Gets the active document and body section. const docTarget = DocumentApp.getActiveDocument(); const docTargetBody = docTarget.getBody(); // Appends import summary section to the end of the target document. // Adds a horizontal line and a header with today's date and a title string. docTargetBody.appendHorizontalRule(); const dateString = Utilities.formatDate( new Date(), Session.getScriptTimeZone(), "MMMM dd, yyyy", ); const headingText = `Imported: ${dateString}`; docTargetBody.appendParagraph(headingText).setHeading(APP_STYLE); // Appends a blank paragraph for spacing. docTargetBody.appendParagraph(" "); /** Process source documents */ // Iterates through each source document in the folder. // Copies and pastes new updates to the main document. const noContentList = []; let numUpdates = 0; for (const id of files) { // Opens source document; get info and body. const docOpen = DocumentApp.openById(id); const docName = docOpen.getName(); const docHtml = docOpen.getUrl(); const docBody = docOpen.getBody(); // Gets summary content from document and returns as object {content:content} const content = getContent(docBody); // Logs if document doesn't contain content to be imported. if (!content) { noContentList.push(docName); continue; } numUpdates++; // Inserts content into the main document. // Appends a title/url reference link back to source document. docTargetBody .appendParagraph("") .appendText(`${docName}`) .setLinkUrl(docHtml); // Appends a single-cell table and pastes the content. docTargetBody.appendTable(content); docOpen.saveAndClose(); } /** Provides an import summary */ docTarget.saveAndClose(); let msg = `Number of documents updated: ${numUpdates}`; if (noContentList.length !== 0) { msg += "\n\nThe following documents had no updates:"; for (const file of noContentList) { msg += `\n ${file}`; } } ui.alert(APP_TITLE, msg, ui.ButtonSet.OK); } /** * Updates the main document drawing content from source files. * Uses the parameters at the top of this file to locate content to import. * * Called from performImport(). */ function getContent(body) { // Finds the heading paragraph with matching style, keywords and !color. let parValidHeading; const searchType = DocumentApp.ElementType.PARAGRAPH; const searchHeading = APP_STYLE; let searchResult = null; // Gets and loops through all paragraphs that match the style of APP_STYLE. while (true) { searchResult = body.findElement(searchType, searchResult); if (!searchResult) { break; } const par = searchResult.getElement().asParagraph(); if (par.getHeading() === searchHeading) { // If heading style matches, searches for text string (case insensitive). const findPos = par.findText(`(?i)${FIND_TEXT_KEYWORDS}`); if (findPos !== null) { // If text color is green, then the paragraph isn't a new summary to copy. if (par.editAsText().getForegroundColor() !== TEXT_COLOR) { parValidHeading = par; } } } } if (!parValidHeading) { return; } // Updates the heading color to indicate that the summary has been imported. const style = {}; style[DocumentApp.Attribute.FOREGROUND_COLOR] = TEXT_COLOR; parValidHeading.setAttributes(style); parValidHeading.appendText(" [Exported]"); // Gets the content from the table following the valid heading. const elemObj = parValidHeading.getNextSibling().asTable(); const content = elemObj.copy(); return content; } /** * Gets the IDs of the Docs files within the folder that contains source files. * * Called from function performImport(). */ function getFiles(folder) { // Only gets Docs files. const files = folder.getFilesByType(MimeType.GOOGLE_DOCS); const docIDs = []; while (files.hasNext()) { const file = files.next(); docIDs.push(file.getId()); } return docIDs; } ================================================ FILE: solutions/automations/aggregate-document-content/Menu.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * This file contains the functions that build the custom menu. */ // Menu constants for easy access to update. const MENU = { NAME: "Import summaries", IMPORT: "Import summaries", SETUP: "Configure", NEW_INSTANCE: "Setup new instance", TEMPLATE: "Create starter template", SAMPLES: "Run demo setup with sample documents", }; /** * Creates custom menu when the document is opened. */ function onOpen() { const ui = DocumentApp.getUi(); ui.createMenu(MENU.NAME) .addItem(MENU.IMPORT, "performImport") .addSeparator() .addSubMenu( ui .createMenu(MENU.SETUP) .addItem(MENU.NEW_INSTANCE, "setupConfig") .addItem(MENU.TEMPLATE, "createSampleFile") .addSeparator() .addItem(MENU.SAMPLES, "setupWithSamples"), ) .addItem("About", "aboutApp") .addToUi(); } /** * About box for context and contact. * TODO: Personalize */ function aboutApp() { const msg = ` ${APP_TITLE} Version: 1.0 Contact: `; const ui = DocumentApp.getUi(); ui.alert("About this application", msg, ui.ButtonSet.OK); } ================================================ FILE: solutions/automations/aggregate-document-content/README.md ================================================ # Aggregate content from multiple documents See [developers.google.com](https://developers.google.com/apps-script/samples/automations/aggregate-document-content) for additional details. ================================================ FILE: solutions/automations/aggregate-document-content/Setup.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * This file contains functions that create the template and sample documents. */ /** * Runs full setup configuration, with option to include samples. * * Called from menu & setupWithSamples() * * @param {boolean} includeSamples - Optional, if true creates samples files. * */ function setupConfig(includeSamples) { // Gets folder to store documents in. const folder = getFolderByName_(PROJECT_FOLDER_NAME); let msg = `\nDrive Folder for Documents: '${PROJECT_FOLDER_NAME}' \nURL: \n${folder.getUrl()}`; // Creates sample documents for testing. // Remove sample document creation and add your own process as needed. if (includeSamples) { let filesCreated = 0; for (const doc of samples.documents) { filesCreated += createGoogleDoc(doc, folder, true); } msg += `\n\nFiles Created: ${filesCreated}`; } const ui = DocumentApp.getUi(); ui.alert(`${APP_TITLE} [Setup]`, msg, ui.ButtonSet.OK); } /** * Creates a single document instance in the application folder. * Includes import settings already created [Heading | Keywords | Table] * * Called from menu. */ function createSampleFile() { // Creates a new Google Docs document. const templateName = `[Template] ${APP_TITLE}`; const doc = DocumentApp.create(templateName); const docId = doc.getId(); const msg = `\nDocument created: '${templateName}' \nURL: \n${doc.getUrl()}`; // Adds template content to the body. const body = doc.getBody(); body.setText(templateName); body.getParagraphs()[0].setHeading(DocumentApp.ParagraphHeading.TITLE); body .appendParagraph("Description") .setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(""); const dateString = Utilities.formatDate( new Date(), Session.getScriptTimeZone(), "MMMM dd, yyyy", ); body .appendParagraph(`${FIND_TEXT_KEYWORDS} - ${dateString}`) .setHeading(APP_STYLE); body.appendTable().appendTableRow().appendTableCell("TL;DR"); body.appendParagraph(""); // Gets folder to store documents in. const folder = getFolderByName_(PROJECT_FOLDER_NAME); // Moves document to application folder. DriveApp.getFileById(docId).moveTo(folder); const ui = DocumentApp.getUi(); ui.alert(`${APP_TITLE} [Template]`, msg, ui.ButtonSet.OK); } /** * Configures application for demonstration by setting it up with sample documents. * * Called from menu | Calls setupConfig with option set to true. */ function setupWithSamples() { setupConfig(true); } /** * Sample document names and demo content. * {object} samples[] */ const samples = { documents: [ { name: "Project GHI", description: "Google Workspace Add-on inventory review.", content: "Reviewed all of the currently in-use and proposed Google Workspace Add-ons. Will perform an assessment on how we can reduce overlap, reduce licensing costs, and limit security exposures. \n\nNext week's goal is to report findings back to the Corp Ops team.", }, { name: "Project DEF", description: "Improve IT networks within the main corporate building.", content: "Primarily focused on 2nd thru 5th floors in the main corporate building evaluating the network infrastructure. Benchmarking tests were performed and results are being analyzed. \n\nWill submit all findings, analysis, and recommendations next week for committee review.", }, { name: "Project ABC", description: "Assess existing Google Chromebook inventory and recommend upgrades where necessary.", content: "Concluded a pilot program with the Customer Service department to perform inventory and update inventory records with Chromebook hardware, Chrome OS versions, and installed apps. \n\nScheduling a work plan and seeking necessary go-forward approvals for next week.", }, ], common: 'This sample document is configured to work with the Import summaries custom menu. For the import to work, the source documents used must contain a specific keyword (currently set to "Summary"). The keyword must reside in a paragraph with a set style (currently set to "Heading 3") that is directly followed by a single-cell table. The table contains the contents to be imported into the primary document.\n\nWhile those rules might seem precise, it\'s how the application programmatically determines what content is meant to be imported and what can be ignored. Once a summary has been imported, the script updates the heading font to a new color (currently set to Green, hex \'#2e7d32\') to ensure the app ignores it in future imports. You can change these settings in the Apps Script code.', }; /** * Creates a sample document in application folder. * Includes import settings already created [Heading | Keywords | Table]. * Inserts demo data from samples[]. * * Called from menu. */ function createGoogleDoc(document, folder, duplicate) { // Checks for duplicates. if (!duplicate) { // Doesn't create file of same name if one already exists. if (folder.getFilesByName(document.name).hasNext()) { return 0; // File not created. } } // Creates a new Google Docs document. const doc = DocumentApp.create(document.name).setName(document.name); const docId = doc.getId(); // Adds boilerplate content to the body. const body = doc.getBody(); body.setText(document.name); body.getParagraphs()[0].setHeading(DocumentApp.ParagraphHeading.TITLE); body .appendParagraph("Description") .setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(document.description); body .appendParagraph("Usage Instructions") .setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(samples.common); const dateString = Utilities.formatDate( new Date(), Session.getScriptTimeZone(), "MMMM dd, yyyy", ); body .appendParagraph(`${FIND_TEXT_KEYWORDS} - ${dateString}`) .setHeading(APP_STYLE); body.appendTable().appendTableRow().appendTableCell(document.content); body.appendParagraph(""); // Moves document to application folder. DriveApp.getFileById(docId).moveTo(folder); // Returns if successfully created. return 1; } ================================================ FILE: solutions/automations/aggregate-document-content/Utilities.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * This file contains common utility functions. */ /** * Returns a Drive folder located in same folder that the application document is located. * Checks if the folder exists and returns that folder, or creates new one if not found. * * @param {string} folderName - Name of the Drive folder. * @return {object} Google Drive folder */ function getFolderByName_(folderName) { // Gets the Drive folder where the current document is located. const docId = DocumentApp.getActiveDocument().getId(); const parentFolder = DriveApp.getFileById(docId).getParents().next(); // Iterates subfolders to check if folder already exists. const subFolders = parentFolder.getFolders(); while (subFolders.hasNext()) { const folder = subFolders.next(); // Returns the existing folder if found. if (folder.getName() === folderName) { return folder; } } // Creates a new folder if one doesn't already exist. return parentFolder .createFolder(folderName) .setDescription( `Created by ${APP_TITLE} application to store documents to process`, ); } /** * Test function to run getFolderByName_. * @logs details of created Google Drive folder. */ function test_getFolderByName() { // Gets the folder in Drive associated with this application. const folder = getFolderByName_(PROJECT_FOLDER_NAME); console.log( `Name: ${folder.getName()}\rID: ${folder.getId()}\rURL:${folder.getUrl()}\rDescription: ${folder.getDescription()}`, ); // Uncomment the following to automatically delete the test folder. // folder.setTrashed(true); } ================================================ FILE: solutions/automations/aggregate-document-content/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/bracket-maker/.clasp.json ================================================ { "scriptId": "1LkY5nKFdBg2Q9-oIUcZsxRuESvgIcFHGobveNeQ5CpTgV6GgpTUQeOIB" } ================================================ FILE: solutions/automations/bracket-maker/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/bracket-maker /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const RANGE_PLAYER1 = "FirstPlayer"; const SHEET_PLAYERS = "Players"; const SHEET_BRACKET = "Bracket"; const CONNECTOR_WIDTH = 15; /** * Adds a custom menu item to run the script. */ function onOpen() { const ss = SpreadsheetApp.getActiveSpreadsheet(); ss.addMenu("Bracket maker", [ { name: "Create bracket", functionName: "createBracket" }, ]); } /** * Creates the brackets based on the data provided on the players. */ function createBracket() { const ss = SpreadsheetApp.getActiveSpreadsheet(); let rangePlayers = ss.getRangeByName(RANGE_PLAYER1); const sheetControl = ss.getSheetByName(SHEET_PLAYERS); const sheetResults = ss.getSheetByName(SHEET_BRACKET); // Gets the players from column A. Assumes the entire column is filled. rangePlayers = rangePlayers.offset( 0, 0, sheetControl.getMaxRows() - rangePlayers.getRowIndex() + 1, 1, ); let players = rangePlayers.getValues(); // Figures out how many players there are by skipping the empty cells. let numPlayers = 0; for (let i = 0; i < players.length; i++) { if (!players[i][0] || players[i][0].length === 0) { break; } numPlayers++; } players = players.slice(0, numPlayers); // Provides some error checking in case there are too many or too few players/teams. if (numPlayers > 64) { Browser.msgBox( "Sorry, this script can only create brackets for 64 or fewer players.", ); return; // Early exit } if (numPlayers < 3) { Browser.msgBox("Sorry, you must have at least 3 players."); return; // Early exit } // Clears the 'Bracket' sheet and all formatting. sheetResults.clear(); let upperPower = Math.ceil(Math.log(numPlayers) / Math.log(2)); // Calculates the number that is a power of 2 and lower than numPlayers. const countNodesUpperBound = 2 ** upperPower; // Calculates the number that is a power of 2 and higher than numPlayers. const countNodesLowerBound = countNodesUpperBound / 2; // Determines the number of nodes that will not show in the 1st level. const countNodesHidden = numPlayers - countNodesLowerBound; // Enters the players for the 1st round. const currentPlayer = 0; for (let i = 0; i < countNodesLowerBound; i++) { if (i < countNodesHidden) { // Must be on the first level const rng = sheetResults.getRange(i * 4 + 1, 1); setBracketItem_(rng, players); setBracketItem_(rng.offset(2, 0, 1, 1), players); setConnector_(sheetResults, rng.offset(0, 1, 3, 1)); setBracketItem_(rng.offset(1, 2, 1, 1)); } else { // This player gets a bye. setBracketItem_(sheetResults.getRange(i * 4 + 2, 3), players); } } // Fills in the rest of the bracket. upperPower--; for (let i = 0; i < upperPower; i++) { const pow1 = 2 ** (i + 1); const pow2 = 2 ** (i + 2); const pow3 = 2 ** (i + 3); for (let j = 0; j < 2 ** (upperPower - i - 1); j++) { setBracketItem_(sheetResults.getRange(j * pow3 + pow2, i * 2 + 5)); setConnector_( sheetResults, sheetResults.getRange(j * pow3 + pow1, i * 2 + 4, pow2 + 1, 1), ); } } } /** * Sets the value of an item in the bracket and the color. * @param {Range} rng The Spreadsheet Range. * @param {string[]} players The list of players. */ function setBracketItem_(rng, players) { if (players) { const rand = Math.ceil(Math.random() * players.length); rng.setValue(players.splice(rand - 1, 1)[0][0]); } rng.setBackgroundColor("yellow"); } /** * Sets the color and width for connector cells. * @param {Sheet} sheet The spreadsheet to setup. * @param {Range} rng The spreadsheet range. */ function setConnector_(sheet, rng) { sheet.setColumnWidth(rng.getColumnIndex(), CONNECTOR_WIDTH); rng.setBackgroundColor("green"); } ================================================ FILE: solutions/automations/bracket-maker/README.md ================================================ # Create a tournament bracket See [developers.google.com](https://developers.google.com/apps-script/samples/automations/bracket-maker) for additional details. ================================================ FILE: solutions/automations/bracket-maker/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/calendar-timesheet/.clasp.json ================================================ { "scriptId": "1WL3-mzC219UHqy_vqI1gEeoFy5Y8eeiKCZjiiPsWmVmQfVVedN5Vt7rK" } ================================================ FILE: solutions/automations/calendar-timesheet/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/calendar-timesheet /* Copyright 2022 Jasper Duizendstra Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Runs when the spreadsheet is opened and adds the menu options * to the spreadsheet menu */ const onOpen = () => { SpreadsheetApp.getUi() .createMenu("myTime") .addItem("Sync calendar events", "run") .addItem("Settings", "settings") .addToUi(); }; /** * Opens the sidebar */ const settings = () => { const html = HtmlService.createHtmlOutputFromFile("Page").setTitle("Settings"); SpreadsheetApp.getUi().showSidebar(html); }; /** * returns the settings from the script properties */ const getSettings = () => { const settings = {}; // get the current settings const savedCalendarSettings = JSON.parse( PropertiesService.getScriptProperties().getProperty("calendar") || "[]", ); // get the primary calendar const primaryCalendar = CalendarApp.getAllCalendars() .filter((cal) => cal.isMyPrimaryCalendar()) .map((cal) => ({ name: "Primary calendar", id: cal.getId(), })); // get the secondary calendars const secundaryCalendars = CalendarApp.getAllCalendars() .filter((cal) => cal.isOwnedByMe() && !cal.isMyPrimaryCalendar()) .map((cal) => ({ name: cal.getName(), id: cal.getId(), })); // the current available calendars const availableCalendars = primaryCalendar.concat(secundaryCalendars); // find any calendars that were removed const unavailebleCalendars = []; for (const savedCalendarSetting of savedCalendarSettings) { if ( !availableCalendars.find( (availableCalendar) => availableCalendar.id === savedCalendarSetting.id, ) ) { unavailebleCalendars.push(savedCalendarSetting); } } // map the current settings to the available calendars const calendarSettings = availableCalendars.map((availableCalendar) => { if ( savedCalendarSettings.find( (savedCalendar) => savedCalendar.id === availableCalendar.id, ) ) { availableCalendar.sync = true; } return availableCalendar; }); // add the calendar settings to the settings settings.calendarSettings = calendarSettings; const savedFrom = PropertiesService.getScriptProperties().getProperty("syncFrom"); settings.syncFrom = savedFrom; const savedTo = PropertiesService.getScriptProperties().getProperty("syncTo"); settings.syncTo = savedTo; const savedIsUpdateTitle = PropertiesService.getScriptProperties().getProperty("isUpdateTitle") === "true"; settings.isUpdateCalendarItemTitle = savedIsUpdateTitle; const savedIsUseCategoriesAsCalendarItemTitle = PropertiesService.getScriptProperties().getProperty( "isUseCategoriesAsCalendarItemTitle", ) === "true"; settings.isUseCategoriesAsCalendarItemTitle = savedIsUseCategoriesAsCalendarItemTitle; const savedIsUpdateDescription = PropertiesService.getScriptProperties().getProperty( "isUpdateDescription", ) === "true"; settings.isUpdateCalendarItemDescription = savedIsUpdateDescription; return settings; }; /** * Saves the settings from the sidebar */ const saveSettings = (settings) => { PropertiesService.getScriptProperties().setProperty( "calendar", JSON.stringify(settings.calendarSettings), ); PropertiesService.getScriptProperties().setProperty( "syncFrom", settings.syncFrom, ); PropertiesService.getScriptProperties().setProperty( "syncTo", settings.syncTo, ); PropertiesService.getScriptProperties().setProperty( "isUpdateTitle", settings.isUpdateCalendarItemTitle, ); PropertiesService.getScriptProperties().setProperty( "isUseCategoriesAsCalendarItemTitle", settings.isUseCategoriesAsCalendarItemTitle, ); PropertiesService.getScriptProperties().setProperty( "isUpdateDescription", settings.isUpdateCalendarItemDescription, ); return "Settings saved"; }; /** * Builds the myTime object and runs the synchronisation */ const run = () => { myTime({ mainSpreadsheetId: SpreadsheetApp.getActiveSpreadsheet().getId(), }).run(); }; /** * The main function used for the synchronisation * @param {Object} par The main parameter object. * @return {Object} The myTime Object. */ const myTime = (par) => { /** * Format the sheet */ const formatSheet = () => { // sort decending on start date hourSheet.sort(3, false); // hide the technical columns hourSheet.hideColumns(1, 2); // remove any extra rows if ( hourSheet.getLastRow() > 1 && hourSheet.getLastRow() < hourSheet.getMaxRows() ) { hourSheet.deleteRows( hourSheet.getLastRow() + 1, hourSheet.getMaxRows() - hourSheet.getLastRow(), ); } // set the validation for the customers let rule = SpreadsheetApp.newDataValidation() .requireValueInRange(categoriesSheet.getRange("A2:A"), true) .setAllowInvalid(true) .build(); hourSheet.getRange("I2:I").setDataValidation(rule); // set the validation for the projects rule = SpreadsheetApp.newDataValidation() .requireValueInRange(categoriesSheet.getRange("B2:B"), true) .setAllowInvalid(true) .build(); hourSheet.getRange("J2:J").setDataValidation(rule); // set the validation for the tsaks rule = SpreadsheetApp.newDataValidation() .requireValueInRange(categoriesSheet.getRange("C2:C"), true) .setAllowInvalid(true) .build(); hourSheet.getRange("K2:K").setDataValidation(rule); if (isUseCategoriesAsCalendarItemTitle) { hourSheet .getRange("L2:L") .setFormulaR1C1( 'IF(OR(R[0]C[-3]="tbd";R[0]C[-2]="tbd";R[0]C[-1]="tbd");""; CONCATENATE(R[0]C[-3];"|";R[0]C[-2];"|";R[0]C[-1];"|"))', ); } // set the hours, month, week and number collumns hourSheet .getRange("P2:P") .setFormulaR1C1('=IF(R[0]C[-12]="";"";R[0]C[-12]-R[0]C[-13])'); hourSheet .getRange("Q2:Q") .setFormulaR1C1('=IF(R[0]C[-13]="";"";month(R[0]C[-13]))'); hourSheet .getRange("R2:R") .setFormulaR1C1('=IF(R[0]C[-14]="";"";WEEKNUM(R[0]C[-14];2))'); hourSheet.getRange("S2:S").setFormulaR1C1("=R[0]C[-3]"); }; /** * Activate the synchronisation */ function run() { console.log("Started processing hours."); const processCalendar = (setting) => { SpreadsheetApp.flush(); // current calendar info const calendarName = setting.name; const calendarId = setting.id; console.log( `processing ${calendarName} with the id ${calendarId} from ${syncStartDate} to ${syncEndDate}`, ); // get the calendar const calendar = CalendarApp.getCalendarById(calendarId); // get the calendar events and create lookups const events = calendar.getEvents(syncStartDate, syncEndDate); const eventsLookup = events.reduce((jsn, event) => { jsn[event.getId()] = event; return jsn; }, {}); // get the sheet events and create lookups const existingEvents = hourSheet.getDataRange().getValues().slice(1); const existingEventsLookUp = existingEvents.reduce((jsn, row, index) => { if (row[0] !== calendarId) { return jsn; } jsn[row[1]] = { event: row, row: index + 2, }; return jsn; }, {}); // handle a calendar event const handleEvent = (event) => { const eventId = event.getId(); // new event if (!existingEventsLookUp[eventId]) { hourSheet.appendRow([ calendarId, eventId, event.getStartTime(), event.getEndTime(), calendarName, event.getCreators().join(","), event.getTitle(), event.getDescription(), event.getTag("Client") || "tbd", event.getTag("Project") || "tbd", event.getTag("Task") || "tbd", isUpdateCalendarItemTitle ? "" : event.getTitle(), isUpdateCalendarItemDescription ? "" : event.getDescription(), event .getGuestList() .map((guest) => guest.getEmail()) .join(","), event.getLocation(), undefined, undefined, undefined, undefined, ]); return true; } // existing event const exisitingEvent = existingEventsLookUp[eventId].event; const exisitingEventRow = existingEventsLookUp[eventId].row; if (event.getStartTime() - exisitingEvent[startTimeColumn - 1] !== 0) { hourSheet .getRange(exisitingEventRow, startTimeColumn) .setValue(event.getStartTime()); } if (event.getEndTime() - exisitingEvent[endTimeColumn - 1] !== 0) { hourSheet .getRange(exisitingEventRow, endTimeColumn) .setValue(event.getEndTime()); } if ( event.getCreators().join(",") !== exisitingEvent[creatorsColumn - 1] ) { hourSheet .getRange(exisitingEventRow, creatorsColumn) .setValue(event.getCreators()[0]); } if ( event .getGuestList() .map((guest) => guest.getEmail()) .join(",") !== exisitingEvent[guestListColumn - 1] ) { hourSheet.getRange(exisitingEventRow, guestListColumn).setValue( event .getGuestList() .map((guest) => guest.getEmail()) .join(","), ); } if (event.getLocation() !== exisitingEvent[locationColumn - 1]) { hourSheet .getRange(exisitingEventRow, locationColumn) .setValue(event.getLocation()); } if (event.getTitle() !== exisitingEvent[titleColumn - 1]) { if (!isUpdateCalendarItemTitle) { hourSheet .getRange(exisitingEventRow, titleColumn) .setValue(event.getTitle()); } if (isUpdateCalendarItemTitle) { event.setTitle(exisitingEvent[titleColumn - 1]); } } if (event.getDescription() !== exisitingEvent[descriptionColumn - 1]) { if (!isUpdateCalendarItemDescription) { hourSheet .getRange(exisitingEventRow, descriptionColumn) .setValue(event.getDescription()); } if (isUpdateCalendarItemDescription) { event.setDescription(exisitingEvent[descriptionColumn - 1]); } } return true; }; // process each event for the calendar events.every(handleEvent); // remove any events in the sheet that are not in de calendar existingEvents.every((event, index) => { if (event[0] !== calendarId) { return true; } if (eventsLookup[event[1]]) { return true; } if (event[3] < syncStartDate) { return true; } hourSheet.getRange(index + 2, 1, 1, 20).clear(); return true; }); return true; }; // process the calendars settings.calendarSettings .filter((calenderSetting) => calenderSetting.sync === true) .every(processCalendar); formatSheet(); SpreadsheetApp.setActiveSheet(hourSheet); console.log("Finished processing hours."); } const mainSpreadSheetId = par.mainSpreadsheetId; const mainSpreadsheet = SpreadsheetApp.openById(mainSpreadSheetId); const hourSheet = mainSpreadsheet.getSheetByName("Hours"); const categoriesSheet = mainSpreadsheet.getSheetByName("Categories"); const settings = getSettings(); const syncStartDate = new Date(); syncStartDate.setDate(syncStartDate.getDate() - Number(settings.syncFrom)); const syncEndDate = new Date(); syncEndDate.setDate(syncEndDate.getDate() + Number(settings.syncTo)); const isUpdateCalendarItemTitle = settings.isUpdateCalendarItemTitle; const isUseCategoriesAsCalendarItemTitle = settings.isUseCategoriesAsCalendarItemTitle; const isUpdateCalendarItemDescription = settings.isUpdateCalendarItemDescription; const startTimeColumn = 3; const endTimeColumn = 4; const creatorsColumn = 6; const originalTitleColumn = 7; const originalDescriptionColumn = 8; const clientColumn = 9; const projectColumn = 10; const taskColumn = 11; const titleColumn = 12; const descriptionColumn = 13; const guestListColumn = 14; const locationColumn = 15; return Object.freeze({ run: run, }); }; ================================================ FILE: solutions/automations/calendar-timesheet/Page.html ================================================ ================================================ FILE: solutions/automations/calendar-timesheet/README.md ================================================ # Record time and activities in Calendar and Sheets See [developers.google.com](https://developers.google.com/apps-script/samples/automations/calendar-timesheet) for additional details. ================================================ FILE: solutions/automations/calendar-timesheet/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/content-signup/.clasp.json ================================================ { "scriptId": "1G8TfU6Rfcl76Uo4gKig7jFMYKai-V_fiUNbO12pAb25pA4_uyxN5PSvd" } ================================================ FILE: solutions/automations/content-signup/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/content-signup /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // To use your own template doc, update the below variable with the URL of your own Google Doc template. // Make sure you update the sharing settings so that 'anyone' or 'anyone in your organization' can view. const EMAIL_TEMPLATE_DOC_URL = "https://docs.google.com/document/d/1enes74gWsMG3dkK3SFO08apXkr0rcYBd3JHKOb2Nksk/edit?usp=sharing"; // Update this variable to customize the email subject. const EMAIL_SUBJECT = "Hello, here is the content you requested"; // Update this variable to the content titles and URLs you want to offer. Make sure you update the form so that the content titles listed here match the content titles you list in the form. const topicUrls = { "Google Calendar how-to videos": "https://www.youtube.com/playlist?list=PLU8ezI8GYqs7IPb_UdmUNKyUCqjzGO9PJ", "Google Drive how-to videos": "https://www.youtube.com/playlist?list=PLU8ezI8GYqs7Y5d1cgZm2Obq7leVtLkT4", "Google Docs how-to videos": "https://www.youtube.com/playlist?list=PLU8ezI8GYqs4JKwZ-fpBP-zSoWPL8Sit7", "Google Sheets how-to videos": "https://www.youtube.com/playlist?list=PLU8ezI8GYqs61ciKpXf_KkV7ZRbRHVG38", }; /** * Installs a trigger on the spreadsheet for when someone submits a form. */ function installTrigger() { ScriptApp.newTrigger("onFormSubmit") .forSpreadsheet(SpreadsheetApp.getActive()) .onFormSubmit() .create(); } /** * Sends a customized email for every form response. * * @param {Object} event - Form submit event */ function onFormSubmit(e) { const responses = e.namedValues; // If the question title is a label, it can be accessed as an object field. // If it has spaces or other characters, it can be accessed as a dictionary. const timestamp = responses.Timestamp[0]; const email = responses["Email address"][0].trim(); const name = responses.Name[0].trim(); const topicsString = responses.Topics[0].toLowerCase(); // Parse topics of interest into a list (since there are multiple items // that are saved in the row as blob of text). const topics = Object.keys(topicUrls).filter((topic) => { // indexOf searches for the topic in topicsString and returns a non-negative // index if the topic is found, or it will return -1 if it's not found. return topicsString.indexOf(topic.toLowerCase()) !== -1; }); // If there is at least one topic selected, send an email to the recipient. let status = ""; if (topics.length > 0) { MailApp.sendEmail({ to: email, subject: EMAIL_SUBJECT, htmlBody: createEmailBody(name, topics), }); status = "Sent"; } else { status = "No topics selected"; } // Append the status on the spreadsheet to the responses' row. const sheet = SpreadsheetApp.getActiveSheet(); const row = sheet.getActiveRange().getRow(); const column = e.values.length + 1; sheet.getRange(row, column).setValue(status); console.log(`status=${status}; responses=${JSON.stringify(responses)}`); } /** * Creates email body and includes the links based on topic. * * @param {string} recipient - The recipient's email address. * @param {string[]} topics - List of topics to include in the email body. * @return {string} - The email body as an HTML string. */ function createEmailBody(name, topics) { let topicsHtml = topics .map((topic) => { const url = topicUrls[topic]; return `
  • ${topic}
  • `; }) .join(""); topicsHtml = `
      ${topicsHtml}
    `; // Make sure to update the emailTemplateDocId at the top. const docId = DocumentApp.openByUrl(EMAIL_TEMPLATE_DOC_URL).getId(); let emailBody = docToHtml(docId); emailBody = emailBody.replace(/{{NAME}}/g, name); emailBody = emailBody.replace(/{{TOPICS}}/g, topicsHtml); return emailBody; } /** * Downloads a Google Doc as an HTML string. * * @param {string} docId - The ID of a Google Doc to fetch content from. * @return {string} The Google Doc rendered as an HTML string. */ function docToHtml(docId) { // Downloads a Google Doc as an HTML string. const url = `https://docs.google.com/feeds/download/documents/export/Export?id=${docId}&exportFormat=html`; const param = { method: "get", headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` }, muteHttpExceptions: true, }; return UrlFetchApp.fetch(url, param).getContentText(); } ================================================ FILE: solutions/automations/content-signup/README.md ================================================ # Send curated content See [developers.google.com](https://developers.google.com/apps-script/samples/automations/content-signup) for additional details. ================================================ FILE: solutions/automations/content-signup/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "oauthScopes": [ "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/script.scriptapp", "https://www.googleapis.com/auth/script.send_mail", "https://www.googleapis.com/auth/spreadsheets.currentonly" ], "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/course-feedback-response/.clasp.json ================================================ { "scriptId": "1k75E4EdC3TcJEGGIupBANjm5duvs35ORAU1Mg2_6DNXENo827dFzmFeC" } ================================================ FILE: solutions/automations/course-feedback-response/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/course-feedback-response /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Creates custom menu for user to run scripts. */ function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu("Form Reply Tool") .addItem("Enable auto draft replies", "installTrigger") .addToUi(); } /** * Installs a trigger on the Spreadsheet for when a Form response is submitted. */ function installTrigger() { ScriptApp.newTrigger("onFormSubmit") .forSpreadsheet(SpreadsheetApp.getActive()) .onFormSubmit() .create(); } /** * Creates a draft email for every response on a form * * @param {Object} event - Form submit event */ function onFormSubmit(e) { const responses = e.namedValues; // parse form response data const timestamp = responses.Timestamp[0]; const email = responses["Email address"][0].trim(); // create email body const emailBody = createEmailBody(responses); // create draft email createDraft(timestamp, email, emailBody); } /** * Creates email body and includes feedback from Google Form. * * @param {string} responses - The form response data * @return {string} - The email body as an HTML string */ function createEmailBody(responses) { // parse form response data const name = responses.Name[0].trim(); const industry = responses["What industry do you work in?"][0]; const source = responses["How did you find out about this course?"][0]; const rating = responses["On a scale of 1 - 5 how would you rate this course?"][0]; const productFeedback = responses["What could be different to make it a 5 rating?"][0]; const otherFeedback = responses["Any other feedback?"][0]; // create email body const htmlBody = `Hi ${name},

    Thanks for responding to our course feedback questionnaire.

    It's really useful to us to help improve this course.

    Have a great day!

    Thanks,
    Course Team

    ****************************************************************

    Your feedback:

    What industry do you work in?

    ${industry}

    How did you find out about this course?

    ${source}

    On a scale of 1 - 5 how would you rate this course?

    ${rating}

    What could be different to make it a 5 rating?

    ${productFeedback}

    Any other feedback?

    ${otherFeedback}

    `; return htmlBody; } /** * Create a draft email with the feedback * * @param {string} timestamp Timestamp for the form response * @param {string} email Email address from the form response * @param {string} emailBody The email body as an HTML string */ function createDraft(timestamp, email, emailBody) { console.log("draft email create process started"); // create subject line const subjectLine = `Thanks for your course feedback! ${timestamp}`; // create draft email GmailApp.createDraft(email, subjectLine, "", { htmlBody: emailBody, }); } ================================================ FILE: solutions/automations/course-feedback-response/README.md ================================================ # Respond to feedback See [developers.google.com](https://developers.google.com/apps-script/samples/automations/course-feedback-response) for additional details. ================================================ FILE: solutions/automations/course-feedback-response/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/employee-certificate/.clasp.json ================================================ { "scriptId": "1f0EhMh_a2Jtq3DS96ZWeG2XviJ-XHSStB2B3mVXODPz3KyojS7nFRzV-" } ================================================ FILE: solutions/automations/employee-certificate/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/employee-certificate /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const slideTemplateId = "PRESENTATION_ID"; const tempFolderId = "FOLDER_ID"; // Create an empty folder in Google Drive /** * Creates a custom menu "Appreciation" in the spreadsheet * with drop-down options to create and send certificates */ function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu("Appreciation") .addItem("Create certificates", "createCertificates") .addSeparator() .addItem("Send certificates", "sendCertificates") .addToUi(); } /** * Creates a personalized certificate for each employee * and stores every individual Slides doc on Google Drive */ function createCertificates() { // Load the Google Slide template file const template = DriveApp.getFileById(slideTemplateId); // Get all employee data from the spreadsheet and identify the headers const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); const values = sheet.getDataRange().getValues(); const headers = values[0]; const empNameIndex = headers.indexOf("Employee Name"); const dateIndex = headers.indexOf("Date"); const managerNameIndex = headers.indexOf("Manager Name"); const titleIndex = headers.indexOf("Title"); const compNameIndex = headers.indexOf("Company Name"); const empEmailIndex = headers.indexOf("Employee Email"); const empSlideIndex = headers.indexOf("Employee Slide"); const statusIndex = headers.indexOf("Status"); // Iterate through each row to capture individual details for (let i = 1; i < values.length; i++) { const rowData = values[i]; const empName = rowData[empNameIndex]; const date = rowData[dateIndex]; const managerName = rowData[managerNameIndex]; const title = rowData[titleIndex]; const compName = rowData[compNameIndex]; // Make a copy of the Slide template and rename it with employee name const tempFolder = DriveApp.getFolderById(tempFolderId); const empSlideId = template.makeCopy(tempFolder).setName(empName).getId(); const empSlide = SlidesApp.openById(empSlideId).getSlides()[0]; // Replace placeholder values with actual employee related details empSlide.replaceAllText("Employee Name", empName); empSlide.replaceAllText( "Date", `Date: ${Utilities.formatDate( date, Session.getScriptTimeZone(), "MMMM dd, yyyy", )}`, ); empSlide.replaceAllText("Your Name", managerName); empSlide.replaceAllText("Title", title); empSlide.replaceAllText("Company Name", compName); // Update the spreadsheet with the new Slide Id and status sheet.getRange(i + 1, empSlideIndex + 1).setValue(empSlideId); sheet.getRange(i + 1, statusIndex + 1).setValue("CREATED"); SpreadsheetApp.flush(); } } /** * Send an email to each individual employee * with a PDF attachment of their appreciation certificate */ function sendCertificates() { // Get all employee data from the spreadsheet and identify the headers const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); const values = sheet.getDataRange().getValues(); const headers = values[0]; const empNameIndex = headers.indexOf("Employee Name"); const dateIndex = headers.indexOf("Date"); const managerNameIndex = headers.indexOf("Manager Name"); const titleIndex = headers.indexOf("Title"); const compNameIndex = headers.indexOf("Company Name"); const empEmailIndex = headers.indexOf("Employee Email"); const empSlideIndex = headers.indexOf("Employee Slide"); const statusIndex = headers.indexOf("Status"); // Iterate through each row to capture individual details for (let i = 1; i < values.length; i++) { const rowData = values[i]; const empName = rowData[empNameIndex]; const date = rowData[dateIndex]; const managerName = rowData[managerNameIndex]; const title = rowData[titleIndex]; const compName = rowData[compNameIndex]; const empSlideId = rowData[empSlideIndex]; const empEmail = rowData[empEmailIndex]; // Load the employee's personalized Google Slide file const attachment = DriveApp.getFileById(empSlideId); // Setup the required parameters and send them the email const senderName = "CertBot"; const subject = `${empName}, you're awesome!`; const body = `Please find your employee appreciation certificate attached.\n\n${compName} team`; GmailApp.sendEmail(empEmail, subject, body, { attachments: [attachment.getAs(MimeType.PDF)], name: senderName, }); // Update the spreadsheet with email status sheet.getRange(i + 1, statusIndex + 1).setValue("SENT"); SpreadsheetApp.flush(); } } ================================================ FILE: solutions/automations/employee-certificate/README.md ================================================ # Send personalized appreciation certificates to employees See [developers.google.com](https://developers.google.com/apps-script/samples/automations/employee-certificate) for additional details. ================================================ FILE: solutions/automations/employee-certificate/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/equipment-requests/.clasp.json ================================================ { "scriptId": "1T0G2Qr0QkHfqOK8dqjdiMRGuX2UVzkQU3BGfl2lC3wsNwkSmISbp2q6t" } ================================================ FILE: solutions/automations/equipment-requests/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/equipment-requests /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Update this variable with the email address you want to send equipment requests to. const REQUEST_NOTIFICATION_EMAIL = "request_intake@example.com"; // Update the following variables with your own equipment options. const AVAILABLE_LAPTOPS = [ '15" high Performance Laptop (OS X)', '15" high Performance Laptop (Windows)', '15" high performance Laptop (Linux)', '13" lightweight laptop (Windows)', ]; const AVAILABLE_DESKTOPS = [ "Standard workstation (Windows)", "Standard workstation (Linux)", "High performance workstation (Windows)", "High performance workstation (Linux)", "Mac Pro (OS X)", ]; const AVAILABLE_MONITORS = ['Single 27"', 'Single 32"', 'Dual 24"']; // Form field titles, used for creating the form and as keys when handling // responses. /** * Adds a custom menu to the spreadsheet. */ function onOpen() { SpreadsheetApp.getUi() .createMenu("Equipment requests") .addItem("Set up", "setup_") .addItem("Clean up", "cleanup_") .addToUi(); } /** * Creates the form and triggers for the workflow. */ function setup_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (ss.getFormUrl()) { const msg = "Form already exists. Unlink the form and try again."; SpreadsheetApp.getUi().alert(msg); return; } const form = FormApp.create("Equipment Requests") .setCollectEmail(true) .setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()) .setLimitOneResponsePerUser(false); form.addTextItem().setTitle("Employee name").setRequired(true); form.addTextItem().setTitle("Desk location").setRequired(true); form.addDateItem().setTitle("Due date").setRequired(true); form.addListItem().setTitle("Laptop").setChoiceValues(AVAILABLE_LAPTOPS); form.addListItem().setTitle("Desktop").setChoiceValues(AVAILABLE_DESKTOPS); form.addListItem().setTitle("Monitor").setChoiceValues(AVAILABLE_MONITORS); // Hide the raw form responses. for (const sheet of ss.getSheets()) { if (sheet.getFormUrl() === ss.getFormUrl()) { sheet.hideSheet(); } } // Start workflow on each form submit ScriptApp.newTrigger("onFormSubmit_").forForm(form).onFormSubmit().create(); // Archive completed items every 5m. ScriptApp.newTrigger("processCompletedItems_") .timeBased() .everyMinutes(5) .create(); } /** * Cleans up the project (stop triggers, form submission, etc.) */ function cleanup_() { const formUrl = SpreadsheetApp.getActiveSpreadsheet().getFormUrl(); if (!formUrl) { return; } for (const trigger of ScriptApp.getProjectTriggers()) { ScriptApp.deleteTrigger(trigger); } FormApp.openByUrl(formUrl).deleteAllResponses().setAcceptingResponses(false); } /** * Handles new form submissions to trigger the workflow. * * @param {Object} event - Form submit event */ function onFormSubmit_(event) { const response = mapResponse_(event.response); sendNewEquipmentRequestEmail_(response); const equipmentDetails = Utilities.formatString( "%s\n%s\n%s", response.Laptop, response.Desktop, response.Monitor, ); const row = [ "New", "", response["Due date"], response["Employee name"], response["Desk location"], equipmentDetails, response.email, ]; const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName("Pending requests"); sheet.appendRow(row); } /** * Sweeps completed and cancelled requests, notifying the requestors and archiving them * to the completed sheet. * * @param {Object} event */ function processCompletedItems_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const pending = ss.getSheetByName("Pending requests"); const completed = ss.getSheetByName("Completed requests"); const rows = pending.getDataRange().getValues(); for (let i = rows.length; i >= 2; i--) { const row = rows[i - 1]; const status = row[0]; if (status === "Completed" || status === "Cancelled") { pending.deleteRow(i); completed.appendRow(row); console.log(`Deleted row: ${i}`); sendEquipmentRequestCompletedEmail_({ "Employee name": row[3], "Desk location": row[4], email: row[6], }); } } } /** * Sends an email notification that a new equipment request has been submitted. * * @param {Object} request - Request details */ function sendNewEquipmentRequestEmail_(request) { const template = HtmlService.createTemplateFromFile( "new-equipment-request.html", ); template.request = request; template.sheetUrl = SpreadsheetApp.getActiveSpreadsheet().getUrl(); const msg = template.evaluate(); MailApp.sendEmail({ to: REQUEST_NOTIFICATION_EMAIL, subject: "New equipment request", htmlBody: msg.getContent(), }); } /** * Sends an email notifying the requestor that the request is complete. * * @param {Object} request - Request details */ function sendEquipmentRequestCompletedEmail_(request) { const template = HtmlService.createTemplateFromFile("request-complete.html"); template.request = request; const msg = template.evaluate(); MailApp.sendEmail({ to: request.email, subject: "Equipment request completed", htmlBody: msg.getContent(), }); } /** * Converts a form response to an object keyed by the item titles. Allows easier * access to response values. * * @param {FormResponse} response * @return {Object} Form values keyed by question title */ function mapResponse_(response) { const initialValue = { email: response.getRespondentEmail(), timestamp: response.getTimestamp(), }; return response.getItemResponses().reduce((obj, itemResponse) => { const key = itemResponse.getItem().getTitle(); obj[key] = itemResponse.getResponse(); return obj; }, initialValue); } ================================================ FILE: solutions/automations/equipment-requests/README.md ================================================ # Manage new employee equipment requests See [developers.google.com](https://developers.google.com/apps-script/samples/automations/equipment-requests) for additional details. ================================================ FILE: solutions/automations/equipment-requests/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/equipment-requests/new-equipment-request.html ================================================

    A new equipment request has been made by .

    Employee name:
    Desk location name:
    Due date:
    Laptop model:
    Desktop model:
    Monitor(s):

    See the spreadsheet to take or assign this item. ================================================ FILE: solutions/automations/equipment-requests/request-complete.html ================================================

    An equipment request has been completed.

    Employee name:
    Desk location name:

    ================================================ FILE: solutions/automations/event-session-signup/.clasp.json ================================================ { "scriptId": "1RTfpaBw-RYW8PTJsidiqXHRrqaKnwMWAK_nq4LnWk9xXKGJWi_bhexRj" } ================================================ FILE: solutions/automations/event-session-signup/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/event-session-signup /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Inserts a custom menu when the spreadsheet opens. */ function onOpen() { SpreadsheetApp.getUi() .createMenu("Conference") .addItem("Set up conference", "setUpConference_") .addToUi(); } /** * Uses the conference data in the spreadsheet to create * Google Calendar events, a Google Form, and a trigger that allows the script * to react to form responses. */ function setUpConference_() { const scriptProperties = PropertiesService.getScriptProperties(); if (scriptProperties.getProperty("calId")) { Browser.msgBox( "Your conference is already set up. Look in Google Drive for your" + " sign-up form!", ); return; } const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName("Conference Setup"); const range = sheet.getDataRange(); const values = range.getValues(); setUpCalendar_(values, range); setUpForm_(ss, values); ScriptApp.newTrigger("onFormSubmit") .forSpreadsheet(ss) .onFormSubmit() .create(); } /** * Creates a Google Calendar with events for each conference session in the * spreadsheet, then writes the event IDs to the spreadsheet for future use. * @param {Array} values Cell values for the spreadsheet range. * @param {Range} range A spreadsheet range that contains conference data. */ function setUpCalendar_(values, range) { const cal = CalendarApp.createCalendar("Conference Calendar"); // Start at 1 to skip the header row. for (let i = 1; i < values.length; i++) { const session = values[i]; const title = session[0]; const start = joinDateAndTime_(session[1], session[2]); const end = joinDateAndTime_(session[1], session[3]); const options = { location: session[4], sendInvites: true }; const event = cal .createEvent(title, start, end, options) .setGuestsCanSeeGuests(false); session[5] = event.getId(); } range.setValues(values); // Stores the ID for the Calendar, which is needed to retrieve events by ID. const scriptProperties = PropertiesService.getScriptProperties(); scriptProperties.setProperty("calId", cal.getId()); } /** * Creates a single Date object from separate date and time cells. * * @param {Date} date A Date object from which to extract the date. * @param {Date} time A Date object from which to extract the time. * @return {Date} A Date object representing the combined date and time. */ function joinDateAndTime_(date_, time) { const processedDate = new Date(date_); processedDate.setHours(time.getHours()); processedDate.setMinutes(time.getMinutes()); return processedDate; } /** * Creates a Google Form that allows respondents to select which conference * sessions they would like to attend, grouped by date and start time in the * caller's time zone. * * @param {Spreadsheet} ss The spreadsheet that contains the conference data. * @param {Array} values Cell values for the spreadsheet range. */ function setUpForm_(ss, values) { // Group the sessions by date and time so that they can be passed to the form. const schedule = {}; // Start at 1 to skip the header row. for (let i = 1; i < values.length; i++) { const session = values[i]; const day = session[1].toLocaleDateString(); const time = session[2].toLocaleTimeString(); if (!schedule[day]) { schedule[day] = {}; } if (!schedule[day][time]) { schedule[day][time] = []; } schedule[day][time].push(session[0]); } // Creates the form and adds a multiple-choice question for each timeslot. const form = FormApp.create("Conference Form"); form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); form.addTextItem().setTitle("Name").setRequired(true); form.addTextItem().setTitle("Email").setRequired(true); for (const day of Object.keys(schedule)) { form.addSectionHeaderItem().setTitle(`Sessions for ${day}`); for (const time of Object.keys(schedule[day])) { form .addMultipleChoiceItem() .setTitle(`${time} ${day}`) .setChoiceValues(schedule[day][time]); } } } /** * Sends out calendar invitations and a * personalized Google Docs itinerary after a user responds to the form. * * @param {Object} e The event parameter for form submission to a spreadsheet; * see https://developers.google.com/apps-script/understanding_events */ function onFormSubmit(e) { const user = { name: e.namedValues.Name[0], email: e.namedValues.Email[0], }; // Grab the session data again so that we can match it to the user's choices. const response = []; const values = SpreadsheetApp.getActive() .getSheetByName("Conference Setup") .getDataRange() .getValues(); for (let i = 1; i < values.length; i++) { const session = values[i]; const title = session[0]; const day = session[1].toLocaleDateString(); const time = session[2].toLocaleTimeString(); const timeslot = `${time} ${day}`; // For every selection in the response, find the matching timeslot and title // in the spreadsheet and add the session data to the response array. if (e.namedValues[timeslot] && e.namedValues[timeslot] === title) { response.push(session); } } sendInvites_(user, response); sendDoc_(user, response); } /** * Add the user as a guest for every session he or she selected. * @param {object} user An object that contains the user's name and email. * @param {Array} response An array of data for the user's session choices. */ function sendInvites_(user, response) { const id = ScriptProperties.getProperty("calId"); const cal = CalendarApp.getCalendarById(id); for (let i = 0; i < response.length; i++) { cal.getEventSeriesById(response[i][5]).addGuest(user.email); } } /** * Creates and shares a personalized Google Doc that shows the user's itinerary. * @param {object} user An object that contains the user's name and email. * @param {Array} response An array of data for the user's session choices. */ function sendDoc_(user, response) { const doc = DocumentApp.create( `Conference Itinerary for ${user.name}`, ).addEditor(user.email); const body = doc.getBody(); let table = [["Session", "Date", "Time", "Location"]]; for (let i = 0; i < response.length; i++) { table.push([ response[i][0], response[i][1].toLocaleDateString(), response[i][2].toLocaleTimeString(), response[i][4], ]); } body .insertParagraph(0, doc.getName()) .setHeading(DocumentApp.ParagraphHeading.HEADING1); table = body.appendTable(table); table.getRow(0).editAsText().setBold(true); doc.saveAndClose(); // Emails a link to the Doc as well as a PDF copy. MailApp.sendEmail({ to: user.email, subject: doc.getName(), body: `Thanks for registering! Here's your itinerary: ${doc.getUrl()}`, attachments: doc.getAs(MimeType.PDF), }); } /** * Removes the calId script property so that the 'setUpConference_()' can be run again. */ function resetProperties() { const scriptProperties = PropertiesService.getScriptProperties(); scriptProperties.deleteAllProperties(); } ================================================ FILE: solutions/automations/event-session-signup/README.md ================================================ # Create a sign-up for sessions at a conference See [developers.google.com](https://developers.google.com/apps-script/samples/automations/event-session-signup) for additional details. ================================================ FILE: solutions/automations/event-session-signup/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/feedback-sentiment-analysis/.clasp.json ================================================ { "scriptId": "1LOheMLQDlSkvmlt8EQOGGETewdt8tKWyzxspCwqzfianqxTXjBGpAc8c" } ================================================ FILE: solutions/automations/feedback-sentiment-analysis/README.md ================================================ # Analyze sentiment of open-ended feedback See [developers.google.com](https://developers.google.com/apps-script/samples/automations/feedback-sentiment-analysis) for additional details. ================================================ FILE: solutions/automations/feedback-sentiment-analysis/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": { "libraries": [ { "userSymbol": "OAuth2", "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", "version": "24" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/feedback-sentiment-analysis/code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/feedback-sentiment-analysis /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Sets API key for accessing Cloud Natural Language API. const myApiKey = "YOUR_API_KEY"; // Replace with your API key. // Matches column names in Review Data sheet to variables. const COLUMN_NAME = { COMMENTS: "comments", ENTITY: "entity_sentiment", ID: "id", }; /** * Creates a Demo menu in Google Spreadsheets. */ function onOpen() { SpreadsheetApp.getUi() .createMenu("Sentiment Tools") .addItem("Mark entities and sentiment", "markEntitySentiment") .addToUi(); } /** * Analyzes entities and sentiment for each comment in * Review Data sheet and copies results into the * Entity Sentiment Data sheet. */ function markEntitySentiment() { // Sets variables for "Review Data" sheet const ss = SpreadsheetApp.getActiveSpreadsheet(); const dataSheet = ss.getSheetByName("Review Data"); const rows = dataSheet.getDataRange(); const numRows = rows.getNumRows(); const values = rows.getValues(); const headerRow = values[0]; // Checks to see if "Entity Sentiment Data" sheet is present, and // if not, creates a new sheet and sets the header row. const entitySheet = ss.getSheetByName("Entity Sentiment Data"); if (entitySheet == null) { ss.insertSheet("Entity Sentiment Data"); const entitySheet = ss.getSheetByName("Entity Sentiment Data"); const esHeaderRange = entitySheet.getRange(1, 1, 1, 6); const esHeader = [ [ "Review ID", "Entity", "Salience", "Sentiment Score", "Sentiment Magnitude", "Number of mentions", ], ]; esHeaderRange.setValues(esHeader); } // Finds the column index for comments, language_detected, // and comments_english columns. const textColumnIdx = headerRow.indexOf(COLUMN_NAME.COMMENTS); const entityColumnIdx = headerRow.indexOf(COLUMN_NAME.ENTITY); const idColumnIdx = headerRow.indexOf(COLUMN_NAME.ID); if (entityColumnIdx === -1) { Browser.msgBox( `Error: Could not find the column named ${COLUMN_NAME.ENTITY}. Please create an empty column with header "entity_sentiment" on the Review Data tab.`, ); return; // bail } ss.toast("Analyzing entities and sentiment..."); for (let i = 0; i < numRows; ++i) { const value = values[i]; const commentEnCellVal = value[textColumnIdx]; const entityCellVal = value[entityColumnIdx]; const reviewId = value[idColumnIdx]; // Calls retrieveEntitySentiment function for each row that has a comment // and also an empty entity_sentiment cell value. if (commentEnCellVal && !entityCellVal) { const nlData = retrieveEntitySentiment(commentEnCellVal); // Pastes each entity and sentiment score into Entity Sentiment Data sheet. const newValues = []; for (let entity in nlData.entities) { entity = nlData.entities[entity]; const row = [ reviewId, entity.name, entity.salience, entity.sentiment.score, entity.sentiment.magnitude, entity.mentions.length, ]; newValues.push(row); } if (newValues.length) { entitySheet .getRange( entitySheet.getLastRow() + 1, 1, newValues.length, newValues[0].length, ) .setValues(newValues); } // Pastes "complete" into entity_sentiment column to denote completion of NL API call. dataSheet.getRange(i + 1, entityColumnIdx + 1).setValue("complete"); } } } /** * Calls the Cloud Natural Language API with a string of text to analyze * entities and sentiment present in the string. * @param {String} the string for entity sentiment analysis * @return {Object} the entities and related sentiment present in the string */ function retrieveEntitySentiment(line) { const apiKey = myApiKey; const apiEndpoint = `https://language.googleapis.com/v1/documents:analyzeEntitySentiment?key=${apiKey}`; // Creates a JSON request, with text string, language, type and encoding const nlData = { document: { language: "en-us", type: "PLAIN_TEXT", content: line, }, encodingType: "UTF8", }; // Packages all of the options and the data together for the API call. const nlOptions = { method: "post", contentType: "application/json", payload: JSON.stringify(nlData), }; // Makes the API call. const response = UrlFetchApp.fetch(apiEndpoint, nlOptions); return JSON.parse(response); } ================================================ FILE: solutions/automations/folder-creation/Code.js ================================================ /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* This function will create a new folder in the defined Shard Drive. You define the Shared Drive by adding its ID on line number 26. The parameter 'project' is passed in from the AppSheet app. Please watch this video tutorial to see how to use this script: https://youtu.be/Utl57R7I2Cs */ function createNewFolder(project) { const folder = Drive.Files.insert( { parents: [{ id: "ADD YOUR SHARED DRIVE FOLDER ID HERE" }], title: project, mimeType: "application/vnd.google-apps.folder", }, null, { supportsAllDrives: true }, ); return folder.alternateLink; } ================================================ FILE: solutions/automations/folder-creation/README.md ================================================ # Folder creation This code sample is part of a video tutorial on how to combine AppSheet and Apps Script. You can watch the video tutorial to find out how to use the sample.

    See the [Google Apps Script Documentation](https://developers.google.com/apps-script/advanced/drive) for additional information about the advanced Google Drive services. ================================================ FILE: solutions/automations/folder-creation/appscript.json ================================================ { "timeZone": "Europe/Madrid", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v2", "serviceId": "drive" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/generate-pdfs/.clasp.json ================================================ { "scriptId": "1k9PjGdQ_G0HKEoS3np_Szfe-flmLw9gUvblQIxOfvTmS-NLeLgVUzvOa" } ================================================ FILE: solutions/automations/generate-pdfs/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/generate-pdfs /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // TODO: To test this solution, set EMAIL_OVERRIDE to true and set EMAIL_ADDRESS_OVERRIDE to your email address. const EMAIL_OVERRIDE = false; const EMAIL_ADDRESS_OVERRIDE = "test@example.com"; // Application constants const APP_TITLE = "Generate and send PDFs"; const OUTPUT_FOLDER_NAME = "Customer PDFs"; const DUE_DATE_NUM_DAYS = 15; // Sheet name constants. Update if you change the names of the sheets. const CUSTOMERS_SHEET_NAME = "Customers"; const PRODUCTS_SHEET_NAME = "Products"; const TRANSACTIONS_SHEET_NAME = "Transactions"; const INVOICES_SHEET_NAME = "Invoices"; const INVOICE_TEMPLATE_SHEET_NAME = "Invoice Template"; // Email constants const EMAIL_SUBJECT = "Invoice Notification"; const EMAIL_BODY = "Hello!\rPlease see the attached PDF document."; /** * Iterates through the worksheet data populating the template sheet with * customer data, then saves each instance as a PDF document. * * Called by user via custom menu item. */ function processDocuments() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const customersSheet = ss.getSheetByName(CUSTOMERS_SHEET_NAME); const productsSheet = ss.getSheetByName(PRODUCTS_SHEET_NAME); const transactionsSheet = ss.getSheetByName(TRANSACTIONS_SHEET_NAME); const invoicesSheet = ss.getSheetByName(INVOICES_SHEET_NAME); const invoiceTemplateSheet = ss.getSheetByName(INVOICE_TEMPLATE_SHEET_NAME); // Gets data from the storage sheets as objects. const customers = dataRangeToObject(customersSheet); const products = dataRangeToObject(productsSheet); const transactions = dataRangeToObject(transactionsSheet); ss.toast("Creating Invoices", APP_TITLE, 1); const invoices = []; // Iterates for each customer calling createInvoiceForCustomer routine. for (const customer of customers) { ss.toast(`Creating Invoice for ${customer.customer_name}`, APP_TITLE, 1); const invoice = createInvoiceForCustomer( customer, products, transactions, invoiceTemplateSheet, ss.getId(), ); invoices.push(invoice); } // Writes invoices data to the sheet. invoicesSheet .getRange(2, 1, invoices.length, invoices[0].length) .setValues(invoices); } /** * Processes each customer instance with passed in data parameters. * * @param {object} customer - Object for the customer * @param {object} products - Object for all the products * @param {object} transactions - Object for all the transactions * @param {object} invoiceTemplateSheet - Object for the invoice template sheet * @param {string} ssId - Google Sheet ID * Return {array} of instance customer invoice data */ function createInvoiceForCustomer( customer, products, transactions, templateSheet, ssId, ) { const customerTransactions = transactions.filter( (transaction) => transaction.customer_name === customer.customer_name, ); // Clears existing data from the template. clearTemplateSheet(); const lineItems = []; let totalAmount = 0; for (const lineItem of customerTransactions) { const lineItemProduct = products.filter( (product) => product.sku_name === lineItem.sku, )[0]; const qty = Number.parseInt(lineItem.licenses); const price = Number.parseFloat(lineItemProduct.price).toFixed(2); const amount = Number.parseFloat(qty * price).toFixed(2); lineItems.push([ lineItemProduct.sku_name, lineItemProduct.sku_description, "", qty, price, amount, ]); totalAmount += Number.parseFloat(amount); } // Generates a random invoice number. You can replace with your own document ID method. const invoiceNumber = Math.floor(100000 + Math.random() * 900000); // Calulates dates. const todaysDate = new Date().toDateString(); const dueDate = new Date( Date.now() + 1000 * 60 * 60 * 24 * DUE_DATE_NUM_DAYS, ).toDateString(); // Sets values in the template. templateSheet.getRange("B10").setValue(customer.customer_name); templateSheet.getRange("B11").setValue(customer.address); templateSheet.getRange("F10").setValue(invoiceNumber); templateSheet.getRange("F12").setValue(todaysDate); templateSheet.getRange("F14").setValue(dueDate); templateSheet.getRange(18, 2, lineItems.length, 6).setValues(lineItems); // Cleans up and creates PDF. SpreadsheetApp.flush(); Utilities.sleep(500); // Using to offset any potential latency in creating .pdf const pdf = createPDF( ssId, templateSheet, `Invoice#${invoiceNumber}-${customer.customer_name}`, ); return [ invoiceNumber, todaysDate, customer.customer_name, customer.email, "", totalAmount, dueDate, pdf.getUrl(), "No", ]; } /** * Resets the template sheet by clearing out customer data. * You use this to prepare for the next iteration or to view blank * the template for design. * * Called by createInvoiceForCustomer() or by the user via custom menu item. */ function clearTemplateSheet() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const templateSheet = ss.getSheetByName(INVOICE_TEMPLATE_SHEET_NAME); // Clears existing data from the template. const rngClear = templateSheet .getRangeList(["B10:B11", "F10", "F12", "F14"]) .getRanges(); for (const cell of rngClear) { cell.clearContent(); } // This sample only accounts for six rows of data 'B18:G24'. You can extend or make dynamic as necessary. templateSheet.getRange(18, 2, 7, 6).clearContent(); } /** * Creates a PDF for the customer given sheet. * @param {string} ssId - Id of the Google Spreadsheet * @param {object} sheet - Sheet to be converted as PDF * @param {string} pdfName - File name of the PDF being created * @return {file object} PDF file as a blob */ function createPDF(ssId, sheet, pdfName) { const fr = 0; const fc = 0; const lc = 9; const lr = 27; const url = `https://docs.google.com/spreadsheets/d/${ssId}/export?format=pdf&size=7&fzr=true&portrait=true&fitw=true&gridlines=false&printtitle=false&top_margin=0.5&bottom_margin=0.25&left_margin=0.5&right_margin=0.5&sheetnames=false&pagenum=UNDEFINED&attachment=true&gid=${sheet.getSheetId()}&r1=${fr}&c1=${fc}&r2=${lr}&c2=${lc}`; const params = { method: "GET", headers: { authorization: `Bearer ${ScriptApp.getOAuthToken()}` }, }; const blob = UrlFetchApp.fetch(url, params) .getBlob() .setName(`${pdfName}.pdf`); // Gets the folder in Drive where the PDFs are stored. const folder = getFolderByName_(OUTPUT_FOLDER_NAME); const pdfFile = folder.createFile(blob); return pdfFile; } /** * Sends emails with PDF as an attachment. * Checks/Sets 'Email Sent' column to 'Yes' to avoid resending. * * Called by user via custom menu item. */ function sendEmails() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const invoicesSheet = ss.getSheetByName(INVOICES_SHEET_NAME); const invoicesData = invoicesSheet .getRange(1, 1, invoicesSheet.getLastRow(), invoicesSheet.getLastColumn()) .getValues(); const keysI = invoicesData.splice(0, 1)[0]; const invoices = getObjects(invoicesData, createObjectKeys(keysI)); ss.toast("Emailing Invoices", APP_TITLE, 1); invoices.forEach((invoice, index) => { if (invoice.email_sent !== "Yes") { ss.toast(`Emailing Invoice for ${invoice.customer}`, APP_TITLE, 1); const fileId = invoice.invoice_link.match(/[-\w]{25,}(?!.*[-\w]{25,})/); const attachment = DriveApp.getFileById(fileId); let recipient = invoice.email; if (EMAIL_OVERRIDE) { recipient = EMAIL_ADDRESS_OVERRIDE; } GmailApp.sendEmail(recipient, EMAIL_SUBJECT, EMAIL_BODY, { attachments: [attachment.getAs(MimeType.PDF)], name: APP_TITLE, }); invoicesSheet.getRange(index + 2, 9).setValue("Yes"); } }); } /** * Helper function that turns sheet data range into an object. * * @param {SpreadsheetApp.Sheet} sheet - Sheet to process * Return {object} of a sheet's datarange as an object */ function dataRangeToObject(sheet) { const dataRange = sheet .getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn()) .getValues(); const keys = dataRange.splice(0, 1)[0]; return getObjects(dataRange, createObjectKeys(keys)); } /** * Utility function for mapping sheet data to objects. */ function getObjects(data, keys) { const objects = []; for (let i = 0; i < data.length; ++i) { const object = {}; let hasData = false; for (let j = 0; j < data[i].length; ++j) { const cellData = data[i][j]; if (isCellEmpty(cellData)) { continue; } object[keys[j]] = cellData; hasData = true; } if (hasData) { objects.push(object); } } return objects; } // Creates object keys for column headers. function createObjectKeys(keys) { return keys.map((key) => key.replace(/\W+/g, "_").toLowerCase()); } // Returns true if the cell where cellData was read from is empty. function isCellEmpty(cellData) { return typeof cellData === "string" && cellData === ""; } ================================================ FILE: solutions/automations/generate-pdfs/Menu.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @OnlyCurrentDoc * * The above comment specifies that this automation will only * attempt to read or modify the spreadsheet this script is bound to. * The authorization request message presented to users reflects the * limited scope. */ /** * Creates a custom menu in the Google Sheets UI when the document is opened. * * @param {object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { const menu = SpreadsheetApp.getUi().createMenu(APP_TITLE); menu .addItem("Process invoices", "processDocuments") .addItem("Send emails", "sendEmails") .addSeparator() .addItem("Reset template", "clearTemplateSheet") .addToUi(); } ================================================ FILE: solutions/automations/generate-pdfs/README.md ================================================ # Generate and send PDFs from Google Sheets See [developers.google.com](https://developers.google.com/apps-script/samples/automations/generate-pdfs) for additional details. ================================================ FILE: solutions/automations/generate-pdfs/Utilities.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Returns a Google Drive folder in the same location * in Drive where the spreadsheet is located. First, it checks if the folder * already exists and returns that folder. If the folder doesn't already * exist, the script creates a new one. The folder's name is set by the * "OUTPUT_FOLDER_NAME" variable from the Code.gs file. * * @param {string} folderName - Name of the Drive folder. * @return {object} Google Drive Folder */ function getFolderByName_(folderName) { // Gets the Drive Folder of where the current spreadsheet is located. const ssId = SpreadsheetApp.getActiveSpreadsheet().getId(); const parentFolder = DriveApp.getFileById(ssId).getParents().next(); // Iterates the subfolders to check if the PDF folder already exists. const subFolders = parentFolder.getFolders(); while (subFolders.hasNext()) { const folder = subFolders.next(); // Returns the existing folder if found. if (folder.getName() === folderName) { return folder; } } // Creates a new folder if one does not already exist. return parentFolder .createFolder(folderName) .setDescription( `Created by ${APP_TITLE} application to store PDF output files`, ); } /** * Test function to run getFolderByName_. * @prints a Google Drive FolderId. */ function test_getFolderByName() { // Gets the PDF folder in Drive. const folder = getFolderByName_(OUTPUT_FOLDER_NAME); console.log( `Name: ${folder.getName()}\rID: ${folder.getId()}\rDescription: ${folder.getDescription()}`, ); // To automatically delete test folder, uncomment the following code: // folder.setTrashed(true); } ================================================ FILE: solutions/automations/generate-pdfs/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/import-csv-sheets/.clasp.json ================================================ { "scriptId": "1ANsCqbcTeepCzPpAKRUSxavm-2bTtKhp6I-G530ddH315H-59LGofc6m" } ================================================ FILE: solutions/automations/import-csv-sheets/Code.js ================================================ // To learn more about this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/import-csv-sheets /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This file contains the main functions that import data from CSV files into a Google Spreadsheet. */ // Application constants const APP_TITLE = "Trigger-driven CSV import [App Script Sample]"; // Application name const APP_FOLDER = "[App Script sample] Import CSVs"; // Application primary folder const SOURCE_FOLDER = "Inbound CSV Files"; // Folder for the update files. const PROCESSED_FOLDER = "Processed CSV Files"; // Folder to hold processed files. const SHEET_REPORT_NAME = "Import CSVs"; // Name of destination spreadsheet. // Application settings const CSV_HEADER_EXIST = true; // Set to true if CSV files have a header row, false if not. const HANDLER_FUNCTION = "updateApplicationSheet"; // Function called by installable trigger to run data processing. /** * Installs a time-driven trigger that runs daily to import CSVs into the main application spreadsheet. * Prior to creating a new instance, removes any existing triggers to avoid duplication. * * Called by setupSample() or run directly setting up the application. */ function installTrigger() { // Checks for an existing trigger to avoid creating duplicate instances. // Removes existing if found. const projectTriggers = ScriptApp.getProjectTriggers(); for (let i = 0; i < projectTriggers.length; i++) { if (projectTriggers[i].getHandlerFunction() === HANDLER_FUNCTION) { console.log( `Existing trigger with Handler Function of '${HANDLER_FUNCTION}' removed.`, ); ScriptApp.deleteTrigger(projectTriggers[i]); } } // Creates the new trigger. const newTrigger = ScriptApp.newTrigger(HANDLER_FUNCTION) .timeBased() .atHour(23) // Runs at 11 PM in the time zone of this script. .everyDays(1) // Runs once per day. .create(); console.log( `New trigger with Handler Function of '${HANDLER_FUNCTION}' created.`, ); } /** * Handler function called by the trigger created with the "installTrigger" function. * Run this directly to execute the entire automation process of the application with a trigger. * * Process: Iterates through CSV files located in the source folder (SOURCE_FOLDER), * and appends them to the end of destination spreadsheet (SHEET_REPORT_NAME). * Successfully processed CSV files are moved to the processed folder (PROCESSED_FOLDER) to avoid duplication. * Sends summary email with status of the import. */ function updateApplicationSheet() { // Gets application & supporting folders. const folderAppPrimary = getApplicationFolder_(APP_FOLDER); const folderSource = getFolder_(SOURCE_FOLDER); const folderProcessed = getFolder_(PROCESSED_FOLDER); // Gets the application's destination spreadsheet {Spreadsheet object} const objSpreadSheet = getSpreadSheet_(SHEET_REPORT_NAME, folderAppPrimary); // Creates arrays to track every CSV file, categorized as processed sucessfully or not. const filesProcessed = []; const filesNotProcessed = []; // Gets all CSV files found in the source folder. const cvsFiles = folderSource.getFilesByType(MimeType.CSV); // Iterates through each CSV file. while (cvsFiles.hasNext()) { const csvFile = cvsFiles.next(); const isSuccess = processCsv_(objSpreadSheet, csvFile); if (isSuccess) { // Moves the processed file to the processed folder to prevent future duplicate data imports. csvFile.moveTo(folderProcessed); // Logs the successfully processed file to the filesProcessed array. filesProcessed.push(csvFile.getName()); console.log(`Successfully processed: ${csvFile.getName()}`); } else { // Doesn't move the unsuccesfully processed file so that it can be corrected and reprocessed later. // Logs the unsuccessfully processed file to the filesNotProcessed array. filesNotProcessed.push(csvFile.getName()); console.log(`Not processed: ${csvFile.getName()}`); } } // Prepares summary email. // Gets variables to link to this Apps Script project. const scriptId = ScriptApp.getScriptId(); const scriptUrl = DriveApp.getFileById(scriptId).getUrl(); const scriptName = DriveApp.getFileById(scriptId).getName(); // Gets variables to link to the main application spreadsheet. const sheetUrl = objSpreadSheet.getUrl(); const sheetName = objSpreadSheet.getName(); // Gets user email and timestamp. const emailTo = Session.getEffectiveUser().getEmail(); const timestamp = Utilities.formatDate( new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss zzzz", ); // Prepares lists and counts of processed CSV files. let processedList = ""; const processedCount = filesProcessed.length; for (const processed of filesProcessed) { processedList += `${processed}
    `; } const unProcessedCount = filesNotProcessed.length; let unProcessedList = ""; for (const unProcessed of filesNotProcessed) { unProcessedList += `${unProcessed}\n`; } // Assembles email body as html. const eMailBody = `${APP_TITLE} ran an automated process at ${timestamp}.

    Files successfully updated: ${processedCount}
    ${processedList}
    Files not updated: ${unProcessedCount}
    ${unProcessedList}

    View all updates in the Google Sheets spreadsheet ${sheetName}.

    *************

    This email was generated by Google Apps Script. To learn more about this application or make changes, open the script project below:
    ${scriptName}`; MailApp.sendEmail({ to: emailTo, subject: `Automated email from ${APP_TITLE}`, htmlBody: eMailBody, }); console.log(`Email sent to ${emailTo}`); } /** * Parses CSV data into an array and appends it after the last row in the destination spreadsheet. * * @return {boolean} true if the update is successful, false if unexpected errors occur. */ function processCsv_(objSpreadSheet, csvFile) { try { // Gets the first sheet of the destination spreadsheet. const sheet = objSpreadSheet.getSheets()[0]; // Parses CSV file into data array. const data = Utilities.parseCsv(csvFile.getBlob().getDataAsString()); // Omits header row if application variable CSV_HEADER_EXIST is set to 'true'. if (CSV_HEADER_EXIST) { data.splice(0, 1); } // Gets the row and column coordinates for next available range in the spreadsheet. const startRow = sheet.getLastRow() + 1; const startCol = 1; // Determines the incoming data size. const numRows = data.length; const numColumns = data[0].length; // Appends data into the sheet. sheet.getRange(startRow, startCol, numRows, numColumns).setValues(data); return true; // Success. } catch { return false; // Failure. Checks for CSV data file error. } } ================================================ FILE: solutions/automations/import-csv-sheets/README.md ================================================ # Import CSV data to a spreadsheet See [developers.google.com](https://developers.google.com/apps-script/samples/automations/import-csv-sheets) for additional details. ================================================ FILE: solutions/automations/import-csv-sheets/SampleData.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * This file contains functions to access headings and data for sample files. * * Sample data is stored in the variable SAMPLE_DATA. */ // Fictitious sample data. const SAMPLE_DATA = { headings: [ "PropertyName", "LeaseID", "LeaseLocation", "OwnerName", "SquareFootage", "RenewDate", "LastAmount", "LastPaymentDate", "Revenue", ], csvFiles: [ { name: "Sample One.CSV", rows: [ { PropertyName: "The Modern Building", LeaseID: "271312", LeaseLocation: "Mountain View CA 94045", OwnerName: "Yuri", SquareFootage: "17500", RenewDate: "12/15/2022", LastAmount: "100000", LastPaymentDate: "3/01/2022", Revenue: "12000", }, { PropertyName: "Garage @ 45", LeaseID: "271320", LeaseLocation: "Mountain View CA 94045", OwnerName: "Luka", SquareFootage: "1000", RenewDate: "6/2/2022", LastAmount: "50000", LastPaymentDate: "4/01/2022", Revenue: "20000", }, { PropertyName: "Office Park Deluxe", LeaseID: "271301", LeaseLocation: "Mountain View CA 94045", OwnerName: "Sasha", SquareFootage: "5000", RenewDate: "6/2/2022", LastAmount: "25000", LastPaymentDate: "4/01/2022", Revenue: "1200", }, ], }, { name: "Sample Two.CSV", rows: [ { PropertyName: "Tours Jumelles Minuscules", LeaseID: "271260", LeaseLocation: "8 Rue du Nom Fictif 341 Paris", OwnerName: "Lucian", SquareFootage: "1000000", RenewDate: "7/14/2022", LastAmount: "1250000", LastPaymentDate: "5/01/2022", Revenue: "77777", }, { PropertyName: "Barraca da Praia", LeaseID: "271281", LeaseLocation: "Avenida da Pastelaria 1903 Lisbon 1229-076", OwnerName: "Raha", SquareFootage: "1000", RenewDate: "6/2/2022", LastAmount: "50000", LastPaymentDate: "4/01/2022", Revenue: "20000", }, ], }, { name: "Sample Three.CSV", rows: [ { PropertyName: "Round Building in the Square", LeaseID: "371260", LeaseLocation: "8 Rue du Nom Fictif 341 Paris", OwnerName: "Charlie", SquareFootage: "75000", RenewDate: "8/1/2022", LastAmount: "250000", LastPaymentDate: "6/01/2022", Revenue: "22222", }, { PropertyName: "Square Building in the Round", LeaseID: "371281", LeaseLocation: "Avenida da Pastelaria 1903 Lisbon 1229-076", OwnerName: "Lee", SquareFootage: "10000", RenewDate: "6/2/2022", LastAmount: "5000", LastPaymentDate: "4/01/2022", Revenue: "1800", }, ], }, ], }; /** * Returns headings for use in destination spreadsheet and CSV files. * @return {string[][]} array of each column heading as string. */ function getHeadings() { const headings = [[]]; for (const i in SAMPLE_DATA.headings) headings[0].push(SAMPLE_DATA.headings[i]); return headings; } /** * Returns CSV file names and content to create sample CSV files. * @return {object[]} {"file": ["name","csv"]} */ function getCSVFilesData() { const files = []; // Gets headings once - same for all files/rows. let csvHeadings = ""; for (const i in SAMPLE_DATA.headings) csvHeadings += `${SAMPLE_DATA.headings[i]},`; // Gets data for each file by rows. for (const i in SAMPLE_DATA.csvFiles) { let sampleCSV = ""; sampleCSV += csvHeadings; const fileName = SAMPLE_DATA.csvFiles[i].name; for (const j in SAMPLE_DATA.csvFiles[i].rows) { sampleCSV += "\n"; for (const k in SAMPLE_DATA.csvFiles[i].rows[j]) { sampleCSV += `${SAMPLE_DATA.csvFiles[i].rows[j][k]},`; } } files.push({ name: fileName, csv: sampleCSV }); } return files; } /* * Checks data functions are working as necessary. */ function test_getHeadings() { const h = getHeadings(); console.log(h); console.log(h[0].length); } function test_getCSVFilesData() { const csvFiles = getCSVFilesData(); console.log(csvFiles); for (const file of csvFiles) { console.log(file.name); console.log(file.csv); } } ================================================ FILE: solutions/automations/import-csv-sheets/SetupSample.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * This file contains functions that set up the folders and sample files used to demo the application. * * Sample data for the application is stored in the SampleData.gs file. */ // Global variables for sample setup. const INCLUDE_SAMPLE_DATA_FILES = true; // Set to true to create sample data files, false to skip. /** * Runs the setup for the sample. * 1) Creates the application folder and subfolders for unprocessed/processed CSV files. * from global variables APP_FOLDER | SOURCE_FOLDER | PROCESSED_FOLDER * 2) Creates the sample Sheets spreadsheet in the application folder. * from global variable SHEET_REPORT_NAME * 3) Creates CSV files from sample data in the unprocessed files folder. * from variable SAMPLE_DATA in SampleData.gs. * 4) Creates an installable trigger to run process automatically at a specified time interval. */ function setupSample() { console.log(`Application setup for: ${APP_TITLE}`); // Creates application folder. const folderAppPrimary = getApplicationFolder_(APP_FOLDER); // Creates supporting folders. const folderSource = getFolder_(SOURCE_FOLDER); const folderProcessed = getFolder_(PROCESSED_FOLDER); console.log( `Application folders: ${folderAppPrimary.getName()}, ${folderSource.getName()}, ${folderProcessed.getName()}`, ); if (INCLUDE_SAMPLE_DATA_FILES) { // Sets up primary destination spreadsheet const sheet = setupPrimarySpreadsheet_(folderAppPrimary); // Gets the CSV files data - refer to the SampleData.gs file to view. const csvFiles = getCSVFilesData(); // Processes each CSV file. for (const file of csvFiles) { // Creates CSV file in source folder if it doesn't exist. if (!fileExists_(file.name, folderSource)) { const csvFileId = DriveApp.createFile( file.name, file.csv, MimeType.CSV, ); console.log(`Created Sample CSV: ${file.name}`); csvFileId.moveTo(folderSource); } } } // Installs (or recreates) project trigger installTrigger(); console.log(`Setup completed for: ${APP_TITLE}`); } /** * */ function setupPrimarySpreadsheet_(folderAppPrimary) { // Creates the report destination spreadsheet if doesn't exist. if (!fileExists_(SHEET_REPORT_NAME, folderAppPrimary)) { // Creates new destination spreadsheet (report) with cell size of 20 x 10. const sheet = SpreadsheetApp.create(SHEET_REPORT_NAME, 20, 10); // Adds the sample data headings. const sheetHeadings = getHeadings(); sheet .getSheets()[0] .getRange(1, 1, 1, sheetHeadings[0].length) .setValues(sheetHeadings); SpreadsheetApp.flush(); // Moves to primary application root folder. DriveApp.getFileById(sheet.getId()).moveTo(folderAppPrimary); console.log( `Created file: ${SHEET_REPORT_NAME} In folder: ${folderAppPrimary.getName()}.`, ); return sheet; } } /** * Moves sample content to Drive trash & uninstalls trigger. * This function removes all folders and content related to this application. */ function removeSample() { getApplicationFolder_(APP_FOLDER).setTrashed(true); console.log( `'${APP_FOLDER}' contents have been moved to Drive Trash folder.`, ); // Removes existing trigger if found. const projectTriggers = ScriptApp.getProjectTriggers(); for (let i = 0; i < projectTriggers.length; i++) { if (projectTriggers[i].getHandlerFunction() === HANDLER_FUNCTION) { console.log( `Existing trigger with handler function of '${HANDLER_FUNCTION}' removed.`, ); ScriptApp.deleteTrigger(projectTriggers[i]); } } } ================================================ FILE: solutions/automations/import-csv-sheets/Utilities.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * This file contains utility functions that work with application's folder and files. */ /** * Gets application destination spreadsheet from a given folder * Returns new sample version if orignal is not found. * * @param {string} fileName - Name of the file to test for. * @param {object} objFolder - Folder object in which to search. * @return {object} Spreadsheet object. */ function getSpreadSheet_(fileName, objFolder) { const files = objFolder.getFilesByName(fileName); while (files.hasNext()) { const file = files.next(); const fileId = file.getId(); const existingSpreadsheet = SpreadsheetApp.openById(fileId); return existingSpreadsheet; } // If application destination spreadsheet is missing, creates a new sample version. const folderAppPrimary = getApplicationFolder_(APP_FOLDER); const sampleSheet = setupPrimarySpreadsheet_(folderAppPrimary); return sampleSheet; } /** * Tests if a file exists within a given folder. * * @param {string} fileName - Name of the file to test for. * @param {object} objFolder - Folder object in which to search. * @return {boolean} true if found in folder, false if not. */ function fileExists_(fileName, objFolder) { const files = objFolder.getFilesByName(fileName); while (files.hasNext()) { const file = files.next(); console.log(`${file.getName()} already exists.`); return true; } return false; } /** * Returns folder named in folderName parameter. * Checks if folder already exists, creates it if it doesn't. * * @param {string} folderName - Name of the Drive folder. * @return {object} Google Drive Folder */ function getFolder_(folderName) { // Gets the primary folder for the application. const parentFolder = getApplicationFolder_(); // Iterates subfolders to check if folder already exists. const subFolders = parentFolder.getFolders(); while (subFolders.hasNext()) { const folder = subFolders.next(); // Returns the existing folder if found. if (folder.getName() === folderName) { return folder; } } // Creates a new folder if one doesn't already exist. return parentFolder .createFolder(folderName) .setDescription(`Supporting folder created by ${APP_TITLE}.`); } /** * Returns the primary folder as named by the APP_FOLDER variable in the Code.gs file. * Checks if folder already exists to avoid duplication. * Creates new instance if existing folder not found. * * @return {object} Google Drive Folder */ function getApplicationFolder_() { // Gets root folder, currently set to 'My Drive' const parentFolder = DriveApp.getRootFolder(); // Iterates through the subfolders to check if folder already exists. const subFolders = parentFolder.getFolders(); while (subFolders.hasNext()) { const folder = subFolders.next(); // Returns the existing folder if found. if (folder.getName() === APP_FOLDER) { return folder; } } // Creates a new folder if one doesn't already exist. return parentFolder .createFolder(APP_FOLDER) .setDescription(`Main application folder created by ${APP_TITLE}.`); } /** * Tests getApplicationFolder_ and getFolder_ * @logs details of created Google Drive folder. */ function test_getFolderByName() { let folder = getApplicationFolder_(); console.log( `Name: ${folder.getName()}\rID: ${folder.getId()}\rURL:${folder.getUrl()}\rDescription: ${folder.getDescription()}`, ); // Uncomment the following to automatically delete test folder. // folder.setTrashed(true); folder = getFolder_(SOURCE_FOLDER); console.log( `Name: ${folder.getName()}\rID: ${folder.getId()}\rURL:${folder.getUrl()}\rDescription: ${folder.getDescription()}`, ); // Uncomment the following to automatically delete test folder. // folder.setTrashed(true); folder = getFolder_(PROCESSED_FOLDER); console.log( `Name: ${folder.getName()}\rID: ${folder.getId()}\rURL:${folder.getUrl()}\rDescription: ${folder.getDescription()}`, ); // Uncomment the following to automatically delete test folder. // folder.setTrashed(true); } ================================================ FILE: solutions/automations/import-csv-sheets/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/mail-merge/.clasp.json ================================================ { "scriptId": "1evL25lW9fLN43j6gGBJWtLq4GncLkdgoxxSVCawc8dWNoLoravNebAih" } ================================================ FILE: solutions/automations/mail-merge/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/mail-merge /* Copyright 2022 Martin Hawksey Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @OnlyCurrentDoc */ /** * Change these to match the column names you are using for email * recipient addresses and email sent column. */ const RECIPIENT_COL = "Recipient"; const EMAIL_SENT_COL = "Email Sent"; /** * Creates the menu item "Mail Merge" for user to run scripts on drop-down. */ function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu("Mail Merge").addItem("Send Emails", "sendEmails").addToUi(); } /** * Sends emails from sheet data. * @param {string} subjectLine (optional) for the email draft message * @param {Sheet} sheet to read data from */ function sendEmails(subjectLine, sheet = SpreadsheetApp.getActiveSheet()) { // option to skip browser prompt if you want to use this code in other projects let processedSubjectLine = subjectLine; if (!processedSubjectLine) { processedSubjectLine = Browser.inputBox( "Mail Merge", "Type or copy/paste the subject line of the Gmail " + "draft message you would like to mail merge with:", Browser.Buttons.OK_CANCEL, ); if (processedSubjectLine === "cancel" || processedSubjectLine === "") { // If no subject line, finishes up return; } } // Gets the draft Gmail message to use as a template const emailTemplate = getGmailTemplateFromDrafts_(processedSubjectLine); // Gets the data from the passed sheet const dataRange = sheet.getDataRange(); // Fetches displayed values for each row in the Range HT Andrew Roberts // https://mashe.hawksey.info/2020/04/a-bulk-email-mail-merge-with-gmail-and-google-sheets-solution-evolution-using-v8/#comment-187490 // @see https://developers.google.com/apps-script/reference/spreadsheet/range#getdisplayvalues const data = dataRange.getDisplayValues(); // Assumes row 1 contains our column headings const heads = data.shift(); // Gets the index of the column named 'Email Status' (Assumes header names are unique) // @see http://ramblings.mcpher.com/Home/excelquirks/gooscript/arrayfunctions const emailSentColIdx = heads.indexOf(EMAIL_SENT_COL); // Converts 2d array into an object array // See https://stackoverflow.com/a/22917499/1027723 // For a pretty version, see https://mashe.hawksey.info/?p=17869/#comment-184945 const obj = data.map((r) => heads.reduce((o, k, i) => { o[k] = r[i] || ""; return o; }, {}), ); // Creates an array to record sent emails const out = []; // Loops through all the rows of data obj.forEach((row, rowIdx) => { // Only sends emails if email_sent cell is blank and not hidden by a filter if (row[EMAIL_SENT_COL] === "") { try { const msgObj = fillInTemplateFromObject_(emailTemplate.message, row); // See https://developers.google.com/apps-script/reference/gmail/gmail-app#sendEmail(String,String,String,Object) // If you need to send emails with unicode/emoji characters change GmailApp for MailApp // Uncomment advanced parameters as needed (see docs for limitations) GmailApp.sendEmail(row[RECIPIENT_COL], msgObj.subject, msgObj.text, { htmlBody: msgObj.html, // bcc: 'a.bcc@email.com', // cc: 'a.cc@email.com', // from: 'an.alias@email.com', // name: 'name of the sender', // replyTo: 'a.reply@email.com', // noReply: true, // if the email should be sent from a generic no-reply email address (not available to gmail.com users) attachments: emailTemplate.attachments, inlineImages: emailTemplate.inlineImages, }); // Edits cell to record email sent date out.push([new Date()]); } catch (e) { // modify cell to record error out.push([e.message]); } } else { out.push([row[EMAIL_SENT_COL]]); } }); // Updates the sheet with new data sheet.getRange(2, emailSentColIdx + 1, out.length).setValues(out); /** * Get a Gmail draft message by matching the subject line. * @param {string} subject_line to search for draft message * @return {object} containing the subject, plain and html message body and attachments */ function getGmailTemplateFromDrafts_(subject_line) { try { // get drafts const drafts = GmailApp.getDrafts(); // filter the drafts that match subject line const draft = drafts.filter(subjectFilter_(subject_line))[0]; // get the message object const msg = draft.getMessage(); // Handles inline images and attachments so they can be included in the merge // Based on https://stackoverflow.com/a/65813881/1027723 // Gets all attachments and inline image attachments const allInlineImages = draft.getMessage().getAttachments({ includeInlineImages: true, includeAttachments: false, }); const attachments = draft .getMessage() .getAttachments({ includeInlineImages: false }); const htmlBody = msg.getBody(); // Creates an inline image object with the image name as key // (can't rely on image index as array based on insert order) const img_obj = allInlineImages.reduce((obj, i) => { obj[i.getName()] = i; return obj; }, {}); //Regexp searches for all img string positions with cid const imgexp = /]+>/g; const matches = [...htmlBody.matchAll(imgexp)]; //Initiates the allInlineImages object const inlineImagesObj = {}; for (const match of matches) { inlineImagesObj[match[1]] = img_obj[match[2]]; } return { message: { subject: subject_line, text: msg.getPlainBody(), html: htmlBody, }, attachments: attachments, inlineImages: inlineImagesObj, }; } catch (e) { throw new Error("Oops - can't find Gmail draft"); } /** * Filter draft objects with the matching subject linemessage by matching the subject line. * @param {string} subject_line to search for draft message * @return {object} GmailDraft object */ function subjectFilter_(subject_line) { return (element) => { if (element.getMessage().getSubject() === subject_line) { return element; } }; } } /** * Fill template string with data object * @see https://stackoverflow.com/a/378000/1027723 * @param {string} template string containing {{}} markers which are replaced with data * @param {object} data object used to replace {{}} markers * @return {object} message replaced with data */ function fillInTemplateFromObject_(template, data) { // We have two templates one for plain text and the html body // Stringifing the object means we can do a global replace let template_string = JSON.stringify(template); // Token replacement template_string = template_string.replace(/{{[^{}]+}}/g, (key) => { return escapeData_(data[key.replace(/[{}]+/g, "")] || ""); }); return JSON.parse(template_string); } /** * Escape cell data to make JSON safe * @see https://stackoverflow.com/a/9204218/1027723 * @param {string} str to escape JSON special characters from * @return {string} escaped string */ function escapeData_(str) { return str .replace(/[\\]/g, "\\\\") .replace(/[\"]/g, '\\"') .replace(/[\/]/g, "\\/") .replace(/[\b]/g, "\\b") .replace(/[\f]/g, "\\f") .replace(/[\n]/g, "\\n") .replace(/[\r]/g, "\\r") .replace(/[\t]/g, "\\t"); } } ================================================ FILE: solutions/automations/mail-merge/README.md ================================================ # Create a mail merge with Gmail & Google Sheets See [developers.google.com](https://developers.google.com/apps-script/samples/automations/mail-merge) for additional details. ================================================ FILE: solutions/automations/mail-merge/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/news-sentiment/.clasp.json ================================================ { "scriptId": "1KHPvTOwE2pd2myZmvX0mbsp8SPlhJBFotNCwflZiP01xmTasNfibG4zl" } ================================================ FILE: solutions/automations/news-sentiment/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/news-sentiment /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Global variables const googleAPIKey = "YOUR_GOOGLE_API_KEY"; const newsApiKey = "YOUR_NEWS_API_KEY"; const apiEndPointHdr = "https://newsapi.org/v2/everything?q="; const happyFace = '=IMAGE("https://cdn.pixabay.com/photo/2016/09/01/08/24/smiley-1635449_1280.png")'; const mehFace = '=IMAGE("https://cdn.pixabay.com/photo/2016/09/01/08/24/smiley-1635450_1280.png")'; const sadFace = '=IMAGE("https://cdn.pixabay.com/photo/2016/09/01/08/25/smiley-1635454_1280.png")'; const happyColor = "#44f83d"; const mehColor = "#f7f6cc"; const sadColor = "#ff3c3d"; const fullsheet = "A2:D25"; const sentimentCols = "B2:D25"; const articleMax = 20; const threshold = 0.3; let headlines = []; let rows = null; let rowValues = null; let topic = null; let bottomRow = 0; let ds = null; let ss = null; let headerRow = null; let sentimentCol = null; let headlineCol = null; let scoreCol = null; /** * Creates menu in the Google Sheets spreadsheet when the spreadsheet is opened. * */ function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu("News Headlines Sentiments") .addItem("Analyze News Headlines...", "showNewsPrompt") .addToUi(); } /** * Prompts user to enter a new headline topic. * Calls main function AnalyzeHeadlines with entered topic. */ function showNewsPrompt() { //Initializes global variables ss = SpreadsheetApp.getActiveSpreadsheet(); ds = ss.getSheetByName("Sheet1"); headerRow = ds.getDataRange().getValues()[0]; sentimentCol = headerRow.indexOf("Sentiment"); headlineCol = headerRow.indexOf("Headlines"); scoreCol = headerRow.indexOf("Score"); // Builds Menu const ui = SpreadsheetApp.getUi(); const result = ui.prompt("Enter news topic:", ui.ButtonSet.OK_CANCEL); // Processes the user's response. const button = result.getSelectedButton(); topic = result.getResponseText(); if (button === ui.Button.OK) { analyzeNewsHeadlines(); } else if (button === ui.Button.CANCEL) { // Shows alert if user clicked "Cancel." ui.alert("News topic not selected!"); } } /** * For each headline cell, calls the Natural Language API to get general sentiment and then updates * the sentiment response column. */ function analyzeNewsHeadlines() { // Clears and reformats the sheet reformatSheet(); // Gets the headlines array headlines = getHeadlinesArray(); // Syncs the headlines array to the sheet using a single setValues call if (headlines.length > 0) { ds.getRange(2, 1, headlines.length, headlineCol + 1).setValues(headlines); // Set global rowValues rows = ds.getDataRange(); rowValues = rows.getValues(); getSentiments(); } else { ss.toast(`No headlines returned for topic: ${topic}!`); } } /** * Fetches current headlines from the Free News API */ function getHeadlinesArray() { // Fetches headlines for a given topic const hdlnsResp = []; const encodedtopic = encodeURIComponent(topic); ss.toast(`Getting headlines for: ${topic}`); const response = UrlFetchApp.fetch( `${apiEndPointHdr + encodedtopic}&apiKey=${newsApiKey}`, ); const results = JSON.parse(response); const articles = results.articles; for (let i = 0; i < articles.length && i < articleMax; i++) { let newsStory = articles[i].title; if (articles[i].description !== null) { newsStory += `: ${articles[i].description}`; } // Scrubs newsStory of invalid characters newsStory = scrub(newsStory); // Constructs hdlnsResp as a 2d array. This simplifies syncing to the sheet. hdlnsResp.push(new Array(newsStory)); } return hdlnsResp; } /** * For each article cell, calls the Natural Language API to get general sentiment and then updates * the sentiment response columns. */ function getSentiments() { ss.toast("Analyzing the headline sentiments..."); const articleCount = rows.getNumRows() - 1; let avg = 0; // Gets sentiment for each row for (let i = 1; i <= articleCount; i++) { const headlineCell = rowValues[i][headlineCol]; if (headlineCell) { const sentimentData = retrieveSentiment(headlineCell); const result = sentimentData.documentSentiment.score; avg += result; ds.getRange(i + 1, sentimentCol + 1).setBackgroundColor(getColor(result)); ds.getRange(i + 1, sentimentCol + 1).setValue(getFace(result)); ds.getRange(i + 1, scoreCol + 1).setValue(result); } } const avgDecimal = (avg / articleCount).toFixed(2); // Shows news topic and average face, color and sentiment value. bottomRow = articleCount + 3; ds.getRange(bottomRow, 1, headlines.length, scoreCol + 1).setFontWeight( "bold", ); ds.getRange(bottomRow, headlineCol + 1).setValue(`Topic: "${topic}"`); ds.getRange(bottomRow, headlineCol + 2).setValue("Avg:"); ds.getRange(bottomRow, sentimentCol + 1).setValue(getFace(avgDecimal)); ds.getRange(bottomRow, sentimentCol + 1).setBackgroundColor( getColor(avgDecimal), ); ds.getRange(bottomRow, scoreCol + 1).setValue(avgDecimal); ss.toast("Done!!"); } /** * Calls the Natureal Language API to get sentiment response for headline. * * Important note: Not all languages are supported by Google document * sentiment analysis. * Unsupported languages generate a "400" response: "INVALID_ARGUMENT". */ function retrieveSentiment(text) { // Sets REST call options const apiEndPoint = `https://language.googleapis.com/v1/documents:analyzeSentiment?key=${googleAPIKey}`; const jsonReq = JSON.stringify({ document: { type: "PLAIN_TEXT", content: text, }, encodingType: "UTF8", }); const options = { method: "post", contentType: "application/json", payload: jsonReq, }; // Makes the REST call const response = UrlFetchApp.fetch(apiEndPoint, options); const responseData = JSON.parse(response); return responseData; } // Helper Functions /** * Removes old headlines, sentiments and reset formatting */ function reformatSheet() { let range = ds.getRange(fullsheet); range.clearContent(); range.clearFormat(); range.setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP); range = ds.getRange(sentimentCols); // Center the sentiment cols only range.setHorizontalAlignment("center"); } /** * Returns a corresponding face based on numeric value. */ function getFace(value) { if (value >= threshold) { return happyFace; } if (value < threshold && value > -threshold) { return mehFace; } if (value <= -threshold) { return sadFace; } } /** * Returns a corresponding color based on numeric value. */ function getColor(value) { if (value >= threshold) { return happyColor; } if (value < threshold && value > -threshold) { return mehColor; } if (value <= -threshold) { return sadColor; } } /** * Scrubs invalid characters out of headline text. * Can be expanded if needed. */ function scrub(text) { return text.replace(/[\‘\,\“\”\"\'\’\-\n\â\€]/g, " "); } ================================================ FILE: solutions/automations/news-sentiment/README.md ================================================ # Connect to an external API: Analyze news headlines See [developers.google.com](https://developers.google.com/apps-script/samples/automations/news-sentiment) for additional details. ================================================ FILE: solutions/automations/news-sentiment/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/offsite-activity-signup/.clasp.json ================================================ { "scriptId": "10clpAH4ojSXvTlZaE74rhJ6dDwwkfvi24L_AilGROca5Nds2Jy2oZmvY" } ================================================ FILE: solutions/automations/offsite-activity-signup/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/offsite-activity-signup /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const NUM_ITEMS_TO_RANK = 5; const ACTIVITIES_PER_PERSON = 2; const NUM_TEST_USERS = 150; /** * Adds custom menu items when opening the sheet. */ function onOpen() { const menu = SpreadsheetApp.getUi() .createMenu("Activities") .addItem("Create form", "buildForm_") .addItem("Generate test data", "generateTestData_") .addItem("Assign activities", "assignActivities_") .addToUi(); } /** * Builds a form based on the "Activity Schedule" sheet. The form asks attendees to rank their top * N choices of activities, where N is defined by NUM_ITEMS_TO_RANK. */ function buildForm_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (ss.getFormUrl()) { const msg = "Form already exists. Unlink the form and try again."; SpreadsheetApp.getUi().alert(msg); return; } const form = FormApp.create("Activity Signup") .setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()) .setAllowResponseEdits(true) .setLimitOneResponsePerUser(true) .setCollectEmail(true); const sectionHelpText = Utilities.formatString( "Please choose your top %d activities", NUM_ITEMS_TO_RANK, ); form .addSectionHeaderItem() .setTitle("Activity choices") .setHelpText(sectionHelpText); // Presents activity ranking as a form grid with each activity as a row and rank as a column. const rows = loadActivitySchedule_(ss).map( (activity) => activity.description, ); const columns = range_(1, NUM_ITEMS_TO_RANK).map((value) => Utilities.formatString("%s", toOrdinal_(value)), ); const gridValidation = FormApp.createGridValidation() .setHelpText("Select one item per column.") .requireLimitOneResponsePerColumn() .build(); form .addGridItem() .setColumns(columns) .setRows(rows) .setValidation(gridValidation); form .addListItem() .setTitle("Assign other activities if choices are not available?") .setChoiceValues(["Yes", "No"]); } /** * Assigns activities using a random priority/random serial dictatorship approach. The results * are then populated into two new sheets, one listing activities per person, the other listing * the rosters for each activity. * * See https://en.wikipedia.org/wiki/Random_serial_dictatorship for additional information. */ function assignActivities_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const activities = loadActivitySchedule_(ss); const activityIds = activities.map((activity) => activity.id); const attendees = loadAttendeeResponses_(ss, activityIds); assignWithRandomPriority_(attendees, activities, 2); writeAttendeeAssignments_(ss, attendees); writeActivityRosters_(ss, activities); } /** * Selects activities via random priority. * * @param {object[]} attendees - Array of attendees to assign activities to * @param {object[]} activities - Array of all available activities * @param {number} numActivitiesPerPerson - Maximum number of activities to assign */ function assignWithRandomPriority_( attendees, activities, numActivitiesPerPerson, ) { const activitiesById = activities.reduce((obj, activity) => { obj[activity.id] = activity; return obj; }, {}); for (let i = 0; i < numActivitiesPerPerson; ++i) { const randomizedAttendees = shuffleArray_(attendees); for (const attendee of randomizedAttendees) { makeChoice_(attendee, activitiesById); } } } /** * Attempts to assign an activity for an attendee based on their preferences and current schedule. * * @param {object} attendee - Attendee looking to join an activity * @param {object} activitiesById - Map of all available activities */ function makeChoice_(attendee, activitiesById) { for (let i = 0; i < attendee.preferences.length; ++i) { const activity = activitiesById[attendee.preferences[i]]; if (!activity) { continue; } const canJoin = checkAvailability_(attendee, activity); if (canJoin) { attendee.assigned.push(activity); activity.roster.push(attendee); break; } } } /** * Checks that an activity has capacity and doesn't conflict with previously assigned * activities. * * @param {object} attendee - Attendee looking to join the activity * @param {object} activity - Proposed activity * @return {boolean} - True if attendee can join the activity */ function checkAvailability_(attendee, activity) { if (activity.capacity <= activity.roster.length) { return false; } const timesConflict = attendee.assigned.some( (assignedActivity) => !( assignedActivity.startAt.getTime() > activity.endAt.getTime() || activity.startAt.getTime() > assignedActivity.endAt.getTime() ), ); return !timesConflict; } /** * Populates a sheet with the assigned activities for each attendee. * * @param {Spreadsheet} ss - Spreadsheet to write to. * @param {object[]} attendees - Array of attendees with their activity assignments */ function writeAttendeeAssignments_(ss, attendees) { const sheet = findOrCreateSheetByName_(ss, "Activities by person"); sheet.clear(); sheet.appendRow(["Email address", "Activities"]); sheet.getRange("B1:1").merge(); const rows = attendees.map((attendee) => { // Prefill row to ensure consistent length otherwise // can't bulk update the sheet with range.setValues() const row = fillArray_([], ACTIVITIES_PER_PERSON + 1, ""); row[0] = attendee.email; attendee.assigned.forEach((activity, index) => { row[index + 1] = activity.description; }); return row; }); bulkAppendRows_(sheet, rows); sheet.setFrozenRows(1); sheet.getRange("1:1").setFontWeight("bold"); sheet.autoResizeColumns(1, sheet.getLastColumn()); } /** * Populates a sheet with the rosters for each activity. * * @param {Spreadsheet} ss - Spreadsheet to write to. * @param {object[]} activities - Array of activities with their rosters */ function writeActivityRosters_(ss, activities) { const sheet = findOrCreateSheetByName_(ss, "Activity rosters"); sheet.clear(); let rows = activities.map((activity) => { const roster = activity.roster.map((attendee) => attendee.email); return [activity.description].concat(roster); }); // Transpose the data so each activity is a column rows = transpose_(rows, ""); bulkAppendRows_(sheet, rows); sheet.setFrozenRows(1); sheet.getRange("1:1").setFontWeight("bold"); sheet.autoResizeColumns(1, sheet.getLastColumn()); } /** * Loads the activity schedule. * * @param {Spreadsheet} ss - Spreadsheet to load from * @return {object[]} Array of available activities. */ function loadActivitySchedule_(ss) { const timeZone = ss.getSpreadsheetTimeZone(); const sheet = ss.getSheetByName("Activity Schedule"); const rows = sheet.getSheetValues( sheet.getFrozenRows() + 1, 1, sheet.getLastRow() - 1, sheet.getLastRow(), ); const activities = rows.map((row, index) => { const name = row[0]; const startAt = new Date(row[1]); const endAt = new Date(row[2]); const capacity = Number.parseInt(row[3]); const formattedStartAt = Utilities.formatDate( startAt, timeZone, "EEE hh:mm a", ); const formattedEndAt = Utilities.formatDate(endAt, timeZone, "hh:mm a"); const description = Utilities.formatString( "%s (%s-%s)", name, formattedStartAt, formattedEndAt, ); return { id: index, name: name, description: description, capacity: capacity, startAt: startAt, endAt: endAt, roster: [], }; }); return activities; } /** * Loads the attendeee response data. * * @param {Spreadsheet} ss - Spreadsheet to load from * @param {number[]} allActivityIds - Full set of available activity IDs * @return {object[]} Array of parsed attendee respones. */ function loadAttendeeResponses_(ss, allActivityIds) { const sheet = findResponseSheetForForm_(ss); if (!sheet || sheet.getLastRow() === 1) { return undefined; } const rows = sheet.getSheetValues( sheet.getFrozenRows() + 1, 1, sheet.getLastRow() - 1, sheet.getLastRow(), ); const attendees = rows.map((row) => { const _ = row.shift(); // Ignore timestamp const email = row.shift(); const autoAssign = row.pop(); // Find ranked items in the response data. let preferences = row.reduce((prefs, value, index) => { const match = value.match(/(\d+).*/); if (!match) { return prefs; } const rank = Number.parseInt(match[1]) - 1; // Convert ordinal to array index prefs[rank] = index; return prefs; }, []); if (autoAssign === "Yes") { // If auto assigning additional activites, append a randomized list of all the activities. // These will then be considered as if the attendee ranked them. const additionalChoices = shuffleArray_(allActivityIds); preferences = preferences.concat(additionalChoices); } return { email: email, preferences: preferences, assigned: [], }; }); return attendees; } /** * Simulates a large number of users responding to the form. This enables users to quickly * experience the full solution without having to collect sufficient form responses * through other means. */ function generateTestData_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = findResponseSheetForForm_(ss); if (!sheet) { const msg = "No response sheet found. Create the form and try again."; SpreadsheetApp.getUi().alert(msg); } if (sheet.getLastRow() > 1) { const msg = "Response sheet is not empty, can not generate test data. " + "Remove responses and try again."; SpreadsheetApp.getUi().alert(msg); return; } const activities = loadActivitySchedule_(ss); const choices = fillArray_([], activities.length, ""); for (const value of range_(1, 5)) { choices[value] = toOrdinal_(value); } const rows = range_(1, NUM_TEST_USERS).map((value) => { const randomizedChoices = shuffleArray_(choices); const email = Utilities.formatString("person%d@example.com", value); return [new Date(), email].concat(randomizedChoices).concat(["Yes"]); }); bulkAppendRows_(sheet, rows); } /** * Retrieves a sheet by name, creating it if it doesn't yet exist. * * @param {Spreadsheet} ss - Containing spreadsheet * @Param {string} name - Name of sheet to return * @return {Sheet} Sheet instance */ function findOrCreateSheetByName_(ss, name) { const sheet = ss.getSheetByName(name); if (sheet) { return sheet; } return ss.insertSheet(name); } /** * Faster version of appending multiple rows via ranges. Requires all rows are equal length. * * @param {Sheet} sheet - Sheet to append to * @param {Array>} rows - Rows to append */ function bulkAppendRows_(sheet, rows) { const startRow = sheet.getLastRow() + 1; const startColumn = 1; const numRows = rows.length; const numColumns = rows[0].length; sheet.getRange(startRow, startColumn, numRows, numColumns).setValues(rows); } /** * Copies and randomizes an array. * * @param {object[]} array - Array to shuffle * @return {object[]} randomized copy of the array */ function shuffleArray_(array) { const clone = array.slice(0); for (let i = clone.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const temp = clone[i]; clone[i] = clone[j]; clone[j] = temp; } return clone; } /** * Formats an number as an ordinal. * * See: https://stackoverflow.com/questions/13627308/add-st-nd-rd-and-th-ordinal-suffix-to-a-number/13627586 * * @param {number} i - Number to format * @return {string} Formatted string */ function toOrdinal_(i) { const j = i % 10; const k = i % 100; if (j === 1 && k !== 11) { return `${i}st`; } if (j === 2 && k !== 12) { return `${i}nd`; } if (j === 3 && k !== 13) { return `${i}rd`; } return `${i}th`; } /** * Locates the sheet containing the form responses. * * @param {Spreadsheet} ss - Spreadsheet instance to search * @return {Sheet} Sheet with form responses, undefined if not found. */ function findResponseSheetForForm_(ss) { const formUrl = ss.getFormUrl(); if (!ss || !formUrl) { return undefined; } const sheets = ss.getSheets(); for (const i in sheets) { if (sheets[i].getFormUrl() === formUrl) { return sheets[i]; } } return undefined; } /** * Fills an array with a value ([].fill() not supported in Apps Script). * * @param {object[]} arr - Array to fill * @param {number} length - Number of items to fill. * @param {object} value - Value to place at each index. * @return {object[]} the array, for chaining purposes */ function fillArray_(arr, length, value) { for (let i = 0; i < length; ++i) { arr[i] = value; } return arr; } /** * Creates and fills an array with numbers in the range [start, end]. * * @param {number} start - First value in the range, inclusive * @param {number} end - Last value in the range, inclusive * @return {number[]} Array of values representing the range */ function range_(start, end) { const arr = [start]; let i = start; while (i < end) { i += 1; arr.push(i); } return arr; } /** * Transposes a matrix/2d array. For cases where the rows are not the same length, * `fillValue` is used where no other value would otherwise be present. * * @param {Array>} arr - 2D array to transpose * @param {object} fillValue - Placeholder for undefined values created as a result * of the transpose. Only required if rows aren't all of equal length. * @return {Array>} New transposed array */ function transpose_(arr, fillValue) { const transposed = []; for (const [rowIndex, row] of arr.entries()) { for (const [colIndex, col] of row.entries()) { transposed[colIndex] = transposed[colIndex] || fillArray_([], arr.length, fillValue); transposed[colIndex][rowIndex] = row[colIndex]; } } return transposed; } ================================================ FILE: solutions/automations/offsite-activity-signup/README.md ================================================ # Create a sign-up for an offsite See [developers.google.com](https://developers.google.com/apps-script/samples/automations/offsite-activity-signup) for additional details. ================================================ FILE: solutions/automations/offsite-activity-signup/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/tax-loss-harvest-alerts/.clasp.json ================================================ { "scriptId": "1SVf_XAGJiwksNTMnAwtlIvkKaDou4RLsmwGTa9ipVHKgwITgwXWqMixB" } ================================================ FILE: solutions/automations/tax-loss-harvest-alerts/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/tax-loss-harvest-alerts /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Checks for losses in the sheet. */ function checkLosses() { // Pulls data from the spreadsheet const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Calculations"); const source = sheet.getRange("A:G"); const data = source.getValues(); //Prepares the email alert content let message = "Stocks:

    "; let send_message = false; console.log("starting loop"); //Loops through the cells in the spreadsheet to find cells where the stock fell below purchase price let n = 0; for (const i in data) { //Skips the first row if (n++ === 0) continue; //Loads the current row const row = data[i]; console.log(row[1]); console.log(row[6]); //Once at the end of the list, exits the loop if (row[1] === "") break; //If value is below purchase price, adds stock ticker and difference to list of tax loss opportunities if (row[6] < 0) { message += `${row[1]}: ${(Number.parseFloat(row[6].toString()) * 100).toFixed(2).toString()}%
    `; send_message = true; } } if (!send_message) return; MailApp.sendEmail({ to: SpreadsheetApp.getActiveSpreadsheet().getOwner().getEmail(), subject: "Tax-loss harvest", htmlBody: message, }); } ================================================ FILE: solutions/automations/tax-loss-harvest-alerts/README.md ================================================ # Get stock price drop alerts See [developers.google.com](https://developers.google.com/apps-script/samples/automations/tax-loss-harvest-alerts) for additional details. ================================================ FILE: solutions/automations/tax-loss-harvest-alerts/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/timesheets/.clasp.json ================================================ { "scriptId": "1uzOldn2RjqdrbDJwxuPlcsb7twKLdW59YPS02rbEg_ajAG9XzrYF1-fH" } ================================================ FILE: solutions/automations/timesheets/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/timesheets /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Global variables representing the index of certain columns. const COLUMN_NUMBER = { EMAIL: 2, HOURS_START: 4, HOURS_END: 8, HOURLY_PAY: 9, TOTAL_HOURS: 10, CALC_PAY: 11, APPROVAL: 12, NOTIFY: 13, }; // Global variables: const APPROVED_EMAIL_SUBJECT = "Weekly Timesheet APPROVED"; const REJECTED_EMAIL_SUBJECT = "Weekly Timesheet NOT APPROVED"; const APPROVED_EMAIL_MESSAGE = "Your timesheet has been approved."; const REJECTED_EMAIL_MESSAGE = "Your timesheet has not been approved."; /** * Creates the menu item "Timesheets" for user to run scripts on drop-down. */ function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu("Timesheets") .addItem("Form setup", "setUpForm") .addItem("Column setup", "columnSetup") .addItem("Notify employees", "checkApprovedStatusToNotify") .addToUi(); } /** * Adds "WEEKLY PAY" column with calculated values using array formulas. * Adds an "APPROVAL" column at the end of the sheet, containing * drop-down menus to either approve/disapprove employee timesheets. * Adds a "NOTIFIED STATUS" column indicating whether or not an * employee has yet been e mailed. */ function columnSetup() { const sheet = SpreadsheetApp.getActiveSheet(); const lastCol = sheet.getLastColumn(); const lastRow = sheet.getLastRow(); const frozenRows = sheet.getFrozenRows(); const beginningRow = frozenRows + 1; const numRows = lastRow - frozenRows; // Calls helper functions to add new columns. addCalculatePayColumn(sheet, beginningRow); addApprovalColumn(sheet, beginningRow, numRows); addNotifiedColumn(sheet, beginningRow, numRows); } /** * Adds TOTAL HOURS and CALCULATE PAY columns and automatically calculates * every employee's weekly pay. * * @param {Object} sheet Spreadsheet object of current sheet. * @param {Integer} beginningRow Index of beginning row. */ function addCalculatePayColumn(sheet, beginningRow) { sheet.insertColumnAfter(COLUMN_NUMBER.HOURLY_PAY); sheet.getRange(1, COLUMN_NUMBER.TOTAL_HOURS).setValue("TOTAL HOURS"); sheet.getRange(1, COLUMN_NUMBER.CALC_PAY).setValue("WEEKLY PAY"); // Calculates weekly total hours. sheet .getRange(beginningRow, COLUMN_NUMBER.TOTAL_HOURS) .setFormula("=ArrayFormula(D2:D+E2:E+F2:F+G2:G+H2:H)"); // Calculates weekly pay. sheet .getRange(beginningRow, COLUMN_NUMBER.CALC_PAY) .setFormula("=ArrayFormula(I2:I * J2:J)"); } /** * Adds an APPROVAL column allowing managers to approve/ * disapprove of each employee's timesheet. * * @param {Object} sheet Spreadsheet object of current sheet. * @param {Integer} beginningRow Index of beginning row. * @param {Integer} numRows Number of rows currently in use. */ function addApprovalColumn(sheet, beginningRow, numRows) { sheet.insertColumnAfter(COLUMN_NUMBER.CALC_PAY); sheet.getRange(1, COLUMN_NUMBER.APPROVAL).setValue("APPROVAL"); // Make sure approval column is all drop-down menus. const approvalColumnRange = sheet.getRange( beginningRow, COLUMN_NUMBER.APPROVAL, numRows, 1, ); const dropdownValues = ["APPROVED", "NOT APPROVED", "IN PROGRESS"]; const rule = SpreadsheetApp.newDataValidation() .requireValueInList(dropdownValues) .build(); approvalColumnRange.setDataValidation(rule); approvalColumnRange.setValue("IN PROGRESS"); } /** * Adds a NOTIFIED column allowing managers to see which employees * have/have not yet been notified of their approval status. * * @param {Object} sheet Spreadsheet object of current sheet. * @param {Integer} beginningRow Index of beginning row. * @param {Integer} numRows Number of rows currently in use. */ function addNotifiedColumn(sheet, beginningRow, numRows) { sheet.insertColumnAfter(COLUMN_NUMBER.APPROVAL); // global sheet.getRange(1, COLUMN_NUMBER.APPROVAL + 1).setValue("NOTIFIED STATUS"); // Make sure notified column is all drop-down menus. const notifiedColumnRange = sheet.getRange( beginningRow, COLUMN_NUMBER.APPROVAL + 1, numRows, 1, ); const dropdownValues = ["NOTIFIED", "PENDING"]; const rule = SpreadsheetApp.newDataValidation() .requireValueInList(dropdownValues) .build(); notifiedColumnRange.setDataValidation(rule); notifiedColumnRange.setValue("PENDING"); } /** * Sets the notification status to NOTIFIED for employees * who have received a notification email. * * @param {Object} sheet Current Spreadsheet. * @param {Object} notifiedValues Array of notified values. * @param {Integer} i Current status in the for loop. * @parma {Integer} beginningRow Row where iterations began. */ function updateNotifiedStatus(sheet, notifiedValues, i, beginningRow) { // Update notification status. notifiedValues[i][0] = "NOTIFIED"; sheet.getRange(i + beginningRow, COLUMN_NUMBER.NOTIFY).setValue("NOTIFIED"); } /** * Checks the approval status of every employee, and calls helper functions * to notify employees via email & update their notification status. */ function checkApprovedStatusToNotify() { const sheet = SpreadsheetApp.getActiveSheet(); const lastRow = sheet.getLastRow(); const lastCol = sheet.getLastColumn(); // lastCol here is the NOTIFIED column. const frozenRows = sheet.getFrozenRows(); const beginningRow = frozenRows + 1; const numRows = lastRow - frozenRows; // Gets ranges of email, approval, and notified values for every employee. const emailValues = sheet .getRange(beginningRow, COLUMN_NUMBER.EMAIL, numRows, 1) .getValues(); const approvalValues = sheet .getRange(beginningRow, COLUMN_NUMBER.APPROVAL, lastRow - frozenRows, 1) .getValues(); const notifiedValues = sheet .getRange(beginningRow, COLUMN_NUMBER.NOTIFY, numRows, 1) .getValues(); // Traverses through employee's row. for (let i = 0; i < numRows; i++) { // Do not notify twice. if (notifiedValues[i][0] === "NOTIFIED") { continue; } const emailAddress = emailValues[i][0]; const approvalValue = approvalValues[i][0]; // Sends notifying emails & update status. if (approvalValue === "IN PROGRESS") { } else if (approvalValue === "APPROVED") { MailApp.sendEmail( emailAddress, APPROVED_EMAIL_SUBJECT, APPROVED_EMAIL_MESSAGE, ); updateNotifiedStatus(sheet, notifiedValues, i, beginningRow); } else if (approvalValue === "NOT APPROVED") { MailApp.sendEmail( emailAddress, REJECTED_EMAIL_SUBJECT, REJECTED_EMAIL_MESSAGE, ); updateNotifiedStatus(sheet, notifiedValues, i, beginningRow); } } } /** * Set up the Timesheets Responses form, & link the form's trigger to * send manager an email when a new request is submitted. */ function setUpForm() { const sheet = SpreadsheetApp.getActiveSpreadsheet(); if (sheet.getFormUrl()) { const msg = "Form already exists. Unlink the form and try again."; SpreadsheetApp.getUi().alert(msg); return; } // Create the form. const form = FormApp.create("Weekly Timesheets") .setCollectEmail(true) .setDestination(FormApp.DestinationType.SPREADSHEET, sheet.getId()) .setLimitOneResponsePerUser(false); form.addTextItem().setTitle("Employee Name:").setRequired(true); form.addTextItem().setTitle("Monday Hours:").setRequired(true); form.addTextItem().setTitle("Tuesday Hours:").setRequired(true); form.addTextItem().setTitle("Wednesday Hours:").setRequired(true); form.addTextItem().setTitle("Thursday Hours:").setRequired(true); form.addTextItem().setTitle("Friday Hours:").setRequired(true); form.addTextItem().setTitle("HourlyWage:").setRequired(true); // Set up on form submit trigger. ScriptApp.newTrigger("onFormSubmit").forForm(form).onFormSubmit().create(); } /** * Handle new form submissions to trigger the workflow. * * @param {Object} event Form submit event */ function onFormSubmit(event) { const response = getResponsesByName(event.response); // Load form responses into a new row. const row = [ "New", "", response["Emoloyee Email:"], response["Employee Name:"], response["Monday Hours:"], response["Tuesday Hours:"], response["Wednesday Hours:"], response["Thursday Hours:"], response["Friday Hours:"], response["Hourly Wage:"], ]; const sheet = SpreadsheetApp.getActiveSpreadsheet(); sheet.appendRow(row); } /** * Converts a form response to an object keyed by the item titles. Allows easier * access to response values. * * @param {FormResponse} response * @return {Object} Form values keyed by question title */ function getResponsesByName(response) { const initialValue = { email: response.getRespondentEmail(), timestamp: response.getTimestamp(), }; return response.getItemResponses().reduce((obj, itemResponse) => { const key = itemResponse.getItem().getTitle(); obj[key] = itemResponse.getResponse(); return obj; }, initialValue); } ================================================ FILE: solutions/automations/timesheets/README.md ================================================ # Collect and review timesheets from employees See [developers.google.com](https://developers.google.com/apps-script/samples/automations/timesheets) for additional details. ================================================ FILE: solutions/automations/timesheets/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/upload-files/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/upload-files /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // [START apps_script_upload_files] // TODO Before you start using this sample, you must run the setUp() // function in the Setup.gs file. // Application constants const APP_TITLE = "Upload files to Drive from Forms"; const APP_FOLDER_NAME = "Upload files to Drive (File responses)"; // Identifies the subfolder form item const APP_SUBFOLDER_ITEM = "Subfolder"; const APP_SUBFOLDER_NONE = ""; /** * Gets the file uploads from a form response and moves files to the corresponding subfolder. * * @param {object} event - Form submit. */ function onFormSubmit(e) { try { // Gets the application root folder. let destFolder = getFolder_(APP_FOLDER_NAME); // Gets all form responses. const itemResponses = e.response.getItemResponses(); // Determines the subfolder to route the file to, if any. let subFolderName; const dest = itemResponses.filter( (itemResponse) => itemResponse.getItem().getTitle().toString() === APP_SUBFOLDER_ITEM, ); // Gets the destination subfolder name, but ignores if APP_SUBFOLDER_NONE was selected; if (dest.length > 0) { if (dest[0].getResponse() !== APP_SUBFOLDER_NONE) { subFolderName = dest[0].getResponse(); } } // Gets the subfolder or creates it if it doesn't exist. if (subFolderName !== undefined) { destFolder = getSubFolder_(destFolder, subFolderName); } console.log(`Destination folder to use: Name: ${destFolder.getName()} ID: ${destFolder.getId()} URL: ${destFolder.getUrl()}`); // Gets the file upload response as an array to allow for multiple files. const fileUploads = itemResponses .filter( (itemResponse) => itemResponse.getItem().getType().toString() === "FILE_UPLOAD", ) .map((itemResponse) => itemResponse.getResponse()) .reduce((a, b) => a.concat(b), []); // Moves the files to the destination folder. if (fileUploads.length > 0) { for (const fileId of fileUploads) { DriveApp.getFileById(fileId).moveTo(destFolder); console.log(`File Copied: ${fileId}`); } } } catch (err) { console.log(err); } } /** * Returns a Drive folder under the passed in objParentFolder parent * folder. Checks if folder of same name exists before creating, returning * the existing folder or the newly created one if not found. * * @param {object} objParentFolder - Drive folder as an object. * @param {string} subFolderName - Name of subfolder to create/return. * @return {object} Drive folder */ function getSubFolder_(objParentFolder, subFolderName) { // Iterates subfolders of parent folder to check if folder already exists. const subFolders = objParentFolder.getFolders(); while (subFolders.hasNext()) { const folder = subFolders.next(); // Returns the existing folder if found. if (folder.getName() === subFolderName) { return folder; } } // Creates a new folder if one doesn't already exist. return objParentFolder .createFolder(subFolderName) .setDescription( `Created by ${APP_TITLE} application to store uploaded Forms files.`, ); } // [END apps_script_upload_files] ================================================ FILE: solutions/automations/upload-files/README.md ================================================ # Upload files to Google Drive from Google Forms See [developers.google.com](https://developers.google.com/apps-script/samples/automations/upload-files) for additional details. ================================================ FILE: solutions/automations/upload-files/Setup.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_upload_files_setup] // TODO You must run the setUp() function before you start using this sample. /** * The setUp() function performs the following: * - Creates a Google Drive folder named by the APP_FOLDER_NAME * variable in the Code.gs file. * - Creates a trigger to handle onFormSubmit events. */ function setUp() { // Ensures the root destination folder exists. const appFolder = getFolder_(APP_FOLDER_NAME); if (appFolder !== null) { console.log(`Application folder setup. Name: ${appFolder.getName()} ID: ${appFolder.getId()} URL: ${appFolder.getUrl()}`); } else { console.log("Could not setup application folder."); } // Calls the function that creates the Forms onSubmit trigger. installTrigger_(); } /** * Returns a folder to store uploaded files in the same location * in Drive where the form is located. First, it checks if the folder * already exists, and creates it if it doesn't. * * @param {string} folderName - Name of the Drive folder. * @return {object} Google Drive Folder */ function getFolder_(folderName) { // Gets the Drive folder where the form is located. const ssId = FormApp.getActiveForm().getId(); const parentFolder = DriveApp.getFileById(ssId).getParents().next(); // Iterates through the subfolders to check if folder already exists. // The script checks for the folder name specified in the APP_FOLDER_NAME variable. const subFolders = parentFolder.getFolders(); while (subFolders.hasNext()) { const folder = subFolders.next(); // Returns the existing folder if found. if (folder.getName() === folderName) { return folder; } } // Creates a new folder if one doesn't already exist. return parentFolder .createFolder(folderName) .setDescription( `Created by ${APP_TITLE} application to store uploaded files.`, ); } /** * Installs trigger to capture onFormSubmit event when a form is submitted. * Ensures that the trigger is only installed once. * Called by setup(). */ function installTrigger_() { // Ensures existing trigger doesn't already exist. const propTriggerId = PropertiesService.getScriptProperties().getProperty("triggerUniqueId"); if (propTriggerId !== null) { const triggers = ScriptApp.getProjectTriggers(); for (const t in triggers) { if (triggers[t].getUniqueId() === propTriggerId) { console.log( `Trigger with the following unique ID already exists: ${propTriggerId}`, ); return; } } } // Creates the trigger if one doesn't exist. const triggerUniqueId = ScriptApp.newTrigger("onFormSubmit") .forForm(FormApp.getActiveForm()) .onFormSubmit() .create() .getUniqueId(); PropertiesService.getScriptProperties().setProperty( "triggerUniqueId", triggerUniqueId, ); console.log( `Trigger with the following unique ID was created: ${triggerUniqueId}`, ); } /** * Removes all script properties and triggers for the project. * Use primarily to test setup routines. */ function removeTriggersAndScriptProperties() { PropertiesService.getScriptProperties().deleteAllProperties(); // Removes all triggers associated with project. const triggers = ScriptApp.getProjectTriggers(); for (const t in triggers) { ScriptApp.deleteTrigger(triggers[t]); } } /** * Removes all form responses to reset the form. */ function deleteAllResponses() { FormApp.getActiveForm().deleteAllResponses(); } // [END apps_script_upload_files_setup] ================================================ FILE: solutions/automations/upload-files/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/vacation-calendar/.clasp.json ================================================ { "scriptId": "1jvPSSwJcuLzlDLDy2dr-qorjihiTNAW2H6B5k-dJxHjEPX6hMcNghzSh" } ================================================ FILE: solutions/automations/vacation-calendar/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/vacation-calendar /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Set the ID of the team calendar to add events to. You can find the calendar's // ID on the settings page. const TEAM_CALENDAR_ID = "ENTER_TEAM_CALENDAR_ID_HERE"; // Set the email address of the Google Group that contains everyone in the team. // Ensure the group has less than 500 members to avoid timeouts. // Change to an array in order to add indirect members frrm multiple groups, for example: // let GROUP_EMAIL = ['ENTER_GOOGLE_GROUP_EMAIL_HERE', 'ENTER_ANOTHER_GOOGLE_GROUP_EMAIL_HERE']; const GROUP_EMAIL = "ENTER_GOOGLE_GROUP_EMAIL_HERE"; const ONLY_DIRECT_MEMBERS = false; const KEYWORDS = ["vacation", "ooo", "out of office", "offline"]; const MONTHS_IN_ADVANCE = 3; /** * Sets up the script to run automatically every hour. */ function setup() { const triggers = ScriptApp.getProjectTriggers(); if (triggers.length > 0) { throw new Error("Triggers are already setup."); } ScriptApp.newTrigger("sync").timeBased().everyHours(1).create(); // Runs the first sync immediately. sync(); } /** * Looks through the group members' public calendars and adds any * 'vacation' or 'out of office' events to the team calendar. */ function sync() { // Defines the calendar event date range to search. const today = new Date(); const maxDate = new Date(); maxDate.setMonth(maxDate.getMonth() + MONTHS_IN_ADVANCE); // Determines the time the the script was last run. let lastRun = PropertiesService.getScriptProperties().getProperty("lastRun"); lastRun = lastRun ? new Date(lastRun) : null; // Gets the list of users in the Google Group. let users = getAllMembers(GROUP_EMAIL); if (ONLY_DIRECT_MEMBERS) { users = GroupsApp.getGroupByEmail(GROUP_EMAIL).getUsers(); } else if (Array.isArray(GROUP_EMAIL)) { users = getUsersFromGroups(GROUP_EMAIL); } // For each user, finds events having one or more of the keywords in the event // summary in the specified date range. Imports each of those to the team // calendar. let count = 0; for (const user of users) { const username = user.getEmail().split("@")[0]; const events = findEvents(user, today, maxDate, lastRun); for (const event of events) { importEvent(username, event); count++; } } PropertiesService.getScriptProperties().setProperty("lastRun", today); console.log(`Imported ${count} events`); } /** * Imports the given event from the user's calendar into the shared team * calendar. * @param {string} username The team member that is attending the event. * @param {Calendar.Event} event The event to import. */ function importEvent(username, event) { event.summary = `[${username}] ${event.summary}`; event.organizer = { id: TEAM_CALENDAR_ID, }; event.attendees = []; // If the event is not of type 'default', it can't be imported, so it needs // to be changed. if (event.eventType !== "default") { event.eventType = "default"; event.outOfOfficeProperties = undefined; event.focusTimeProperties = undefined; } console.log("Importing: %s", event.summary); try { Calendar.Events.import(event, TEAM_CALENDAR_ID); } catch (e) { console.error( "Error attempting to import event: %s. Skipping.", e.toString(), ); } } /** * In a given user's calendar, looks for occurrences of the given keyword * in events within the specified date range and returns any such events * found. * @param {Session.User} user The user to retrieve events for. * @param {string} keyword The keyword to look for. * @param {Date} start The starting date of the range to examine. * @param {Date} end The ending date of the range to examine. * @param {Date} optSince A date indicating the last time this script was run. * @return {Calendar.Event[]} An array of calendar events. */ function findEvents(user, start, end, optSince) { const params = { eventTypes: "outOfOffice", timeMin: formatDateAsRFC3339(start), timeMax: formatDateAsRFC3339(end), showDeleted: true, }; if (optSince) { // This prevents the script from examining events that have not been // modified since the specified date (that is, the last time the // script was run). params.updatedMin = formatDateAsRFC3339(optSince); } let pageToken = null; let events = []; do { params.pageToken = pageToken; let response; try { response = Calendar.Events.list(user.getEmail(), params); } catch (e) { console.error( "Error retriving events for %s, %s: %s; skipping", user, keyword, e.toString(), ); continue; } events = events.concat(response.items); pageToken = response.nextPageToken; } while (pageToken); return events; } /** * Returns an RFC3339 formated date String corresponding to the given * Date object. * @param {Date} date a Date. * @return {string} a formatted date string. */ function formatDateAsRFC3339(date) { return Utilities.formatDate(date, "UTC", "yyyy-MM-dd'T'HH:mm:ssZ"); } /** * Get both direct and indirect members (and delete duplicates). * @param {string} the e-mail address of the group. * @return {object} direct and indirect members. */ function getAllMembers(groupEmail) { const group = GroupsApp.getGroupByEmail(groupEmail); let users = group.getUsers(); const childGroups = group.getGroups(); for (let i = 0; i < childGroups.length; i++) { const childGroup = childGroups[i]; users = users.concat(getAllMembers(childGroup.getEmail())); } // Remove duplicate members const uniqueUsers = []; const userEmails = {}; for (let i = 0; i < users.length; i++) { const user = users[i]; if (!userEmails[user.getEmail()]) { uniqueUsers.push(user); userEmails[user.getEmail()] = true; } } return uniqueUsers; } /** * Get indirect members from multiple groups (and delete duplicates). * @param {array} the e-mail addresses of multiple groups. * @return {object} indirect members of multiple groups. */ function getUsersFromGroups(groupEmails) { const users = []; for (const groupEmail of groupEmails) { const groupUsers = GroupsApp.getGroupByEmail(groupEmail).getUsers(); for (const user of groupUsers) { if (!users.some((u) => u.getEmail() === user.getEmail())) { users.push(user); } } } return users; } ================================================ FILE: solutions/automations/vacation-calendar/README.md ================================================ # Populate a team vacation calendar See [developers.google.com](https://developers.google.com/apps-script/samples/automations/vacation-calendar) for additional details. ================================================ FILE: solutions/automations/vacation-calendar/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/youtube-tracker/.clasp.json ================================================ { "scriptId": "15WP4FukVYk_4zy21j0_13GftPH7J8lpdtemYcy_168TYKsAQ4x-pAeQz" } ================================================ FILE: solutions/automations/youtube-tracker/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/automations/youtube-tracker /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Sets preferences for email notification. Choose 'Y' to send emails, 'N' to skip emails. const EMAIL_ON = "Y"; // Matches column names in Video sheet to variables. If the column names change, update these variables. const COLUMN_NAME = { VIDEO: "Video Link", TITLE: "Video Title", }; /** * Gets YouTube video details and statistics for all * video URLs listed in 'Video Link' column in each * sheet. Sends email summary, based on preferences above, * when videos have new comments or replies. */ function markVideos() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheets = SpreadsheetApp.getActiveSpreadsheet().getSheets(); // Runs through process for each tab in Spreadsheet. for (const dataSheet of sheets) { const tabName = dataSheet.getName(); const range = dataSheet.getDataRange(); const numRows = range.getNumRows(); const rows = range.getValues(); const headerRow = rows[0]; // Finds the column indices. const videoColumnIdx = headerRow.indexOf(COLUMN_NAME.VIDEO); const titleColumnIdx = headerRow.indexOf(COLUMN_NAME.TITLE); // Creates empty array to collect data for email table. const emailContent = []; // Processes each row in spreadsheet. for (let i = 1; i < numRows; ++i) { const row = rows[i]; // Extracts video ID. const videoId = extractVideoIdFromUrl(row[videoColumnIdx]); // Processes each row that contains a video ID. if (!videoId) { continue; } // Calls getVideoDetails function and extracts target data for the video. const detailsResponse = getVideoDetails(videoId); const title = detailsResponse.items[0].snippet.title; const publishDate = detailsResponse.items[0].snippet.publishedAt; const publishDateFormatted = new Date(publishDate); const views = detailsResponse.items[0].statistics.viewCount; const likes = detailsResponse.items[0].statistics.likeCount; const comments = detailsResponse.items[0].statistics.commentCount; const channel = detailsResponse.items[0].snippet.channelTitle; // Collects title, publish date, channel, views, comments, likes details and pastes into tab. const detailsRow = [ title, publishDateFormatted, channel, views, comments, likes, ]; dataSheet .getRange(i + 1, titleColumnIdx + 1, 1, 6) .setValues([detailsRow]); // Determines if new count of comments/replies is greater than old count of comments/replies. const addlCommentCount = comments - row[titleColumnIdx + 4]; // Adds video title, link, and additional comment count to table if new counts > old counts. if (addlCommentCount > 0) { const emailRow = [title, row[videoColumnIdx], addlCommentCount]; emailContent.push(emailRow); } } // Sends notification email if Content is not empty. if (emailContent.length > 0 && EMAIL_ON === "Y") { sendEmailNotificationTemplate(emailContent, tabName); } } } /** * Gets video details for YouTube videos * using YouTube advanced service. */ function getVideoDetails(videoId) { const part = "snippet,statistics"; const response = YouTube.Videos.list(part, { id: videoId }); return response; } /** * Extracts YouTube video ID from url. * (h/t https://stackoverflow.com/a/3452617) */ function extractVideoIdFromUrl(url) { let videoId = url.split("v=")[1]; const ampersandPosition = videoId.indexOf("&"); if (ampersandPosition !== -1) { videoId = videoId.substring(0, ampersandPosition); } return videoId; } /** * Assembles notification email with table of video details. * (h/t https://stackoverflow.com/questions/37863392/making-table-in-google-apps-script-from-array) */ function sendEmailNotificationTemplate(content, emailAddress) { const template = HtmlService.createTemplateFromFile("email"); template.content = content; const msg = template.evaluate(); MailApp.sendEmail( emailAddress, "New comments or replies on YouTube", msg.getContent(), { htmlBody: msg.getContent() }, ); } ================================================ FILE: solutions/automations/youtube-tracker/README.md ================================================ # Track YouTube video views and comments See [developers.google.com](https://developers.google.com/apps-script/samples/automations/youtube-tracker) for additional details. ================================================ FILE: solutions/automations/youtube-tracker/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/automations/youtube-tracker/email.html ================================================ Hello,

    You have new comments and/or replies on videos:

    Video Title Link Number of new replies and comments
    ================================================ FILE: solutions/custom-functions/calculate-driving-distance/.clasp.json ================================================ { "scriptId": "1_cfhZv-VJBekzu1V4mFD1C5ggRaUumWw9rUz0NaLED6XD4_yHB-eJ01a" } ================================================ FILE: solutions/custom-functions/calculate-driving-distance/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/custom-functions/calculate-driving-distance /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @OnlyCurrentDoc Limits the script to only accessing the current sheet. */ /** * A special function that runs when the spreadsheet is open, used to add a * custom menu to the spreadsheet. */ function onOpen() { try { const spreadsheet = SpreadsheetApp.getActive(); const menuItems = [ { name: "Prepare sheet...", functionName: "prepareSheet_" }, { name: "Generate step-by-step...", functionName: "generateStepByStep_" }, ]; spreadsheet.addMenu("Directions", menuItems); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * A custom function that converts meters to miles. * * @param {Number} meters The distance in meters. * @return {Number} The distance in miles. */ function metersToMiles(meters) { if (typeof meters !== "number") { return null; } return (meters / 1000) * 0.621371; } /** * A custom function that gets the driving distance between two addresses. * * @param {String} origin The starting address. * @param {String} destination The ending address. * @return {Number} The distance in meters. */ function drivingDistance(origin, destination) { const directions = getDirections_(origin, destination); return directions.routes[0].legs[0].distance.value; } /** * A function that adds headers and some initial data to the spreadsheet. */ function prepareSheet_() { try { const sheet = SpreadsheetApp.getActiveSheet().setName("Settings"); const headers = [ "Start Address", "End Address", "Driving Distance (meters)", "Driving Distance (miles)", ]; const initialData = [ "350 5th Ave, New York, NY 10118", "405 Lexington Ave, New York, NY 10174", ]; sheet.getRange("A1:D1").setValues([headers]).setFontWeight("bold"); sheet.getRange("A2:B2").setValues([initialData]); sheet.setFrozenRows(1); sheet.autoResizeColumns(1, 4); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Creates a new sheet containing step-by-step directions between the two * addresses on the "Settings" sheet that the user selected. */ function generateStepByStep_() { try { const spreadsheet = SpreadsheetApp.getActive(); const settingsSheet = spreadsheet.getSheetByName("Settings"); settingsSheet.activate(); // Prompt the user for a row number. const selectedRow = Browser.inputBox( "Generate step-by-step", "Please enter the row number of" + " the" + " addresses to use" + ' (for example, "2"):', Browser.Buttons.OK_CANCEL, ); if (selectedRow === "cancel") { return; } const rowNumber = Number(selectedRow); if ( Number.isNaN(rowNumber) || rowNumber < 2 || rowNumber > settingsSheet.getLastRow() ) { Browser.msgBox( "Error", Utilities.formatString('Row "%s" is not valid.', selectedRow), Browser.Buttons.OK, ); return; } // Retrieve the addresses in that row. const row = settingsSheet.getRange(rowNumber, 1, 1, 2); const rowValues = row.getValues(); const origin = rowValues[0][0]; const destination = rowValues[0][1]; if (!origin || !destination) { Browser.msgBox( "Error", "Row does not contain two addresses.", Browser.Buttons.OK, ); return; } // Get the raw directions information. const directions = getDirections_(origin, destination); // Create a new sheet and append the steps in the directions. const sheetName = `Driving Directions for Row ${rowNumber}`; let directionsSheet = spreadsheet.getSheetByName(sheetName); if (directionsSheet) { directionsSheet.clear(); directionsSheet.activate(); } else { directionsSheet = spreadsheet.insertSheet( sheetName, spreadsheet.getNumSheets(), ); } const sheetTitle = Utilities.formatString( "Driving Directions from %s to %s", origin, destination, ); const headers = [ [sheetTitle, "", ""], ["Step", "Distance (Meters)", "Distance (Miles)"], ]; const newRows = []; for (const step of directions.routes[0].legs[0].steps) { // Remove HTML tags from the instructions. const instructions = XmlService.parse( `${step.html_instructions}`, ) .getRootElement() .getText(); newRows.push([instructions, step.distance.value]); } directionsSheet.getRange(1, 1, headers.length, 3).setValues(headers); directionsSheet .getRange(headers.length + 1, 1, newRows.length, 2) .setValues(newRows); directionsSheet .getRange(headers.length + 1, 3, newRows.length, 1) .setFormulaR1C1("=METERSTOMILES(R[0]C[-1])"); // Format the new sheet. directionsSheet.getRange("A1:C1").merge().setBackground("#ddddee"); directionsSheet.getRange("A1:2").setFontWeight("bold"); directionsSheet.setColumnWidth(1, 500); directionsSheet.getRange("B2:C").setVerticalAlignment("top"); directionsSheet.getRange("C2:C").setNumberFormat("0.00"); const stepsRange = directionsSheet .getDataRange() .offset(2, 0, directionsSheet.getLastRow() - 2); setAlternatingRowBackgroundColors_(stepsRange, "#ffffff", "#eeeeee"); directionsSheet.setFrozenRows(2); SpreadsheetApp.flush(); } catch (e) { // TODO (Developer) - Handle Exception console.log(`Failed with error: %s${e.error}`); } } /** * Sets the background colors for alternating rows within the range. * @param {Range} range The range to change the background colors of. * @param {string} oddColor The color to apply to odd rows (relative to the * start of the range). * @param {string} evenColor The color to apply to even rows (relative to the * start of the range). */ function setAlternatingRowBackgroundColors_(range, oddColor, evenColor) { const backgrounds = []; for (let row = 1; row <= range.getNumRows(); row++) { const rowBackgrounds = []; for (let column = 1; column <= range.getNumColumns(); column++) { if (row % 2 === 0) { rowBackgrounds.push(evenColor); } else { rowBackgrounds.push(oddColor); } } backgrounds.push(rowBackgrounds); } range.setBackgrounds(backgrounds); } /** * A shared helper function used to obtain the full set of directions * information between two addresses. Uses the Apps Script Maps Service. * * @param {String} origin The starting address. * @param {String} destination The ending address. * @return {Object} The directions response object. */ function getDirections_(origin, destination) { const directionFinder = Maps.newDirectionFinder(); directionFinder.setOrigin(origin); directionFinder.setDestination(destination); const directions = directionFinder.getDirections(); if (directions.status !== "OK") { throw directions.error_message; } return directions; } ================================================ FILE: solutions/custom-functions/calculate-driving-distance/README.md ================================================ # Calculate driving distance & convert meters to miles See [developers.google.com](https://developers.google.com/apps-script/samples/custom-functions/calculate-driving-distance) for additional details. ================================================ FILE: solutions/custom-functions/calculate-driving-distance/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/custom-functions/summarize-sheets-data/.clasp.json ================================================ { "scriptId": "1NN-ROSZO3ZsfiVUlCdmNqggpCQuGNtgO_r0nehV0s5mkZJN2bcMTri-7" } ================================================ FILE: solutions/custom-functions/summarize-sheets-data/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/custom-functions/summarize-sheets-data /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Gets summary data from other sheets. The sheets you want to summarize must have columns with headers that match the names of the columns this function summarizes data from. * * @return {string} Summary data from other sheets. * @customfunction */ // The following sheets are ignored. Add additional constants for other sheets that should be ignored. const READ_ME_SHEET_NAME = "ReadMe"; const PM_SHEET_NAME = "Summary"; /** * Reads data ranges for each sheet. Filters and counts based on 'Status' columns. To improve performance, the script uses arrays * until all summary data is gathered. Then the script writes the summary array starting at the cell of the custom function. */ function getSheetsData() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheets = ss.getSheets(); const outputArr = []; // For each sheet, summarizes the data and pushes to a temporary array. for (const s in sheets) { // Gets sheet name. const sheetNm = sheets[s].getName(); // Skips ReadMe and Summary sheets. if (sheetNm === READ_ME_SHEET_NAME || sheetNm === PM_SHEET_NAME) { continue; } // Gets sheets data. const values = sheets[s].getDataRange().getValues(); // Gets the first row of the sheet which is the header row. const headerRowValues = values[0]; // Finds the columns with the heading names 'Owner Name' and 'Status' and gets the index value of each. // Using 'indexOf()' to get the position of each column prevents the script from breaking if the columns change positions in a sheet. const columnOwner = headerRowValues.indexOf("Owner Name"); const columnStatus = headerRowValues.indexOf("Status"); // Removes header row. values.splice(0, 1); // Gets the 'Owner Name' column value by retrieving the first data row in the array. const owner = values[0][columnOwner]; // Counts the total number of tasks. const taskCnt = values.length; // Counts the number of tasks that have the 'Complete' status. // If the options you want to count in your spreadsheet differ, update the strings below to match the text of each option. // To add more options, copy the line below and update the string to the new text. const completeCnt = filterByPosition( values, "Complete", columnStatus, ).length; // Counts the number of tasks that have the 'In-Progress' status. const inProgressCnt = filterByPosition( values, "In-Progress", columnStatus, ).length; // Counts the number of tasks that have the 'Scheduled' status. const scheduledCnt = filterByPosition( values, "Scheduled", columnStatus, ).length; // Counts the number of tasks that have the 'Overdue' status. const overdueCnt = filterByPosition(values, "Overdue", columnStatus).length; // Builds the output array. outputArr.push([ owner, taskCnt, completeCnt, inProgressCnt, scheduledCnt, overdueCnt, sheetNm, ]); } // Writes the output array. return outputArr; } /** * Below is a helper function that filters a 2-dimenstional array. */ function filterByPosition(array, find, position) { return array.filter((innerArray) => innerArray[position] === find); } ================================================ FILE: solutions/custom-functions/summarize-sheets-data/README.md ================================================ # Summarize data from multiple sheets See [developers.google.com](https://developers.google.com/apps-script/samples/custom-functions/summarize-sheets-data) for additional details. ================================================ FILE: solutions/custom-functions/summarize-sheets-data/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/custom-functions/tier-pricing/.clasp.json ================================================ { "scriptId": "1-ql7ECe91XZgWu-hW_UZBx8mhuTtQQj0yNITYh8yQCOuHxLEjxtTngGB" } ================================================ FILE: solutions/custom-functions/tier-pricing/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/samples/custom-functions/tier-pricing /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Calculates the tiered pricing discount. * * You must provide a value to calculate its discount. The value can be a string or a reference * to a cell that contains a string. * You must provide a data table range, for example, $B$4:$D$7, that includes the * tier start, end, and percent columns. If your table has headers, don't include * the headers in the range. * * @param {string} value The value to calculate the discount for, which can be a string or a * reference to a cell that contains a string. * @param {string} table The tier table data range using A1 notation. * @return number The total discount amount for the value. * @customfunction * */ function tierPrice(value, table) { let total = 0; // Creates an array for each row of the table and loops through each array. for (const [start, end, percent] of table) { // Checks if the value is less than the starting value of the tier. If it is less, the loop stops. if (value < start) { break; } // Calculates the portion of the value to be multiplied by the tier's percent value. const amount = Math.min(value, end) - start; // Multiplies the amount by the tier's percent value and adds the product to the total. total += amount * percent; } return total; } ================================================ FILE: solutions/custom-functions/tier-pricing/README.md ================================================ # Calculate a tiered pricing discount See [developers.google.com](https://developers.google.com/apps-script/samples/custom-functions/tier-pricing) for additional details. ================================================ FILE: solutions/custom-functions/tier-pricing/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/editor-add-on/clean-sheet/.clasp.json ================================================ { "scriptId": "10bxhn6eGypm20dgRcTbUCbzP4Bz0dyYR6IZTNEA2gIXXxwoy8Zqs06yr" } ================================================ FILE: solutions/editor-add-on/clean-sheet/Code.js ================================================ // To learn how to use this script, refer to the documentation: // https://developers.google.com/apps-script/add-ons/clean-sheet /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Application Constants const APP_TITLE = "Clean sheet"; /** * Identifies and deletes empty rows in selected range of active sheet. * * Cells that contain space characters are treated as non-empty. * The entire row, including the cells outside of the selected range, * must be empty to be deleted. * * Called from menu option. */ function deleteEmptyRows() { const sheet = SpreadsheetApp.getActiveSheet(); // Gets active selection and dimensions. const activeRange = sheet.getActiveRange(); const rowCount = activeRange.getHeight(); const firstActiveRow = activeRange.getRow(); const columnCount = sheet.getMaxColumns(); // Tests that the selection is a valid range. if (rowCount < 1) { showMessage("Select a valid range."); return; } // Tests active range isn't too large to process. Enforces limit set to 10k. if (rowCount > 10000) { showMessage( "Selected range too large. Select up to 10,000 rows at one time.", ); return; } // Utilizes an array of values for efficient processing to determine blank rows. const activeRangeValues = sheet .getRange(firstActiveRow, 1, rowCount, columnCount) .getValues(); // Checks if array is all empty values. const valueFilter = (value) => value !== ""; const isRowEmpty = (row) => { return row.filter(valueFilter).length === 0; }; // Maps the range values as an object with value (to test) and corresponding row index (with offset from selection). const rowsToDelete = activeRangeValues .map((row, index) => ({ row, offset: index + activeRange.getRowIndex() })) .filter((item) => isRowEmpty(item.row)) // Test to filter out non-empty rows. .map((item) => item.offset); //Remap to include just the row indexes that will be removed. // Combines a sorted, ascending list of indexes into a set of ranges capturing consecutive values as start/end ranges. // Combines sequential empty rows for faster processing. const rangesToDelete = rowsToDelete.reduce((ranges, index) => { const currentRange = ranges[ranges.length - 1]; if (currentRange && index === currentRange[1] + 1) { currentRange[1] = index; return ranges; } ranges.push([index, index]); return ranges; }, []); // Sends a list of row indexes to be deleted to the console. console.log(rangesToDelete); // Deletes the rows using REVERSE order to ensure proper indexing is used. for (const [start, end] of rangesToDelete.reverse()) { sheet.deleteRows(start, end - start + 1); } SpreadsheetApp.flush(); } /** * Removes blank columns in a selected range. * * Cells containing Space characters are treated as non-empty. * The entire column, including cells outside of the selected range, * must be empty to be deleted. * * Called from menu option. */ function deleteEmptyColumns() { const sheet = SpreadsheetApp.getActiveSheet(); // Gets active selection and dimensions. const activeRange = sheet.getActiveRange(); const rowCountMax = sheet.getMaxRows(); const columnWidth = activeRange.getWidth(); const firstActiveColumn = activeRange.getColumn(); // Tests that the selection is a valid range. if (columnWidth < 1) { showMessage("Select a valid range."); return; } // Tests active range is not too large to process. Enforces limit set to 1k. if (columnWidth > 1000) { showMessage( "Selected range too large. Select up to 10,000 rows at one time.", ); return; } // Utilizes an array of values for efficient processing to determine blank columns. const activeRangeValues = sheet .getRange(1, firstActiveColumn, rowCountMax, columnWidth) .getValues(); // Transposes the array of range values so it can be processed in order of columns. const activeRangeValuesTransposed = activeRangeValues[0].map((_, colIndex) => activeRangeValues.map((row) => row[colIndex]), ); // Checks if array is all empty values. const valueFilter = (value) => value !== ""; const isColumnEmpty = (column) => { return column.filter(valueFilter).length === 0; }; // Maps the range values as an object with value (to test) and corresponding column index (with offset from selection). const columnsToDelete = activeRangeValuesTransposed .map((column, index) => ({ column, offset: index + firstActiveColumn })) .filter((item) => isColumnEmpty(item.column)) // Test to filter out non-empty rows. .map((item) => item.offset); //Remap to include just the column indexes that will be removed. // Combines a sorted, ascending list of indexes into a set of ranges capturing consecutive values as start/end ranges. // Combines sequential empty columns for faster processing. const rangesToDelete = columnsToDelete.reduce((ranges, index) => { const currentRange = ranges[ranges.length - 1]; if (currentRange && index === currentRange[1] + 1) { currentRange[1] = index; return ranges; } ranges.push([index, index]); return ranges; }, []); // Sends a list of column indexes to be deleted to the console. console.log(rangesToDelete); // Deletes the columns using REVERSE order to ensure proper indexing is used. for (const [start, end] of rangesToDelete.reverse()) { sheet.deleteColumns(start, end - start + 1); } SpreadsheetApp.flush(); } /** * Trims all of the unused rows and columns outside of selected data range. * * Called from menu option. */ function cropSheet() { const dataRange = SpreadsheetApp.getActiveSheet().getDataRange(); const sheet = dataRange.getSheet(); let numRows = dataRange.getNumRows(); let numColumns = dataRange.getNumColumns(); const maxRows = sheet.getMaxRows(); const maxColumns = sheet.getMaxColumns(); const numFrozenRows = sheet.getFrozenRows(); const numFrozenColumns = sheet.getFrozenColumns(); // If last data row is less than maximium row, then deletes rows after the last data row. if (numRows < maxRows) { numRows = Math.max(numRows, numFrozenRows + 1); // Don't crop empty frozen rows. sheet.deleteRows(numRows + 1, maxRows - numRows); } // If last data column is less than maximium column, then deletes columns after the last data column. if (numColumns < maxColumns) { numColumns = Math.max(numColumns, numFrozenColumns + 1); // Don't crop empty frozen columns. sheet.deleteColumns(numColumns + 1, maxColumns - numColumns); } } /** * Copies value of active cell to the blank cells beneath it. * Stops at last row of the sheet's data range if only blank cells are encountered. * * Called from menu option. */ function fillDownData() { const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); // Gets sheet's active cell and confirms it's not empty. const activeCell = sheet.getActiveCell(); const activeCellValue = activeCell.getValue(); if (!activeCellValue) { showMessage("The active cell is empty. Nothing to fill."); return; } // Gets coordinates of active cell. const column = activeCell.getColumn(); const row = activeCell.getRow(); // Gets entire data range of the sheet. const dataRange = sheet.getDataRange(); const dataRangeRows = dataRange.getNumRows(); // Gets trimmed range starting from active cell to the end of sheet data range. const searchRange = dataRange.offset( row - 1, column - 1, dataRangeRows - row + 1, 1, ); const searchValues = searchRange.getDisplayValues(); // Find the number of empty rows below the active cell. let i = 1; // Start at 1 to skip the ActiveCell. while (searchValues[i] && searchValues[i][0] === "") { i++; } // If blanks exist, fill the range with values. if (i > 1) { const fillRange = searchRange.offset(0, 0, i, 1).setValue(activeCellValue); //sheet.setActiveRange(fillRange) // Uncomment to test affected range. } else { showMessage("There are no empty cells below the Active Cell to fill."); } } /** * A helper function to display messages to user. * * @param {string} message - Message to be displayed. * @param {string} caller - {Optional} text to append to title. */ function showMessage(message, caller) { // Sets the title using the APP_TITLE variable; adds optional caller string. let title = APP_TITLE; if (caller != null) { title += ` : ${caller}`; } const ui = SpreadsheetApp.getUi(); ui.alert(title, message, ui.ButtonSet.OK); } ================================================ FILE: solutions/editor-add-on/clean-sheet/Menu.js ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Creates a menu entry in the Google Sheets Extensions menu when the document is opened. * * @param {object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { // Builds a menu that displays under the Extensions menu in Sheets. const menu = SpreadsheetApp.getUi().createAddonMenu(); menu .addItem("Delete blank rows (from selected rows only)", "deleteEmptyRows") .addItem( "Delete blank columns (from selected columns only)", "deleteEmptyColumns", ) .addItem("Crop sheet to data range", "cropSheet") .addSeparator() .addItem("Fill in blank rows below", "fillDownData") .addSeparator() .addItem("About", "aboutApp") .addToUi(); } /** * Runs when the add-on is installed; calls onOpen() to ensure menu creation and * any other initializion work is done immediately. This method is only used by * the desktop add-on and is never called by the mobile version. * * @param {object} e The event parameter for a simple onInstall trigger. */ function onInstall(e) { onOpen(e); } /** * About box for context and developer contact information. * TODO: Personalize */ function aboutApp() { const msg = ` Name: ${APP_TITLE} Version: 1.0 Contact: `; const ui = SpreadsheetApp.getUi(); ui.alert("About this application", msg, ui.ButtonSet.OK); } ================================================ FILE: solutions/editor-add-on/clean-sheet/README.md ================================================ # Clean up data in a spreadsheet See [developers.google.com](https://developers.google.com/apps-script/add-ons/clean-sheet) for additional details. ================================================ FILE: solutions/editor-add-on/clean-sheet/appsscript.json ================================================ { "timeZone": "America/New_York", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: solutions/ooo-assistant/.clasp.json ================================================ { "scriptId": "16L_UmGrkrDKYWrfw9YlnUnnnWOMBEWywyPrZDZIQqKF17Q97RtZeinqn" } ================================================ FILE: solutions/ooo-assistant/Chat.gs ================================================ /** * Copyright 2025 Google LLC. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const APP_COMMAND = "app command"; /** * Responds to an ADDED_TO_SPACE event in Google Chat. * @param {Object} event the event object from Google Workspace Add On */ function onAddedToSpace(event) { return sendCreateMessageAction(createCardMessage(help(APP_COMMAND))); } /** * Responds to a MESSAGE event in Google Chat. * @param {Object} event the event object from Google Workspace Add On */ function onMessage(event) { return sendCreateMessageAction(createCardMessage(help(APP_COMMAND))); } /** * Responds to a APP_COMMAND event in Google Chat. * @param {Object} event the event object from Google Workspace Add On */ function onAppCommand(event) { switch (event.chat.appCommandPayload.appCommandMetadata.appCommandId) { case 2: // Block out day return sendCreateMessageAction(createCardMessage(blockDayOut())); case 3: // Set auto reply return sendCreateMessageAction(createCardMessage(setAutoReply())); default: // Help, any other return sendCreateMessageAction(createCardMessage(help(APP_COMMAND))); } } /** * Responds to a REMOVED_FROM_SPACE event in Google Chat. * @param {Object} event the event object from Google Workspace Add On */ function onRemovedFromSpace(event) { const space = event.chat.removedFromSpacePayload.space; console.info(`Chat app removed from ${space.name || "this chat"}`); } // ---------------------- // Util functions // ---------------------- function createTextMessage(text) { return { text: text }; } function createCardMessage(card) { return { cardsV2: [{ card: card }] }; } function sendCreateMessageAction(message) { return { hostAppDataAction: { chatDataAction: { createMessageAction: { message: message } }, }, }; } ================================================ FILE: solutions/ooo-assistant/Common.gs ================================================ /** * Copyright 2025 Google LLC. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const UNIVERSAL_ACTION = "universal action"; // ---------------------- // Homepage util functions // ---------------------- /** * Responds to homepage load request. */ function onHomepage() { return help(); } // ---------------------- // Action util functions // ---------------------- // Help action: Show add-on details. function help(featureName = UNIVERSAL_ACTION) { return { header: addOnCardHeader(), sections: [ { widgets: [ { decoratedText: { text: `Hi! 👋 Feel free to use the following ${featureName}s:`, wrapText: true, }, }, { decoratedText: { text: "⛔ Block day out: I will block out your calendar for today.", wrapText: true, }, }, { decoratedText: { text: "↩️ Set auto reply: I will set an OOO auto reply in your Gmail.", wrapText: true, }, }, ], }, ], }; } // Block day out action: Adds an all-day event to the user's Google Calendar. function blockDayOut() { blockOutCalendar(); return createActionResponseCard( "Your calendar is now blocked out for today.", ); } // Creates an OOO event in the user's Calendar. function blockOutCalendar() { function getDateAndHours(hour, minutes) { const date = new Date(); date.setHours(hour); date.setMinutes(minutes); date.setSeconds(0); date.setMilliseconds(0); return date.toISOString(); } const event = { start: { dateTime: getDateAndHours(9, 0) }, end: { dateTime: getDateAndHours(17, 0) }, eventType: "outOfOffice", summary: "OOO", outOfOfficeProperties: { autoDeclineMode: "declineOnlyNewConflictingInvitations", declineMessage: "Declined because OOO.", }, }; Calendar.Events.insert(event, "primary"); } // Set auto reply action: Set OOO auto reply in the user's Gmail . function setAutoReply() { turnOnAutoResponder(); return createActionResponseCard( "The out of office auto reply has been turned on.", ); } // Turns on the user's vacation response for today in Gmail. function turnOnAutoResponder() { const ONE_DAY_MILLIS = 24 * 60 * 60 * 1000; const currentTime = new Date().getTime(); Gmail.Users.Settings.updateVacation( { enableAutoReply: true, responseSubject: "I am OOO today", responseBodyHtml: "I am OOO today.

    Created by OOO Assistant add-on!", restrictToContacts: true, restrictToDomain: true, startTime: currentTime, endTime: currentTime + ONE_DAY_MILLIS, }, "me", ); } // ---------------------- // Card util functions // ---------------------- function addOnCardHeader() { return { title: "OOO Assistant", subtitle: "Helping manage your OOO", imageUrl: "https://goo.gle/3SfMkjb", }; } // Create an action response card function createActionResponseCard(text) { return { header: addOnCardHeader(), sections: [ { widgets: [ { decoratedText: { startIcon: { iconUrl: "https://fonts.gstatic.com/s/i/short-term/web/system/1x/task_alt_gm_grey_48dp.png", }, text: text, wrapText: true, }, }, ], }, ], }; } ================================================ FILE: solutions/ooo-assistant/README.md ================================================ # Build a Google Workspace add-on extending all UIs The add-on extends the following Google Workspace UIs: Chat, Calendar, Gmail, Drive, Docs, Sheets, and Slides. It relies on app commands in Chat, and homepage and universal actions in the others. It's featured in [this YT video](https://www.youtube.com/watch?v=pDthZ2xssDc). ================================================ FILE: solutions/ooo-assistant/appsscript.json ================================================ { "timeZone": "America/New_York", "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Gmail", "version": "v1", "serviceId": "gmail" }, { "userSymbol": "Calendar", "version": "v3", "serviceId": "calendar" } ] }, "addOns": { "common": { "name": "OOO Assistant", "logoUrl": "https://goo.gle/3SfMkjb", "homepageTrigger": { "runFunction": "onHomepage" }, "universalActions": [ { "label": "Block day out", "runFunction": "blockDayOut" }, { "label": "Set auto reply", "runFunction": "setAutoReply" } ] }, "chat": {}, "calendar": {}, "gmail": {}, "drive": {}, "docs": {}, "sheets": {}, "slides": {} } } ================================================ FILE: solutions/webhook-chat-app/README.md ================================================ # Google Chat App Webhook Please see related guide on how to [send messages to Google Chat with incoming webhooks](https://developers.google.com/workspace/chat/quickstart/webhooks). ================================================ FILE: solutions/webhook-chat-app/thread-reply.gs ================================================ /** * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START chat_webhook_thread] function webhook() { const url = "https://chat.googleapis.com/v1/spaces/SPACE_ID/messages?key=KEY&token=TOKEN&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" const options = { "method": "post", "headers": {"Content-Type": "application/json; charset=UTF-8"}, "payload": JSON.stringify({ "text": "Hello from Apps Script!", "thread": { "threadKey": "THREAD_KEY_VALUE" } }) }; const response = UrlFetchApp.fetch(url, options); console.log(response); } // [END chat_webhook_thread] ================================================ FILE: solutions/webhook-chat-app/webhook.gs ================================================ /** * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START chat_webhook] function webhook() { const url = "https://chat.googleapis.com/v1/spaces/SPACE_ID/messages?key=KEY&token=TOKEN" const options = { "method": "post", "headers": {"Content-Type": "application/json; charset=UTF-8"}, "payload": JSON.stringify({ "text": "Hello from Apps Script!" }) }; const response = UrlFetchApp.fetch(url, options); console.log(response); } // [END chat_webhook] ================================================ FILE: tasks/quickstart/quickstart.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START tasks_quickstart] /** * Lists the user's tasks. * @see https://developers.google.com/tasks/reference/rest/v1/tasklists/list */ function listTaskLists() { const optionalArgs = { maxResults: 10, }; try { // Returns all the authenticated user's task lists. const response = Tasks.Tasklists.list(optionalArgs); const taskLists = response.items; // Print task list of user if available. if (!taskLists || taskLists.length === 0) { console.log("No task lists found."); return; } for (const taskList of taskLists) { console.log("%s (%s)", taskList.title, taskList.id); } } catch (err) { // TODO (developer) - Handle exception from Task API console.log("Failed with error %s", err.message); } } // [END tasks_quickstart] ================================================ FILE: tasks/simpleTasks/README.md ================================================ # Simple Tasks Simple Tasks is a sample web app built using Apps Script that provides limited read and write access to your data in [Google Tasks](https://mail.google.com/tasks/canvas). It was created using the [HTML Service](https://developers.google.com/apps-script/guides/html-service) and demonstrates some common patterns and best practices to use when developing user interfaces. ![Simple tasks screenshot](screenshot.png) ## Try it out For your convience we have a [deployed instance](https://script.google.com/macros/s/AKfycbx-sB0Lp8JVgfvVoXkFtLsxMzqvOdfjG7VDo8OAeLusUDkFLj8/exec) of the script already running. The application supports reading your task lists and tasks, marking tasks as complete or incomplete, and adding new tasks to a task list. ## Setup The first step is to create your script and copy in the code. The simplest way to do this is to [make a copy](https://script.google.com/d/1HCsbqH8WNEKFwRZCw8KEhykCGEzfXi-1k5eN-7t8lZoEAAvfqzOOsKtu/edit?newcopy=true) of the deployed instance of the script. If you wish to create your project from scratch, follow the steps below. 1. Create a new standalone script in your Google Drive ([instructions available here](https://developers.google.com/apps-script/managing_projects#creatingDrive)) and add in each of the files in this directory. You should already have a file named Code.gs in your project, and you can replace its contents with the new code. For the remaining files, ensure you select **File > New > HTML file** when creating the files, and when entering the filename omit the `.html` suffix as it will be added automatically. 2. Enabled the Google Tasks API on the script ([instructions available here](https://developers.google.com/apps-script/built_in_services#advanced_google_services)). 3. Save a new version of your script and publish it as a web app that runs as the **User acessing the web app**. ([instructions available here](https://developers.google.com/apps-script/execution_web_apps)). ================================================ FILE: tasks/simpleTasks/appsscript.json ================================================ { "timeZone": "America/Los_Angeles", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Tasks", "serviceId": "tasks", "version": "v1" } ] }, "exceptionLogging": "STACKDRIVER" } ================================================ FILE: tasks/simpleTasks/javascript.html ================================================ ================================================ FILE: tasks/simpleTasks/page.html ================================================

    Simple Tasks

    This application allows you to view your Google Tasks, mark them as complete or incomplete, and add new tasks.

    Tasks
      Loading...
    ================================================ FILE: tasks/simpleTasks/simpleTasks.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Special function that handles HTTP GET requests to the published web app. * @return {HtmlOutput} The HTML page to be served. */ function doGet() { return HtmlService.createTemplateFromFile("page") .evaluate() .setTitle("Simple Tasks"); } /** * Returns the ID and name of every task list in the user's account. * @return {Array.} The task list data. */ function getTaskLists() { const taskLists = Tasks.Tasklists.list().getItems(); if (!taskLists) { return []; } return taskLists.map((taskList) => ({ id: taskList.getId(), name: taskList.getTitle(), })); } /** * Returns information about the tasks within a given task list. * @param {String} taskListId The ID of the task list. * @return {Array.} The task data. */ function getTasks(taskListId) { const tasks = Tasks.Tasks.list(taskListId).getItems(); if (!tasks) { return []; } return tasks .map((task) => ({ id: task.getId(), title: task.getTitle(), notes: task.getNotes(), completed: Boolean(task.getCompleted()), })) .filter((task) => task.title); } /** * Sets the completed status of a given task. * @param {String} taskListId The ID of the task list. * @param {String} taskId The ID of the task. * @param {Boolean} completed True if the task should be marked as complete, false otherwise. */ function setCompleted(taskListId, taskId, completed) { const task = Tasks.newTask(); if (completed) { task.setStatus("completed"); } else { task.setStatus("needsAction"); task.setCompleted(null); } Tasks.Tasks.patch(task, taskListId, taskId); } /** * Adds a new task to the task list. * @param {String} taskListId The ID of the task list. * @param {String} title The title of the new task. */ function addTask(taskListId, title) { const task = Tasks.newTask().setTitle(title); Tasks.Tasks.insert(task, taskListId); } ================================================ FILE: tasks/simpleTasks/stylesheet.html ================================================ ================================================ FILE: templates/README.md ================================================ # Templates for Google Apps Script Templates that provide an initial, working framework for Apps Script projects. ## Introduction Google Apps Script allows developers to extend and maniplate Google Docs, Sheets and Forms. For those just starting with Apps Script, it can be useful to have a template to work from -- a framework that developers can learn from and modify to suit their needs. This collection hosts the following templates: * Custom Functions for Sheets * Google Docs Add-on * Google Sheets Add-on * Google Forms Add-on * Script as Web App Within these templates the following Google Apps Script concepts are illustrated: * [Dialogs and Sidebars](https://developers.google.com/apps-script/guides/dialogs) * Using [Templated HTML](https://developers.google.com/apps-script/guides/html/templates) * Responding to HTTP GET requests with doGet(e) * Using IFRAME sandbox mode ## Getting Started Templates can be accessed from the Apps Script editor Welcome Screen (which is shown when the editor is first opened or by clicking the "Help > Welcome screen" menu item. Selecting a template from the Welcome Screen will create a new project pre-populated with the code you need to get started. Alternatively, the code provided in this repository can be manually copied into the Apps Script editor. Note that certain templates need to be used in a container-bound script (that is, the template is meant to be in a script attached to a Doc, Sheet or Form, rather than a standalone script). ================================================ FILE: templates/custom-functions/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @OnlyCurrentDoc Limits the script to only accessing the current spreadsheet. */ /** * A function that takes a single input value and returns a single value. * Returns a simple concatenation of Strings. * * @param {String} name A name to greet. * @return {String} A greeting. * @customfunction */ function SAY_HELLO(name) { return `Hello ${name}`; } /** * A function that takes an input cell or range of cells and returns a cell or * range of cells. * Returns a range with all the input values incremented by one. * * @param {Array} input The range of numbers to increment. * @return {Array} The incremented values. * @customfunction */ function INCREMENT(input) { if (Array.isArray(input)) { // Recurse to process an array. return input.map(INCREMENT); } if (!(typeof input === "number")) { throw new Error("Input contains a cell value that is not a number"); } // Otherwise process as a single value. return input + 1; } /** * A function that takes an range of values and returns a single value. * Returns the sum the corner values in the range; for a single cell, * this is equal to (4 * the cell value). * * @param {Array} input The Range of numbers to sum the corners of. * @return {Number} The calculated sum. * @customfunction */ function CORNER_SUM(input) { if (!Array.isArray(input)) { // Handle non-range inputs by putting them in an array. return CORNER_SUM([[input]]); // eslint-disable-line new-cap } // Range processing here. const maxRowIndex = input.length - 1; const maxColIndex = input[0].length - 1; return ( input[0][0] + input[0][maxColIndex] + input[maxRowIndex][0] + input[maxRowIndex][maxColIndex] ); } /** * A function that takes a single value and returns a range of values. * Returns a range consisting of the first 10 powers and roots of that * number (with column headers). * * @param {Number} input The number to calculate from. * @return {Array} The first ten powers and roots of that number, * with associated labels. * @customfunction */ function POWERS_AND_ROOTS(input) { if (Array.isArray(input)) { throw new Error("Invalid: Range input not permitted"); } // Value processing and range generation here. const headers = ["x", `${input}^x`, `${input}^(1/x)`]; const result = [headers]; for (let i = 1; i <= 10; i++) { result.push([i, input ** i, input ** (1 / i)]); } return result; } /** * A function that takes a single input cell that is Date- or Date time-formatted. * Returns the day of the year represented by the provided date. * * @param {Date} date A Date to examine. * @return {Number} The day of year for that date. * @customfunction */ function GET_DAY_OF_YEAR(date) { if (!(date instanceof Date)) { throw new Error("Invalid: Date input required"); } // Date processing here. const firstOfYear = new Date(date.getFullYear(), 0, 0); const diff = date - firstOfYear; const oneDay = 1000 * 60 * 60 * 24; return Math.floor(diff / oneDay); } /** * A function that takes a single input cell that is Duration-formatted. * Returns the number of seconds measured by that duration. * * @param {Date} duration A duration to convert. * @return {Number} Number of seconds in that duration. * @customfunction */ function CONVERT_DURATION_TO_SECONDS(duration) { if (!(duration instanceof Date)) { throw new Error("Invalid: Duration input required"); } // Getting elapsed times from duration-formatted cells in Sheets requires // subtracting the reference date from the cell value (while correcting for // timezones). const spreadsheetTimezone = SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone(); const dateString = Utilities.formatDate( duration, spreadsheetTimezone, "EEE, d MMM yyyy HH:mm:ss", ); const date = new Date(dateString); const epoch = new Date("Dec 30, 1899 00:00:00"); const durationInMilliseconds = date.getTime() - epoch.getTime(); // Duration processing here. return Math.round(durationInMilliseconds / 1000); } ================================================ FILE: templates/custom-functions/README.md ================================================ Template: Custom Functions for Sheets ===================================== This template provides a framework for creating custom functions in Google Sheets. It shows the structure needed to define a custom function and its autocomplete documentation, and provides a few examples. The examples provided here demonstrate: * How to create custom functions that accept arguments of different types * How to document a custom function to generate correct autocomplete information in Sheets Note that this template must be added to a container-bound script attached to a Google Sheet in order to function. For more information, see [Custom Functions in Google Sheets](https://developers.google.com/apps-script/guides/sheets/functions). In addition, developers of Sheets custom functions should be aware of the [known issues specific to Google Sheets](https://developers.google.com/apps-script/migration/sheets). ================================================ FILE: templates/docs-addon/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @OnlyCurrentDoc Limits the script to only accessing the current document. */ const DIALOG_TITLE = "Example Dialog"; const SIDEBAR_TITLE = "Example Sidebar"; /** * Adds a custom menu with items to show the sidebar and dialog. * * @param {Object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { DocumentApp.getUi() .createAddonMenu() .addItem("Show sidebar", "showSidebar") .addItem("Show dialog", "showDialog") .addToUi(); } /** * Runs when the add-on is installed; calls onOpen() to ensure menu creation and * any other initializion work is done immediately. * * @param {Object} e The event parameter for a simple onInstall trigger. */ function onInstall(e) { onOpen(e); } /** * Opens a sidebar. The sidebar structure is described in the Sidebar.html * project file. */ function showSidebar() { const ui = HtmlService.createTemplateFromFile("Sidebar") .evaluate() .setTitle(SIDEBAR_TITLE); DocumentApp.getUi().showSidebar(ui); } /** * Opens a dialog. The dialog structure is described in the Dialog.html * project file. */ function showDialog() { const ui = HtmlService.createTemplateFromFile("Dialog") .evaluate() .setWidth(400) .setHeight(150); DocumentApp.getUi().showModalDialog(ui, DIALOG_TITLE); } /** * Returns the existing footer text (if any). * * @return {String} existing document footer text (as a plain string). */ function getFooterText() { // Retrieve and return the information requested by the sidebar. return DocumentApp.getActiveDocument().getFooter().getText(); } /** * Replaces the current document footer with the given text. * * @param {String} footerText text collected from the client-side * sidebar. */ function setFooterText(footerText) { // Use data collected from sidebar to manipulate the document. DocumentApp.getActiveDocument().getFooter().setText(footerText); } /** * Returns the document title. * * @return {String} the current document title. */ function getDocTitle() { // Retrieve and return the information requested by the dialog. return DocumentApp.getActiveDocument().getName(); } /** * Changes the document title. * * @param {String} title the new title to use for the document. */ function setDocTitle(title) { // Use data collected from dialog to manipulate the document. DocumentApp.getActiveDocument().setName(title); } ================================================ FILE: templates/docs-addon/Dialog.html ================================================
    ================================================ FILE: templates/docs-addon/DialogJavaScript.html ================================================ ================================================ FILE: templates/docs-addon/README.md ================================================ Template: Google Docs Add-on ============================ This template provides a framework for creating a [Google Docs add-on](https://developers.google.com/apps-script/add-ons/). It shows the structure needed to define a UI (including menus, a sidebar and dialog) and how to coordinate communication between the UI client and the server where the Doc resides. This template also covers some basic uses of Apps Script with Google Docs, including: * Reading and writing text to and from a Google Doc * Getting and modifying basic file information, such as the file title Note that add-ons that work with Google Docs will usually need to read and manipulate the (sometimes complex) [Doc structure](https://developers.google.com/apps-script/guides/docs#structure_of_a_document). Finally, note that this template must be added to a container-bound script attached to a Google Doc in order to function. Developed add-ons must go through a [publishing process](https://developers.google.com/apps-script/add-ons/publish) before they can be made available publicly. ================================================ FILE: templates/docs-addon/Sidebar.html ================================================ ================================================ FILE: templates/docs-addon/SidebarJavaScript.html ================================================ ================================================ FILE: templates/docs-addon/Stylesheet.html ================================================ ================================================ FILE: templates/forms-addon/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @OnlyCurrentDoc Limits the script to only accessing the current form. */ const DIALOG_TITLE = "Example Dialog"; const SIDEBAR_TITLE = "Example Sidebar"; /** * Adds a custom menu with items to show the sidebar and dialog. * * @param {Object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { FormApp.getUi() .createAddonMenu() .addItem("Show sidebar", "showSidebar") .addItem("Show dialog", "showDialog") .addToUi(); } /** * Runs when the add-on is installed; calls onOpen() to ensure menu creation and * any other initializion work is done immediately. * * @param {Object} e The event parameter for a simple onInstall trigger. */ function onInstall(e) { onOpen(e); } /** * Opens a sidebar. The sidebar structure is described in the Sidebar.html * project file. */ function showSidebar() { const ui = HtmlService.createTemplateFromFile("Sidebar") .evaluate() .setTitle(SIDEBAR_TITLE); FormApp.getUi().showSidebar(ui); } /** * Opens a dialog. The dialog structure is described in the Dialog.html * project file. */ function showDialog() { const ui = HtmlService.createTemplateFromFile("Dialog") .evaluate() .setWidth(350) .setHeight(180); FormApp.getUi().showModalDialog(ui, DIALOG_TITLE); } /** * Appends a new form item to the current form. * * @param {Object} itemData a collection of String data used to * determine the exact form item created. */ function addFormItem(itemData) { // Use data collected from sidebar to manipulate the form. const form = FormApp.getActiveForm(); switch (itemData.type) { case "Date": form.addDateItem().setTitle(itemData.name); break; case "Scale": form.addScaleItem().setTitle(itemData.name); break; case "Text": form.addTextItem().setTitle(itemData.name); break; } } /** * Queries the form DocumentProperties to determine whether the formResponse * trigger is enabled or not. * * @return {Boolean} True if the form submit trigger is enabled; false * otherwise. */ function getTriggerState() { // Retrieve and return the information requested by the dialog. const properties = PropertiesService.getDocumentProperties(); return properties.getProperty("triggerId") != null; } /** * Turns the form submit trigger on or off based on the given argument. * * @param {Boolean} enableTrigger whether to turn on the form submit * trigger or not */ function adjustFormSubmitTrigger(enableTrigger) { // Use data collected from dialog to manipulate form. // Determine existing state of trigger on the server. const form = FormApp.getActiveForm(); const properties = PropertiesService.getDocumentProperties(); const triggerId = properties.getProperty("triggerId"); if (!enableTrigger && triggerId != null) { // Delete the existing trigger. const triggers = ScriptApp.getUserTriggers(form); for (let i = 0; i < triggers.length; i++) { if (triggers[i].getUniqueId() === triggerId) { ScriptApp.deleteTrigger(triggers[i]); break; } } properties.deleteProperty("triggerId"); } else if (enableTrigger && triggerId == null) { // Create a new trigger. const trigger = ScriptApp.newTrigger("respondToFormSubmit") .forForm(form) .onFormSubmit() .create(); properties.setProperty("triggerId", trigger.getUniqueId()); } } /** * Responds to form submit events if a form summit trigger is enabled. * Collects some form information and sends it as an email to the form creator. * * @param {Object} e The event parameter created by a form * submission; see * https://developers.google.com/apps-script/understanding_events */ function respondToFormSubmit(e) { if (MailApp.getRemainingDailyQuota() > 0) { const form = FormApp.getActiveForm(); let message = `There have been ${form.getResponses().length} response(s) so far. Latest Response:\n`; const itemResponses = e.response.getItemResponses(); for (let i = 0; i < itemResponses.length; i++) { const itemTitle = itemResponses[i].getItem().getTitle(); const itemResponse = JSON.stringify(itemResponses[i].getResponse()); message += `${itemTitle}: ${itemResponse}\n`; } MailApp.sendEmail( Session.getEffectiveUser().getEmail(), `Form response received for form ${form.getTitle()}`, message, { name: "Forms Add-on Template" }, ); } } ================================================ FILE: templates/forms-addon/Dialog.html ================================================

    This dialog is used to turn on or off a form submit trigger that will send an email to the form owner whenever a form is responded to.

    ================================================ FILE: templates/forms-addon/DialogJavaScript.html ================================================ ================================================ FILE: templates/forms-addon/README.md ================================================ Template: Google Forms Add-on ============================= This template provides a framework for creating a [Google Forms add-on](https://developers.google.com/apps-script/add-ons/). It shows the structure needed to define a UI (including menus, a sidebar and dialog) and how to coordinate communication between the UI client and the server where the Form resides. This template also covers some basic uses of Apps Script with Google Forms, including: * Creating new form items programmatically * Setting, removing and responding to form submit triggers Note that this template must be added to a container-bound script attached to a Google Form in order to function. Also note that developed add-ons must go through a [publishing process](https://developers.google.com/apps-script/add-ons/publish) before they can be made available publicly. ================================================ FILE: templates/forms-addon/Sidebar.html ================================================ ================================================ FILE: templates/forms-addon/SidebarJavaScript.html ================================================ ================================================ FILE: templates/forms-addon/Stylesheet.html ================================================ ================================================ FILE: templates/sheets-addon/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @OnlyCurrentDoc Limits the script to only accessing the current spreadsheet. */ const DIALOG_TITLE = "Example Dialog"; const SIDEBAR_TITLE = "Example Sidebar"; /** * Adds a custom menu with items to show the sidebar and dialog. * * @param {Object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { SpreadsheetApp.getUi() .createAddonMenu() .addItem("Show sidebar", "showSidebar") .addItem("Show dialog", "showDialog") .addToUi(); } /** * Runs when the add-on is installed; calls onOpen() to ensure menu creation and * any other initializion work is done immediately. * * @param {Object} e The event parameter for a simple onInstall trigger. */ function onInstall(e) { onOpen(e); } /** * Opens a sidebar. The sidebar structure is described in the Sidebar.html * project file. */ function showSidebar() { const ui = HtmlService.createTemplateFromFile("Sidebar") .evaluate() .setTitle(SIDEBAR_TITLE); SpreadsheetApp.getUi().showSidebar(ui); } /** * Opens a dialog. The dialog structure is described in the Dialog.html * project file. */ function showDialog() { const ui = HtmlService.createTemplateFromFile("Dialog") .evaluate() .setWidth(400) .setHeight(190); SpreadsheetApp.getUi().showModalDialog(ui, DIALOG_TITLE); } /** * Returns the value in the active cell. * * @return {String} The value of the active cell. */ function getActiveValue() { // Retrieve and return the information requested by the sidebar. const cell = SpreadsheetApp.getActiveSheet().getActiveCell(); return cell.getValue(); } /** * Replaces the active cell value with the given value. * * @param {Number} value A reference number to replace with. */ function setActiveValue(value) { // Use data collected from sidebar to manipulate the sheet. const cell = SpreadsheetApp.getActiveSheet().getActiveCell(); cell.setValue(value); } /** * Executes the specified action (create a new sheet, copy the active sheet, or * clear the current sheet). * * @param {String} action An identifier for the action to take. */ function modifySheets(action) { // Use data collected from dialog to manipulate the spreadsheet. const ss = SpreadsheetApp.getActiveSpreadsheet(); const currentSheet = ss.getActiveSheet(); if (action === "create") { ss.insertSheet(); } else if (action === "copy") { currentSheet.copyTo(ss); } else if (action === "clear") { currentSheet.clear(); } } ================================================ FILE: templates/sheets-addon/Dialog.html ================================================

    This allows you to create, copy and clear sheets.

    ================================================ FILE: templates/sheets-addon/DialogJavaScript.html ================================================ ================================================ FILE: templates/sheets-addon/README.md ================================================ Template: Google Sheets Add-on ============================== This template provides a framework for creating a [Google Sheets add-on](https://developers.google.com/apps-script/add-ons/). It shows the structure needed to define a UI (including menus, a sidebar and dialog) and how to coordinate communication between the UI client and the server where the Sheet resides. This template also covers some basic uses of Apps Script with Google Sheets, including: * Reading and writing data to a Sheet * Creating, copying and clearing a sheet Note that add-ons that work with Google Sheets will usually need to read and manipulate the Sheet data, formatting, validation, etc. For more information, see [Extending Google Sheets](https://developers.google.com/apps-script/guides/sheets). In addition, developers of Sheets add-ons should be aware of the [Known Issues specific to Google Sheets](https://developers.google.com/apps-script/migration/sheets). Finally, note that this template must be added to a container-bound script attached to a Google Sheet in order to function. Developed add-ons must go through a [publishing process](https://developers.google.com/apps-script/add-ons/publish) before they can be made available publicly. ================================================ FILE: templates/sheets-addon/Sidebar.html ================================================ ================================================ FILE: templates/sheets-addon/SidebarJavaScript.html ================================================ ================================================ FILE: templates/sheets-addon/Stylesheet.html ================================================ ================================================ FILE: templates/sheets-import/APICode.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Return an array of potential columns (identifiers to locate them in * the data response object and the labels to use as column headers). * @return {Array} list of potential columns. */ function getColumnOptions() { const columns = []; // TODO: Replace this section, adding a column entry for each data of // interest. id should be an identifier that can be used to locate // the data in the data request response, and label should be the name // to associate with that data in the UI. columns.push({ id: "DATA_ITEM1_ID", label: "Data Item 1 label" }); columns.push({ id: "DATA_ITEM2_ID", label: "Data Item 2 label" }); columns.push({ id: "DATA_ITEM3_ID", label: "Data Item 3 label" }); return columns; } /** * Return a page of results from the data source as a 2D array of * values (with columns corresponding to the columns specified). Return * null if no data exists for the specified pageNumber. * @param {Array} columns an array of Strings specifying the column ids * to include in the output. * @param {Number} pageNumber a number indicating what page of data to * retrieve from the data source. * @param {Number} pageSize a number indicating the maximum number of * rows to return per call. * @param {Object} opt_settings optional object containing any additional * information needed to retrieve data from the data source. * @return {object[]|null} Pages of data. */ function getDataPage(columns, pageNumber, pageSize, opt_settings) { const data = null; /** * TODO: This function needs to be implemented based on the particular * details of the data source you are extracting data from. For example, * you might request a page of data from an API using OAuth2 credentials * similar to this: * * var service = getService(); // Be sure to configure the Auth.gs code * * // Build the appropriate API URL based on the parameters (pageNumber, * // pageSize, and opt_settings). * var url = '...'; * var response = UrlFetchApp.fetch(url, { * headers: { * Authorization: 'Bearer ' + service.getAccessToken(), * // Include any API-required headers needed for the call * } * }); * * // Given the response, construct the appropriate data output. Return * // null if there is no data for the specified page. * if (noData(response)) { * return null; * } * data = []; * * // Iterate over each relevant data item in the API response and build * // a data row for it containing the data specified by columns * // (in the same column order). Add each data row to data. * */ return data; } ================================================ FILE: templates/sheets-import/Auth.gs ================================================ /** * Copyright 2015 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Return an OAuth service object to handle authorization for a specific * data source (such as an API resource). Makes use of the OAuth2 Apps * Script library: * https://github.com/googlesamples/apps-script-oauth2 * @return {Object} a service object associated with the specified * resource. */ function getService() { /* TODO: Fill in the following required parameters for your data source. */ const service = OAuth2.createService("ENTER_SERVICE_NAME_HERE") .setAuthorizationBaseUrl("ENTER_BASE_URL_HERE") .setTokenUrl("ENTER_TOKEN_URL_HERE") .setClientId("ENTER_CLIENT_ID_HERE") .setClientSecret("ENTER_CLIENT_SECRET_HERE") .setCallbackFunction("authCallback") .setPropertyStore(PropertiesService.getUserProperties()); /* TODO: Do any app-specific OAuth property setting here. * For details, see: * https://github.com/googlesamples/apps-script-oauth2 */ return service; } /** * Example of a authorization callback function that is called after an * authorization attempt. Presents an authorization results window upon * completion of the API auth sequence. For additional details, see the * OAuth2 Apps Script library: * https://github.com/googlesamples/apps-script-oauth2 * @param {Object} request results of API auth request. * @return {HTML} A auth callback HTML page. */ function authCallback(request) { const template = HtmlService.createTemplateFromFile("AuthCallbackView"); template.user = Session.getEffectiveUser().getEmail(); template.isAuthorized = false; template.error = null; let title; try { const service = getService(); const authorized = service.handleCallback(request); template.isAuthorized = authorized; title = authorized ? "Access Granted" : "Access Denied"; } catch (e) { template.error = e; title = "Access Error"; } template.title = title; return template.evaluate().setTitle(title); } /** * Builds and returns the API authorization URL from the service object. * @return {String} the API authorization URL. */ function getAuthorizationUrl() { return getService().getAuthorizationUrl(); } /** * Resets the API service, forcing re-authorization before * additional authorization-required API calls can be made. */ function signout() { getService().reset(); } ================================================ FILE: templates/sheets-import/AuthCallbackView.html ================================================

    An error has occurred: .

    You may close this tab.

    Authorization complete! You may close this tab.

    Authorization denied. You may close this tab.

    ================================================ FILE: templates/sheets-import/AuthorizationEmail.html ================================================

    The Google Sheets add-on was recently updated and it needs you to re-authorize.

    The add-on's automatic report updates are temporarily disabled until you re-authorize. You can accomplish this by opening one of the sheets using the add-on and running the add-on through the menu. Alternatively, you can click this link to approve authorization directly:

    Click here to re-authorize the add-on.

    This notification email will be sent to you at most once per day until the add-on is re-authorized.


    This automatic message was sent to you via the add-on for Google Sheets.

    ================================================ FILE: templates/sheets-import/Configurations.gs ================================================ /** * Copyright 2015 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const REPORT_SET_KEY = "Import.ReportSet"; const SCHEDULE_TRIGGER_ID = "Import.scheduled.triggerId"; /** * Update type enum used when adding or deleting a report. */ const UPDATE_TYPE = { ADD: 1, REMOVE: 2, }; /** * Return the report configuration for the report with the given * ID; returns an empty Object if no such report name exists. * @param {String} reportId a report ID. * @return {Object} a report configuration corresponding to that ID, * or null if no such report exists. */ function getReportConfig(reportId) { const config = getObjectFromProperties(reportId); if (!config) { return null; } // Sheet name may have been changed manually, so // get the current one. const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = getSheetById(ss, Number.parseInt(config.sheetId)); config.sheetName = !sheet ? null : sheet.getName(); return config; } /** * Given a report configuration, save it. * @param {object} config the report configuration. * @param {object} the updated report configuration. * @return {object} The saved configuration. */ function saveReportConfig(config) { const previous = getReportConfig(config.reportId); if (config.reportId === "new-report") { config.reportId = newReportId(); config.lastRun = null; config.owner = Session.getEffectiveUser().getEmail(); } saveObjectToProperties(config.reportId, config); updateReportSet(UPDATE_TYPE.ADD, config.reportId, config.name); if (previous == null) { return config; } return { ...previous, ...config, }; } /** * Delete the report specified by the given ID. * @param {String} reportId indicates the report to delete. */ function deleteReportConfig(reportId) { deleteObjectFromProperties(reportId); updateReportSet(UPDATE_TYPE.REMOVE, reportId); } /** * Returns true if the current user is allowed to edit the * report associated with the given config. * @param {Object} config a report configuration. * @return {boolean} True if the user can edit the report. */ function canEditReport(config) { if (!config) { return false; } return ( config.scheduled === false || Session.getEffectiveUser().getEmail() === config.owner ); } /** * Given a new report configuration, return true if it saving this report would mean the limit on * scheduled reports would be exceeded. * @param {Object} config a report configuration to be saved. * @return {boolean} If it saving this report would mean the limit on scheduled reports * would be exceeded. */ function isOverScheduleLimit(config) { const previous = getReportConfig(config.reportId); const currentUser = Session.getEffectiveUser().getEmail(); const isScheduled = config == null ? false : config.scheduled; const wasScheduled = previous == null ? false : previous.scheduled; return ( isScheduled && wasScheduled !== true && getScheduledReports(currentUser).length >= MAX_SCHEDULED_REPORTS ); } /** * Return a set of all saved reports (reportIds as keys, report * names as values). * @return {Object} */ function getAllReports() { const properties = PropertiesService.getDocumentProperties(); return JSON.parse(properties.getProperty(REPORT_SET_KEY)); } /** * Get a set of report configurations that all have been marked * for scheduled imports. * @param {String} opt_user optional user email; if provided, returned * results will only include reports that user is the owner of. * @return {Object} collection of configuration object for scheduled * reports. */ function getScheduledReports(opt_user) { const scheduledReports = []; for (const reportId of Object.keys(getAllReports())) { const config = getReportConfig(reportId); if (config?.scheduled && (!opt_user || opt_user === config.owner)) { scheduledReports.push(config); } } return scheduledReports; } /** * Updates the current report list (adding or removing a given * report name and id). * @param {Number} updateType Enum: either UPDATE_TYPE.ADD or * UPDATE_TYPE.REMOVE. * @param {String} reportId report to add or remove. * @param {String} reportName report name (only needed for ADD). */ function updateReportSet(updateType, reportId, reportName) { const properties = PropertiesService.getDocumentProperties(); const lock = LockService.getDocumentLock(); lock.waitLock(2000); let reportSet = JSON.parse(properties.getProperty(REPORT_SET_KEY)); if (reportSet == null) { reportSet = {}; } if (updateType === UPDATE_TYPE.ADD) { reportSet[reportId] = reportName; } else if (updateType === UPDATE_TYPE.REMOVE) { delete reportSet[reportId]; } properties.setProperty(REPORT_SET_KEY, JSON.stringify(reportSet)); lock.releaseLock(); } /** * Update a report configuration with a sheetId and last runtime * information, save and return it. Include but do not save the * sheet name. * @param {Object} config the report configuration. * @param {Sheet} sheet the report's sheet. * @param {String} lastRun the datetime string indicating the last * time the report was run. * @return {Object} the updated report configuration. */ function updateOnImport(config, sheet, lastRun) { const update = { sheetId: sheet.getSheetId().toString(), lastRun: lastRun, }; saveObjectToProperties(config.reportId, update); update.sheetName = sheet.getName(); return { ...config, ...update, }; } /** * Return the array of column IDs used by the given report * configuration. * @param {Object} config the report configuration. * @return {Array} column ID strings. */ function getColumnIds(config) { return config.columns.map((col) => col.column); } /** * Return the saved trigger ID of the scheduling trigger for this user. * @return {string|null} the trigger ID or null if the trigger is not set. */ function getTriggerId() { const properties = PropertiesService.getUserProperties(); return properties.getProperty(SCHEDULE_TRIGGER_ID); } /** * Save the trigger ID of the scheduling trigger for this user. * @param {Trigger} trigger the trigger whose ID should be saved. */ function saveTriggerId(trigger) { const properties = PropertiesService.getUserProperties(); properties.setProperty(SCHEDULE_TRIGGER_ID, trigger.getUniqueId()); } /** * Remove the saved trigger ID. */ function removeTriggerId() { const properties = PropertiesService.getUserProperties(); properties.deleteProperty(SCHEDULE_TRIGGER_ID); } ================================================ FILE: templates/sheets-import/JavaScript.html ================================================ ================================================ FILE: templates/sheets-import/README.md ================================================ # Template: Importing Data to Sheets This template provides a framework for creating a Sheets [add-on](https://developers.google.com/apps-script/add-ons/) that imports data from a third-party source (such as an API). It shows the basic structure needed to define a UI and how to coordinate communication between the client, server, and third-party source. This template also demonstrates some useful aspects of Apps Script, including: * Writing data to a Google Sheet * Using time-based triggers to establish automated sheet updates * Using [Templated HTML](https://developers.google.com/apps-script/guides/html/templates) * Using IFRAME sandbox mode **Note**: The purpose of this template is to show a general add-on structure. It will not run as an add-on in it's current state. To make use of this template, you will need to fill in the sections marked **TODO** to customize the template to a specific third-party data source. ## Project manifest The following project files are included in this template: * `**APICode.gs**` - This file contains all the API-specific code for handling authorization, callbacks, and API calls. It will need to be modified to handle a specific API. * `**Auth.gs**` - This file contains code that assists with constructing a OAuth2 service object using the [Apps Script OAuth2 library](https://github.com/googlesamples/apps-script-oauth2). * `**AuthCallbackView.html**` - This file is the page that is presented to the user after an authorization attempt, and shows whether the authorization was successful. * `**AuthorizationEmail.html**` - This file contains the HTML template of an email that would be sent to the user in the event that a trigger attempts to fire without all the required authorizations. * `**Configurations.gs**` - This file contains code that controls the creation, updating and deletion of report configurations that describe what to import to Sheets from the third-party source. By default report configurations are saved to Apps Script's PropertyService, but it would be possible to adapt the code here to store that data elsewhere (for example, in an external database). * `**JavaScript.html**` - This file contains the bulk of the control code for the sidebar UI. * `**Server.gs**` - This file contains server-side code that responds to user interactions in the sidebar UI. It also sets up the add-on menu. * `**Sidebar.html**` - This file contains the HTML structure for that defines the sidebar UI. * *`*Stylesheet.html**` - This file contains all the CSS properties defined for the template. * `**Utilities.gs**` - This file contains some generic functionalities to support the rest of the code. The functions here are not specific to this template and could be taken for use in other projects without modification. * `**intercom.js.html**` - This file contains a copy of [intercom.js](https://github.com/diy/intercom.js), a cross-window message broadcast interface (intercom.js is released under an Apache V2.0 license). ## Setup: Libraries This template makes use of the following libraries, which much the added to the Apps Script project before the template can be used: * [Apps Script OAuth2 library](https://github.com/googlesamples/apps-script-oauth2) * [Underscore](http://underscorejs.org/) These libraries are already published as an Apps Script, making it easy to include in your project. To add it to your script, do the following in the Apps Script code editor: 1. Click on the menu item "Resources > Libraries..." 1. In the "Find a Library" text box, enter the project key "MswhXl8fVhTFUH_Q3UOJbXvxhMjh3Sh48" and click the "Select" button. 1. Choose the latest version in the dropdown box. 1. Click the "Save" button. This will add the [OAuth2 library](https://github.com/googlesamples/apps-script-oauth2) to your project. Repeat the above steps with the project key "MGwgKN2Th03tJ5OdmlzB8KPxhMjh3Sh48" to add Underscore to the project as well. ## Setup: API configuration This template requires app-specific configuration before it can used. Specifically, the template will need to be informed of the authorization details, and certain adjustments made to ensure the correct data can be extracted from the responses. In the Server.gs, APICode.gs and Auth.gs project files, there are several comments marked as **TODO**. To configure the template, visit each of these **TODO** sections and follow the directions found there. Note that the template design assumes that the template will connect to an API service using OAuth 2.0; however, it would be possible adapt it to connect to a different kind of service, such as a SQL database. ## Additional information For more information, see: * [Extending Google Sheets](https://developers.google.com/apps-script/guides/sheets) * [Known Issues specific to Google Sheets](https://developers.google.com/apps-script/migration/sheets) Note that this template must be added to a container-bound script attached to a Google Sheet in order to function. Developed add-ons must go through a [publishing process](https://developers.google.com/apps-script/add-ons/publish) before they can be made available publicly. ================================================ FILE: templates/sheets-import/Server.gs ================================================ /** * Copyright 2015 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @OnlyCurrentDoc Limits the script to only accessing the current spreadsheet. */ const _ = Underscore.load(); /** * TODO: Replace the following with the name of the service you are importing * from and the name of the add-on you are building, respectively. */ const DATA_ALIAS = "MyDataSource"; const ADDON_NAME = "YOUR_ADDON_NAME_HERE"; const SIDEBAR_TITLE = "Import Control Center"; const MAX_SCHEDULED_REPORTS = 24; const IMPORT_PAGE_SIZE = 30; /** * Error code enum; this gets passed to the sidebar for use there as well. */ const ERROR_CODES = { AUTO_UPDATE_LIMIT: 1, ILLEGAL_EDIT: 2, ILLEGAL_DELETE: 3, IMPORT_FAILED: 4, }; /** * Adds a custom menu with items to show the sidebar. * @param {Object} e The event parameter for a simple onOpen trigger. */ function onOpen(e) { SpreadsheetApp.getUi() .createAddonMenu() .addItem("Import control center", "showSidebar") .addToUi(); } /** * Runs when the add-on is installed; calls onOpen() to ensure menu creation and * any other initializion work is done immediately. * @param {Object} e The event parameter for a simple onInstall trigger. */ function onInstall(e) { onOpen(e); } /** * Opens a sidebar. The sidebar structure is described in the Sidebar.html * project file. */ function showSidebar() { const service = getService(); const template = HtmlService.createTemplateFromFile("Sidebar"); template.user = Session.getEffectiveUser().getEmail(); template.dataSource = DATA_ALIAS; template.isAuthorized = service.hasAccess(); template.authorizationUrl = null; if (!template.isAuthorized) { template.authorizationUrl = service.getAuthorizationUrl(); } const page = template.evaluate().setTitle(SIDEBAR_TITLE); SpreadsheetApp.getUi().showSidebar(page); } /** * Return data needed to build the sidebar UI: a list of the names of the * currently saved report configurations and the list of potential * column choices. * @return {Object} a collection of saved report data and column options. */ function getInitialDataForSidebar() { const reportSet = getAllReports(); const reportList = []; _.each(reportSet, (val, key) => { reportList.push({ name: val, reportId: key }); }); reportList.sort((a, b) => { if (a.name > b.name) { return 1; } if (a.name < b.name) { return -1; } return 0; }); return { reports: reportList, columns: getColumnOptions() }; } /** * Get the report configuration for the given report and, if a sheet * exists for it, activate that sheet. * @param {String} reportId a report ID. * @return {object} The report config. */ function switchToReport(reportId) { const config = getReportConfig(reportId); activateById(config.sheetId); return config; } /** * Import data to the spreadsheet according to the given report * configuration. * @param {string} reportId the report identifier. * @return {object} the (possibly updated) report configuration. */ function runImport(reportId) { const ss = SpreadsheetApp.getActiveSpreadsheet(); let config = getReportConfig(reportId); // Acquire the sheet to place the import results in, // then clear and format it. // Update the saved config with sheet/time information. const sheet = activateReportSheet(config); const columnIds = getColumnIds(config); const lastRun = new Date().toString(); config = updateOnImport(config, sheet, lastRun); // Call for pages of API information to place in the sheet, one // page at a time. let pageNumber = 0; let firstRow = 2; try { let page; do { page = getDataPage(columnIds, pageNumber, IMPORT_PAGE_SIZE, config); if (page) { sheet .getRange(firstRow, 1, page.length, page[0].length) .setValues(page); firstRow += page.length; pageNumber++; SpreadsheetApp.flush(); } } while (page != null); } catch (e) { // Ensure a new sheet Id, if created, is preserved. throw ERROR_CODES.IMPORT_FAILED; } for (let i = 1; i <= sheet.getLastColumn(); i++) { sheet.autoResizeColumn(i); } ss.toast(`Report ${config.name} updated.`); return config; } /** * Save the given report configuration. * @param {Object} config a report configuration to save. * @return {Object} the updated report configuration. */ function saveReport(config) { const existingConfig = getReportConfig(config.reportId); if (existingConfig != null) { activateById(existingConfig.sheetId); // Check: users are not allowed to save edits to reports // created by other users if those reports have been marked // for auto-update. if (!canEditReport(existingConfig)) { throw ERROR_CODES.ILLEGAL_EDIT; } } // Check against max number of scheduled reports. if (isOverScheduleLimit(config)) { throw ERROR_CODES.AUTO_UPDATE_LIMIT; } const result = saveReportConfig(config); adjustScheduleTrigger(); return result; } /** * Delete the given report configuration. * @param {String} reportId indicates the report to delete. * @return {String} the report ID deleted. */ function removeReport(reportId) { // Check: users are not allowed to delete reports created by // other users if those reports have been marked for auto-update. if (!canEditReport(getReportConfig(reportId))) { throw ERROR_CODES.ILLEGAL_DELETE; } deleteReportConfig(reportId); adjustScheduleTrigger(); return reportId; } /** * Activate, clear, format and return the sheet associated with the * specified report configuration. If the sheet does not exist, create, * format and activate it. * @param {Object} config the report configuration. * @return {Sheet} */ function activateReportSheet(config) { const ss = SpreadsheetApp.getActiveSpreadsheet(); let sheet = getSheetById(ss, Number.parseInt(config.sheetId)); if (sheet == null) { sheet = ss.insertSheet(); sheet.setName(getUniqueSheetName(ss, config.name)); } sheet.activate(); const headers = _.map(config.columns, (col) => col.label); sheet.clear(); sheet.clearNotes(); sheet.setFrozenRows(1); sheet .getRange("1:1") .setFontWeight("bold") .setBackground("#000000") .setFontColor("#ffffff"); sheet.getRange(1, 1, 1, headers.length).setValues([headers]); return sheet; } /** * On an hourly trigger, search through scheduled reports, find one * that hasn't been run in 24 hours or more (or never), and run * an import for that one. With <= 24 scheduled reports, this pattern * ensures that every scheduled report will be updated once a day. */ function respondToHourlyTrigger() { const authInfo = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); // Check if the actions of the trigger require authorizations that have not // been supplied yet -- if so, warn the active user via email (if possible). // This check is required when using triggers with add-ons to maintain // functional triggers. if ( authInfo.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED ) { // Re-authorization is required. In this case, the user needs to be alerted // that they need to reauthorize; the normal trigger action is not // conducted, since it authorization needs to be provided first. Send at // most one 'Authorization Required' email a day, to avoid spamming users // of the add-on. sendReauthorizationRequest(); } else { const potentials = getScheduledReports( Session.getEffectiveUser().getEmail(), ); for (let i = 0; i < potentials.length; i++) { const lastRun = potentials[i].lastRun; if (!lastRun || isOlderThanADay(lastRun)) { runImport(potentials[i].reportId); return; } } } } /** * Called when the user needs to reauthorize. Sends the user of the * add-on an email explaining the need to reauthorize and provides * a link for the user to do so. Capped to send at most one email * a day to prevent spamming the users of the add-on. */ function sendReauthorizationRequest() { const authInfo = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); const properties = PropertiesService.getUserProperties(); const LAST_AUTH_EMAIL_KEY = "Import.reauth.lastAuthEmailDate"; const lastAuthEmailDate = properties.getProperty(LAST_AUTH_EMAIL_KEY); const today = new Date().toDateString(); if (lastAuthEmailDate !== today) { if (MailApp.getRemainingDailyQuota() > 0) { const template = HtmlService.createTemplateFromFile("AuthorizationEmail"); template.url = authInfo.getAuthorizationUrl(); template.addonName = ADDON_NAME; const message = template.evaluate(); MailApp.sendEmail( Session.getEffectiveUser().getEmail(), "Add-on Authorization Required", message.getContent(), { name: ADDON_NAME, htmlBody: message.getContent(), }, ); } properties.setProperty(LAST_AUTH_EMAIL_KEY, today); } } /** * Turn on the scheduling trigger if scheduled reports owned * by the current user are present; turn it off otherwise. */ function adjustScheduleTrigger() { const existingTriggerId = getTriggerId(); const user = Session.getEffectiveUser().getEmail(); const triggerNeeded = getScheduledReports(user).length > 0; // Create a new trigger if required; delete existing trigger // if it is not needed. if (triggerNeeded && existingTriggerId == null) { const trigger = ScriptApp.newTrigger("respondToHourlyTrigger") .timeBased() .everyHours(1) .create(); saveTriggerId(trigger); } else if (!triggerNeeded && existingTriggerId != null) { const existingTrigger = getUserTriggerById( SpreadsheetApp.getActiveSpreadsheet(), existingTriggerId, ); if (existingTrigger != null) { ScriptApp.deleteTrigger(existingTrigger); } removeTriggerId(); } } ================================================ FILE: templates/sheets-import/Sidebar.html ================================================ ================================================ FILE: templates/sheets-import/Stylesheet.html ================================================ ================================================ FILE: templates/sheets-import/Utilities.gs ================================================ /** * Copyright 2015 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Includes the given project HTML file in the current HTML project file. * Also used to include JavaScript. * @param {string} filename Project file name. * @return {string} The content of the rendered HTML. */ function include(filename) { return HtmlService.createHtmlOutputFromFile(filename).getContent(); } /** * Returns true if the given date string represents a date that is * more than 24 hours in the past; returns false otherwise. * @param {String} dateStr a date string. * @return {Boolean} */ function isOlderThanADay(dateStr) { const now = new Date().getTime(); const then = Date.parse(dateStr); return then + 24 * 60 * 60 * 1000 < now; } /** * Given an object and a string prefix, save every value in that object * to the Document properties service as a JSONified string. The property * key for each object key will be: prefix. * @param {String} prefix a common string to label each added property. * @param {Object} obj a collection of key-values to save as * user properties. */ function saveObjectToProperties(prefix, obj) { const properties = PropertiesService.getDocumentProperties(); _.each(obj, (val, key) => { const propKey = `${prefix}.${key}`; properties.setProperty(propKey, JSON.stringify(val)); }); } /** * Given a string prefix, fetch from the Document properties service all * properties whose keys start with that prefix, and return the (JSON-parsed) * values in an object. The keys of the returned object will be the * same as the property keys with the leading "prefix." removed. * @param {String} prefix label of requested properties. * @return {Object} collection of key-value pairs taken from the * properties service. Will return null if the prefix is unrecognized. */ function getObjectFromProperties(prefix) { const properties = PropertiesService.getDocumentProperties(); const obj = {}; _.each(properties.getProperties(), (val, key) => { if (key.indexOf(prefix) > -1) { obj[key.substr(prefix.length + 1)] = JSON.parse(val); } }); if (_.keys(obj).length === 0) { return null; } return obj; } /** * Given a string prefix, remove from the Document properties service all * properties whose keys start with that prefix. * @param {String} prefix label of properties to remove. */ function deleteObjectFromProperties(prefix) { const properties = PropertiesService.getDocumentProperties(); _.each(properties.getProperties(), (val, key) => { if (key.indexOf(prefix) > -1) { properties.deleteProperty(key); } }); } /** * Generate a random alphanumeric string. * @return {String} report ID string. */ function newReportId() { return Math.random().toString(36).substring(2); } /** * Sheets-specific utility. Find a sheet within a spreadsheet with * the given id. If not present, return null. * @param {Object} ss a Spreadsheet object. * @param {Number} sheetId a Sheet id. * @return {Object} a Sheet object, or null if not found. */ function getSheetById(ss, sheetId) { if (sheetId === null) { return null; } const sheets = ss.getSheets(); for (let i = 0; i < sheets.length; i++) { if (sheets[i].getSheetId() === sheetId) { return sheets[i]; } } return null; } /** * Sheets-specific utility. Given a base title for a sheet, check * for that it is unique in the spreadsheet. If not, find an integer * suffix to append to it to make it unique and return. This function * is used to avoid name collisions while adding or renaming sheets * automatically. * @param {Object} spreadsheet a Spreadsheet. * @param {String} baseName the initial suggested title for a sheet. * @return {String} a unique title for the sheet, based on the * given base title. */ function getUniqueSheetName(spreadsheet, baseName) { let sheetName = baseName; let i = 2; while (spreadsheet.getSheetByName(sheetName) != null) { sheetName = `${baseName} ${i++}`; } return sheetName; } /** * Sheets-specific utility. Given a spreadsheet and a triggerId string, * return the user trigger that corresponds to that ID. Returns null * if no such trigger exists. * @param {Spreadsheet} spreadsheet container of the user triggers. * @param {String} triggerId trigger ID string. * @return {Trigger} corresponding user trigger, or null if not found. */ function getUserTriggerById(spreadsheet, triggerId) { const triggers = ScriptApp.getUserTriggers(spreadsheet); for (let i = 0; i < triggers.length; i++) { if (triggers[i].getUniqueId() === triggerId) { return triggers[i]; } } return null; } /** * Sheets-specific utility. Given a String sheet id, activate that * sheet if it exists. * @param {String} sheetId the sheet ID. */ function activateById(sheetId) { const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = getSheetById(ss, Number.parseInt(sheetId)); if (sheet != null) { sheet.activate(); } } ================================================ FILE: templates/sheets-import/intercom.js.html ================================================ ================================================ FILE: templates/standalone/helloWorld.gs ================================================ /** * Copyright 2018 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_hello_world] /** * Creates a Google Doc and sends an email to the current user with a link to the doc. */ function createAndSendDocument() { try { // Create a new Google Doc named 'Hello, world!' const doc = DocumentApp.create("Hello, world!"); // Access the body of the document, then add a paragraph. doc .getBody() .appendParagraph("This document was created by Google Apps Script."); // Get the URL of the document. const url = doc.getUrl(); // Get the email address of the active user - that's you. const email = Session.getActiveUser().getEmail(); // Get the name of the document to use as an email subject line. const subject = doc.getName(); // Append a new string to the "url" variable to use as an email body. const body = `Link to your doc: ${url}`; // Send yourself an email with a link to the document. GmailApp.sendEmail(email, subject, body); } catch (err) { // TODO (developer) - Handle exception console.log("Failed with error %s", err.message); } } // [END apps_script_hello_world] ================================================ FILE: templates/web-app/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Serves HTML of the application for HTTP GET requests. * If folderId is provided as a URL parameter, the web app will list * the contents of that folder (if permissions allow). Otherwise * the web app will list the contents of the root folder. * * @param {Object} e event parameter that can contain information * about any URL parameters provided. * @return {HTML} The web app's HTML. */ function doGet(e) { const template = HtmlService.createTemplateFromFile("Index"); // Retrieve and process any URL parameters, as necessary. if (e.parameter.folderId) { template.folderId = e.parameter.folderId; } else { template.folderId = "root"; } // Build and return HTML in IFRAME sandbox mode. return template.evaluate().setTitle("Web App Window Title"); } /** * Return an array of up to 20 filenames contained in the * folder previously specified (or the root folder by default). * * @param {String} folderId String ID of folder whose contents * are to be retrieved; if this is 'root', the * root folder is used. * @return {Object} list of content filenames, along with * the root folder name. */ function getFolderContents(folderId) { let topFolder; const contents = { children: [], }; if (folderId === "root") { topFolder = DriveApp.getRootFolder(); } else { // May throw exception if the folderId is invalid or app // doesn't have permission to access. topFolder = DriveApp.getFolderById(folderId); } contents.rootName = `${topFolder.getName()}/`; const files = topFolder.getFiles(); let numFiles = 0; while (files.hasNext() && numFiles < 20) { const file = files.next(); contents.children.push(file.getName()); numFiles++; } return contents; } ================================================ FILE: templates/web-app/Index.html ================================================

    To read a specific folder's contents, include that folder's ID as a URL parameter: ?folderId=<folder id>. Otherwise the root folder will be read. Up to 20 files may be displayed here, in no particular order.

    Loading...

    ================================================ FILE: templates/web-app/JavaScript.html ================================================ ================================================ FILE: templates/web-app/README.md ================================================ Template: Script as Web App =========================== This template provides a framework for creating a [web app](https://developers.google.com/apps-script/guides/web). It shows the basic structure needed to define a UI and how to coordinate communication between the client and server. This template also includes some useful aspects of Apps Script, including: * Using [Templated HTML](https://developers.google.com/apps-script/guides/html/templates) * Responding to HTTP GET requests with doGet(e) * Using IFRAME sandbox mode ================================================ FILE: templates/web-app/Stylesheet.html ================================================ ================================================ FILE: triggers/form/AuthorizationEmail.html ================================================

    The Google Sheets add-on is set to run automatically whenever a form is submitted. The add-on was recently updated and it needs you to re-authorize it to run on your behalf.

    The add-on's automatic functions are temporarily disabled until you re-authorize it. To do so, open Google Sheets and run the add-on from the Add-ons menu. Alternatively, you can click this link to authorize it:

    Re-authorize the add-on.

    This notification email will be sent to you at most once per day until the add-on is re-authorized.

    ================================================ FILE: triggers/form/Code.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_triggers_form] /** * Responds to a form when submitted. * @param {event} e The Form submit event. */ function respondToFormSubmit(e) { const addonTitle = "My Add-on Title"; const props = PropertiesService.getDocumentProperties(); const authInfo = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL); // Check if the actions of the trigger requires authorization that has not // been granted yet; if so, warn the user via email. This check is required // when using triggers with add-ons to maintain functional triggers. if ( authInfo.getAuthorizationStatus() === ScriptApp.AuthorizationStatus.REQUIRED ) { // Re-authorization is required. In this case, the user needs to be alerted // that they need to re-authorize; the normal trigger action is not // conducted, since it requires authorization first. Send at most one // "Authorization Required" email per day to avoid spamming users. const lastAuthEmailDate = props.getProperty("lastAuthEmailDate"); const today = new Date().toDateString(); if (lastAuthEmailDate !== today) { if (MailApp.getRemainingDailyQuota() > 0) { const html = HtmlService.createTemplateFromFile("AuthorizationEmail"); html.url = authInfo.getAuthorizationUrl(); html.addonTitle = addonTitle; const message = html.evaluate(); MailApp.sendEmail( Session.getEffectiveUser().getEmail(), "Authorization Required", message.getContent(), { name: addonTitle, htmlBody: message.getContent(), }, ); } props.setProperty("lastAuthEmailDate", today); } } else { // Authorization has been granted, so continue to respond to the trigger. // Main trigger logic here. } } // [END apps_script_triggers_form] ================================================ FILE: triggers/test_triggers.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests createTimeDrivenTrigger function of trigger.gs */ function itShouldCreateTimeDrivenTriggers() { console.log("> itShouldCreateTimeDrivenTriggers"); createTimeDrivenTriggers(); } /** * Tests createSpreadsheetOpenTrigger function of triggers.gs */ function itShouldCreateSpreadsheetOpenTrigger() { console.log("> itShouldCreateSpreadsheetOpenTrigger"); createSpreadsheetOpenTrigger(); } /** * Tests deleteTrigger function of triggers.gs */ function itShouldDeleteTrigger() { console.log("> itShouldDeleteTrigger"); deleteTrigger(); } /** * Run all the tests for triggers.gs */ function RUN_ALL_TESTS() { itShouldCreateSpreadsheetOpenTrigger(); itShouldCreateTimeDrivenTriggers(); itShouldDeleteTrigger(); } ================================================ FILE: triggers/triggers.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_triggers_onopen] /** * The event handler triggered when opening the spreadsheet. * @param {Event} e The onOpen event. * @see https://developers.google.com/apps-script/guides/triggers#onopene */ function onOpen(e) { // Add a custom menu to the spreadsheet. SpreadsheetApp.getUi() // Or DocumentApp, SlidesApp, or FormApp. .createMenu("Custom Menu") .addItem("First item", "menuItem1") .addToUi(); } // [END apps_script_triggers_onopen] // [START apps_script_triggers_onedit] /** * The event handler triggered when editing the spreadsheet. * @param {Event} e The onEdit event. * @see https://developers.google.com/apps-script/guides/triggers#onedite */ function onEdit(e) { // Set a comment on the edited cell to indicate when it was changed. const range = e.range; range.setNote(`Last modified: ${new Date()}`); } // [END apps_script_triggers_onedit] // [START apps_script_triggers_onselectionchange] /** * The event handler triggered when the selection changes in the spreadsheet. * @param {Event} e The onSelectionChange event. * @see https://developers.google.com/apps-script/guides/triggers#onselectionchangee */ function onSelectionChange(e) { // Set background to red if a single empty cell is selected. const range = e.range; if ( range.getNumRows() === 1 && range.getNumColumns() === 1 && range.getCell(1, 1).getValue() === "" ) { range.setBackground("red"); } } // [END apps_script_triggers_onselectionchange] // [START apps_script_triggers_oninstall] /** * The event handler triggered when installing the add-on. * @param {Event} e The onInstall event. * @see https://developers.google.com/apps-script/guides/triggers#oninstalle */ function onInstall(e) { onOpen(e); } // [END apps_script_triggers_oninstall] // [START apps_script_triggers_time] /** * Creates two time-driven triggers. * @see https://developers.google.com/apps-script/guides/triggers/installable#time-driven_triggers */ function createTimeDrivenTriggers() { // Trigger every 6 hours. ScriptApp.newTrigger("myFunction").timeBased().everyHours(6).create(); // Trigger every Monday at 09:00. ScriptApp.newTrigger("myFunction") .timeBased() .onWeekDay(ScriptApp.WeekDay.MONDAY) .atHour(9) .create(); } // [END apps_script_triggers_time] // [START apps_script_triggers_open] /** * Creates a trigger for when a spreadsheet opens. * @see https://developers.google.com/apps-script/guides/triggers/installable */ function createSpreadsheetOpenTrigger() { const ss = SpreadsheetApp.getActive(); ScriptApp.newTrigger("myFunction").forSpreadsheet(ss).onOpen().create(); } // [END apps_script_triggers_open] // [START apps_script_triggers_delete] /** * Deletes a trigger. * @param {string} triggerId The Trigger ID. * @see https://developers.google.com/apps-script/guides/triggers/installable */ function deleteTrigger(triggerId) { // Loop over all triggers. const allTriggers = ScriptApp.getProjectTriggers(); for (let index = 0; index < allTriggers.length; index++) { // If the current trigger is the correct one, delete it. if (allTriggers[index].getUniqueId() === triggerId) { ScriptApp.deleteTrigger(allTriggers[index]); break; } } } // [END apps_script_triggers_delete] ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "allowJs": true, "checkJs": true, "noEmit": true, "target": "es2019", "module": "commonjs", "alwaysStrict": true, "lib": ["es2019"], "types": ["google-apps-script"], "strict": true, "noImplicitAny": true }, "include": ["**/*.gs", ".github/scripts/check-gs.ts"] } ================================================ FILE: ui/communication/basic/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } function doSomething() { console.log("I was called!"); } ================================================ FILE: ui/communication/basic/index.html ================================================ ================================================ FILE: ui/communication/failure/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } function getUnreadEmails() { // 'got' instead of 'get' will throw an error. return GmailApp.gotInboxUnreadCount(); } ================================================ FILE: ui/communication/failure/index.html ================================================
    ================================================ FILE: ui/communication/private/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } function getBankBalance() { const email = Session.getActiveUser().getEmail(); return deepSecret_(email); } function deepSecret_(email) { // Do some secret calculations return `${email} has $1,000,000 in the bank.`; } ================================================ FILE: ui/communication/private/index.html ================================================
    No result yet...
    ================================================ FILE: ui/communication/runner.gs ================================================ const myRunner = google.script.run.withFailureHandler(onFailure); const myRunner1 = myRunner.withSuccessHandler(onSuccess); const myRunner2 = myRunner.withSuccessHandler(onDifferentSuccess); myRunner1.doSomething(); myRunner1.doSomethingElse(); myRunner2.doSomething(); ================================================ FILE: ui/communication/success/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } function getUnreadEmails() { return GmailApp.getInboxUnreadCount(); } ================================================ FILE: ui/communication/success/index.html ================================================
    ================================================ FILE: ui/dialogs/alert/alert.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_alert_dialogs] /** * Creates a custom menu when a user opens a Spreadsheet. */ function onOpen() { SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .createMenu("Custom Menu") .addItem("Show alert", "showAlert") .addToUi(); } /** * Shows an alert dialog. */ function showAlert() { const ui = SpreadsheetApp.getUi(); // Same variations. const result = ui.alert( "Please confirm", "Are you sure you want to continue?", ui.ButtonSet.YES_NO, ); // Process the user's response. if (result === ui.Button.YES) { // User clicked "Yes". ui.alert("Confirmation received."); } else { // User clicked "No" or X in the title bar. ui.alert("Permission denied."); } } // [END apps_script_alert_dialogs] ================================================ FILE: ui/dialogs/custom_dialog/Page.html ================================================ Hello, world! ================================================ FILE: ui/dialogs/custom_dialog/custom_dialog.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_custom_dialog] /** * Creates a custom menu when a user opens a Spreadsheet. */ function onOpen() { SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .createMenu("Custom Menu") .addItem("Show dialog", "showDialog") .addToUi(); } /** * Shows a custom dialog. */ function showDialog() { const html = HtmlService.createHtmlOutputFromFile("Page") .setWidth(400) .setHeight(300); SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .showModalDialog(html, "My custom dialog"); } // [END apps_script_custom_dialog] ================================================ FILE: ui/dialogs/custom_sidebar/Page.html ================================================ Hello, world! ================================================ FILE: ui/dialogs/custom_sidebar/custom_sidebar.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_custom_sidebar] /** * Creates a custom sidebar when a user opens a Spreadsheet. */ function onOpen() { SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .createMenu("Custom Menu") .addItem("Show sidebar", "showSidebar") .addToUi(); } /** * Shows a custom sidebar. */ function showSidebar() { const html = HtmlService.createHtmlOutputFromFile("Page") .setTitle("My custom sidebar") .setWidth(300); SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .showSidebar(html); } // [END apps_script_custom_sidebar] ================================================ FILE: ui/dialogs/menus.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_menu] /** * Handler for when a user opens the spreadsheet. * Creates a custom menu. */ function onOpen() { const ui = SpreadsheetApp.getUi(); // Or DocumentApp or FormApp. ui.createMenu("Custom Menu") .addItem("First item", "menuItem1") .addSeparator() .addSubMenu(ui.createMenu("Sub-menu").addItem("Second item", "menuItem2")) .addToUi(); } /** * Handler for when menu item 1 is clicked. */ function menuItem1() { SpreadsheetApp.getUi() // Or DocumentApp or FormApp. .alert("You clicked the first menu item!"); } /** * Handler for when menu item 2 is clicked. */ function menuItem2() { SpreadsheetApp.getUi() // Or DocumentApp or FormApp. .alert("You clicked the second menu item!"); } // [END apps_script_menu] // [START apps_script_show_message_box] /** * Shows a message box to the user. */ function showMessageBox() { Browser.msgBox("You clicked it!"); } // [END apps_script_show_message_box] // [START apps_script_sites_link] /** * A function that can be invoked from a Google Sites link. */ function sitesLink() { const recipient = Session.getActiveUser().getEmail(); GmailApp.sendEmail(recipient, "Email from your site", "You clicked a link!"); } // [END apps_script_sites_link] ================================================ FILE: ui/dialogs/prompt/prompt.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_prompt_dialog] /** * Creates a custom menu when a user opens a Spreadsheet. */ function onOpen() { SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .createMenu("Custom Menu") .addItem("Show prompt", "showPrompt") .addToUi(); } /** * Shows a prompt dialog. */ function showPrompt() { const ui = SpreadsheetApp.getUi(); // Same variations. const result = ui.prompt( "Let's get to know each other!", "Please enter your name:", ui.ButtonSet.OK_CANCEL, ); // Process the user's response. const button = result.getSelectedButton(); const text = result.getResponseText(); if (button === ui.Button.OK) { // User clicked "OK". ui.alert(`Your name is ${text}.`); } else if (button === ui.Button.CANCEL) { // User clicked "Cancel". ui.alert("I didn't get your name."); } else if (button === ui.Button.CLOSE) { // User clicked X in the title bar. ui.alert("You closed the dialog."); } } // [END apps_script_prompt_dialog] ================================================ FILE: ui/forms/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } function processForm(formObject) { const formBlob = formObject.myFile; const driveFile = DriveApp.createFile(formBlob); return driveFile.getUrl(); } ================================================ FILE: ui/forms/index.html ================================================
    ================================================ FILE: ui/html/printing_scriptlet.html ================================================ ================================================ FILE: ui/html/scriptlet.html ================================================ Hello, World! The time is . ================================================ FILE: ui/html/standard_scriptlet.html ================================================

    This will always be served!

    This will never be served.

    ================================================ FILE: ui/sidebar/code.gs ================================================ // Use this code for Google Docs, Slides, Forms, or Sheets. function onOpen() { SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .createMenu("Dialog") .addItem("Open", "openDialog") .addToUi(); } function openDialog() { const html = HtmlService.createHtmlOutputFromFile("Index"); SpreadsheetApp.getUi() // Or DocumentApp or SlidesApp or FormApp. .showModalDialog(html, "Dialog title"); } ================================================ FILE: ui/sidebar/index.html ================================================ Hello, World! ================================================ FILE: ui/user/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } function getEmail() { return Session.getActiveUser().getEmail(); } ================================================ FILE: ui/user/index.html ================================================ ================================================ FILE: ui/webapp/code.gs ================================================ function doGet() { return HtmlService.createHtmlOutputFromFile("Index"); } ================================================ FILE: ui/webapp/index.html ================================================ Hello, World! ================================================ FILE: utils/logging.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // [START apps_script_logging_execution_time] /** * A placeholder function to be timed. * @param {Object} parameters */ function myFunction(parameters) { // Placeholder for the function being timed. } /** * Logs the time taken to execute 'myFunction'. */ function measuringExecutionTime() { // A simple INFO log message, using sprintf() formatting. console.info("Timing the %s function (%d arguments)", "myFunction", 1); // Log a JSON object at a DEBUG level. The log is labeled // with the message string in the log viewer, and the JSON content // is displayed in the expanded log structure under "jsonPayload". const parameters = { isValid: true, content: "some string", timestamp: new Date(), }; console.log({ message: "Function Input", initialData: parameters }); const label = "myFunction() time"; // Labels the timing log entry. console.time(label); // Starts the timer. try { myFunction(parameters); // Function to time. } catch (e) { // Logs an ERROR message. console.error(`myFunction() yielded an error: ${e}`); } console.timeEnd(label); // Stops the timer, logs execution duration. } // [END apps_script_logging_execution_time] // [START apps_script_logging_sheet_information] /** * Logs Google Sheet information. * @param {number} rowNumber The spreadsheet row number. * @param {string} email The email to send with the row data. */ function emailDataRow(rowNumber, email) { console.log(`Emailing data row ${rowNumber} to ${email}`); const sheet = SpreadsheetApp.getActiveSheet(); const data = sheet.getDataRange().getValues(); const rowData = data[rowNumber - 1].join(" "); console.log(`Row ${rowNumber} data: ${rowData}`); MailApp.sendEmail(email, `Data in row ${rowNumber}`, rowData); } // [END apps_script_logging_sheet_information] ================================================ FILE: utils/test_logging.gs ================================================ /** * Copyright Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * create test spreadsheets * @return {string} spreadsheet */ function createTestSpreadsheet() { const spreadsheet = SpreadsheetApp.create("Test Spreadsheet"); for (let i = 0; i < 3; ++i) { spreadsheet.appendRow([1, 2, 3]); } return spreadsheet.getId(); } /** * populate the created spreadsheet with values * @param {string} spreadsheetId */ function populateValues(spreadsheetId) { const batchUpdateRequest = Sheets.newBatchUpdateSpreadsheetRequest(); const repeatCellRequest = Sheets.newRepeatCellRequest(); /** @type {string[][]} */ const values = []; for (let i = 0; i < 10; ++i) { values[i] = []; for (let j = 0; j < 10; ++j) { values[i].push("Hello"); } } const range = "A1:J10"; SpreadsheetApp.openById(spreadsheetId).getRange(range).setValues(values); SpreadsheetApp.flush(); } /** * Test emailDataRow of logging.gs */ function itShouldEmailDataRow() { console.log("> itShouldEmailDataRow"); const email = Session.getActiveUser().getEmail(); const spreadsheetId = createTestSpreadsheet(); populateValues(spreadsheetId); const data = SpreadsheetApp.openById(spreadsheetId); emailDataRow(1, email); } /** * runs all the functions of logging.gs */ function RUN_ALL_TESTS() { console.log("> itShouldMeasureExecutionTime"); measuringExecutionTime(); itShouldEmailDataRow(); } ================================================ FILE: wasm/README.md ================================================ # Unleashing the power of Rust, Python, and WebAssembly in Apps Script This folder is the companion to the talk "Unleashing the power of Rust, Python, and WebAssembly in Apps Script" at Google Cloud Next '24. ## Development The development of this proof of concept requires a deep understanding of the Apps Script runtime, JavaScript bundlers, the Rust programming language, and the WebAssembly ecosystem. Please note that this is a quickly evolving space, and the tools and techniques used in this project may become outdated quickly. ### Prerequisites - Node.js - https://nodejs.org/en/download - Rust - https://www.rust-lang.org/tools/install - Binaryen (for wasm-opt) - https://github.com/WebAssembly/binaryen ================================================ FILE: wasm/hello-world/.clasp.json ================================================ { "scriptId": "1xt1CvoUyFAzfoCdkwCHXBXzu3oaNz2a6iNsPW2GA6rOAsyBv66r4TarA", "rootDir": "./dist" } ================================================ FILE: wasm/hello-world/.gitattributes ================================================ package-lock.json merge=binary -diff Cargo.lock merge=binary -diff ================================================ FILE: wasm/hello-world/.gitignore ================================================ /target /node_modules /dist /src/pkg .wireit ================================================ FILE: wasm/hello-world/Cargo.toml ================================================ # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [package] name = "example" version = "0.1.0" edition = "2021" rust-version = "1.57" [lib] crate-type = ["cdylib"] [dependencies] wasm-bindgen = { version = "0.2.91", features = [] } [dev-dependencies] wasm-bindgen-cli = "0.2.91" [profile.release] opt-level = 's' ================================================ FILE: wasm/hello-world/README.md ================================================ # Unleashing the power of Rust, Python, and WebAssembly in Apps Script This folder is the companion to the talk "Unleashing the power of Rust, Python, and WebAssembly in Apps Script" at Google Cloud Next '24. ## Development The development of this proof of concept requires a deep understanding of the Apps Script runtime, JavaScript bundlers, the Rust programming language, and the WebAssembly ecosystem. Please note that this is a quickly evolving space, and the tools and techniques used in this project may become outdated quickly. ### Prerequisites - Node.js - https://nodejs.org/en/download - Rust - https://www.rust-lang.org/tools/install - Binaryen (for wasm-opt) - https://github.com/WebAssembly/binaryen ### Build 1. `npm i` 1. `npm run build` ================================================ FILE: wasm/hello-world/build.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from "node:fs"; import path from "node:path"; import esbuild from "esbuild"; import { wasmLoader } from "esbuild-plugin-wasm"; const outdir = "dist"; const sourceRoot = "src"; await esbuild.build({ entryPoints: ["./src/wasm.js"], bundle: true, outdir, sourceRoot, platform: "neutral", format: "esm", plugins: [wasmLoader({ mode: "embedded" })], inject: ["polyfill.js"], minify: true, banner: { js: "// Generated code DO NOT EDIT\n" }, }); const passThroughFiles = ["main.js", "test.js", "appsscript.json"]; await Promise.all( passThroughFiles.map(async (file) => fs.promises.copyFile(path.join(sourceRoot, file), path.join(outdir, file)), ), ); ================================================ FILE: wasm/hello-world/package.json ================================================ { "name": "example", "version": "0.1.0", "description": "An example integration of WASM with Rust into Apps Script", "scripts": { "build": "wireit", "build:rust": "wireit", "build:wasm": "wireit", "clean": "rm -rf dist pkg target", "deploy": "wireit", "format": "cargo fmt", "start": "wireit" }, "wireit": { "build": { "command": "node build.js", "dependencies": ["build:wasm"], "files": ["src/*.js", "*.js", "package.json"], "output": ["dist"] }, "build:rust": { "command": "cargo build --release --target wasm32-unknown-unknown", "output": ["./target/wasm32-unknown-unknown/release/example.wasm"], "files": ["Cargo.lock", "Cargo.toml", "src/**/*.rs", "package.json"] }, "build:wasm": { "command": "wasm-bindgen --out-dir src/pkg --target bundler ./target/wasm32-unknown-unknown/release/example.wasm && wasm-opt src/pkg/example_bg.wasm -Oz -o src/pkg/example_bg.wasm", "dependencies": ["build:rust"], "files": [ "./target/wasm32-unknown-unknown/release/example_bg.wasm", "package.json" ], "output": ["src/pkg"] }, "start": { "command": "node dist/index.js", "dependencies": ["build"] }, "deploy": { "command": "clasp push -f", "dependencies": ["build"], "files": [".clasp.json", ".claspignore"] } }, "author": "Justin Poehnelt ", "license": "Apache-2.0", "devDependencies": { "@google/clasp": "^2.4.2", "esbuild": "^0.20.1", "esbuild-plugin-wasm": "github:Tschrock/esbuild-plugin-wasm#04ce98be15b9471980c150eb142d51b20a7dd8bd", "vitest": "^1.3.1", "wireit": "^0.14.4" }, "dependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22" }, "type": "module" } ================================================ FILE: wasm/hello-world/polyfill.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { TextEncoder, TextDecoder, } from "fastestsmallesttextencoderdecoder/EncoderDecoderTogether.min.js"; ================================================ FILE: wasm/hello-world/src/appsscript.json ================================================ { "timeZone": "America/Denver", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8" } ================================================ FILE: wasm/hello-world/src/lib.rs ================================================ // Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn hello(name: &str) -> JsValue { format!("Hello, {} from Rust!", name).into() } ================================================ FILE: wasm/hello-world/src/main.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ async function main() { const name = "world"; console.log(await hello_(name)); } ================================================ FILE: wasm/hello-world/src/test.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ async function test() { await assert(hello_("world"), "Hello, world from Rust!"); } async function assert(a, b, message) { const aVal = await a; const bVal = await b; if (aVal !== bVal) { throw message ?? `'${aVal}' !== '${bVal}'`; } } async function latency(func, iterations, argsFunc = () => []) { const executionTimes = []; for (let i = 0; i < iterations; i++) { const args = argsFunc(); const startTime = Date.now(); let endTime; try { await func(...args); endTime = Date.now(); } catch (e) { endTime = Number.POSITIVE_INFINITY; console.error(e); continue; } executionTimes.push(endTime - startTime); } // Calculate statistics const min = Math.min(...executionTimes); const max = Math.max(...executionTimes); const totalTime = executionTimes.reduce((sum, time) => sum + time, 0); const average = totalTime / iterations; return { min: min, max: max, average: average, totalTime, // times: executionTimes // Array of all execution times }; } async function benchmark() { await hello_("world"); // Warmup console.log(await latency(hello_, 100, () => [generateRandomString(10)])); console.log(await latency(hello_, 100, () => [generateRandomString(100)])); console.log(await latency(hello_, 100, () => [generateRandomString(1000)])); console.log(await latency(hello_, 100, () => [generateRandomString(10000)])); console.log(await latency(hello_, 100, () => [generateRandomString(100000)])); console.log(await latency(hello_, 30, () => [generateRandomString(1000000)])); } function generateRandomString(length = 1024) { // Choose your desired character set const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const charactersLength = characters.length; let result = ""; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } ================================================ FILE: wasm/hello-world/src/wasm.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Wrapper function for hello * @param {string} name * @returns */ async function hello_(name) { const wasm = await import("./pkg/example_bg.wasm"); const { __wbg_set_wasm, hello } = await import("./pkg/example_bg.js"); __wbg_set_wasm(wasm); return hello(name); } globalThis.hello_ = hello_; ================================================ FILE: wasm/image-add-on/.clasp.json ================================================ { "scriptId": "1gP1tiV1KkhVbMADIA_M-d4IJP1GNXwU7-7MundfqlESmSAdo0sC_Nml4", "rootDir": "./dist" } ================================================ FILE: wasm/image-add-on/.gitattributes ================================================ package-lock.json merge=binary -diff Cargo.lock merge=binary -diff ================================================ FILE: wasm/image-add-on/.gitignore ================================================ /target /node_modules /dist /src/pkg .wireit ================================================ FILE: wasm/image-add-on/Cargo.toml ================================================ # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [package] name = "example" version = "0.1.0" edition = "2021" rust-version = "1.57" [lib] crate-type = ["cdylib"] [dependencies] base64 = "0.22.0" console_error_panic_hook = "0.1.7" image = {version = "0.24.9", features = ["jpeg"]} js-sys = "0.3.69" wasm-bindgen = { version = "0.2.91", features = [] } [dev-dependencies] wasm-bindgen-cli = "0.2.91" [profile.release] opt-level = 's' ================================================ FILE: wasm/image-add-on/README.md ================================================ # Unleashing the power of Rust, Python, and WebAssembly in Apps Script This folder is the companion to the talk "Unleashing the power of Rust, Python, and WebAssembly in Apps Script" at Google Cloud Next '24. ## Development The development of this proof of concept requires a deep understanding of the Apps Script runtime, JavaScript bundlers, the Rust programming language, and the WebAssembly ecosystem. Please note that this is a quickly evolving space, and the tools and techniques used in this project may become outdated quickly. ### Prerequisites - Node.js - https://nodejs.org/en/download - Rust - https://www.rust-lang.org/tools/install - Binaryen (for wasm-opt) - https://github.com/WebAssembly/binaryen ### Build 1. `npm i` 1. `npm run build` ================================================ FILE: wasm/image-add-on/build.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from "node:fs"; import path from "node:path"; import esbuild from "esbuild"; import { wasmLoader } from "esbuild-plugin-wasm"; const outdir = "dist"; const sourceRoot = "src"; await esbuild.build({ entryPoints: ["./src/wasm.js"], bundle: true, outdir, sourceRoot, platform: "neutral", format: "esm", plugins: [wasmLoader({ mode: "embedded" })], inject: ["polyfill.js"], minify: true, banner: { js: "// Generated code DO NOT EDIT\n" }, }); const passThroughFiles = ["main.js", "test.js", "appsscript.json", "add-on.js"]; await Promise.all( passThroughFiles.map(async (file) => fs.promises.copyFile(path.join(sourceRoot, file), path.join(outdir, file)), ), ); ================================================ FILE: wasm/image-add-on/package.json ================================================ { "name": "example", "version": "0.1.0", "description": "An example integration of WASM with Rust into Apps Script", "scripts": { "build": "wireit", "build:rust": "wireit", "build:wasm": "wireit", "clean": "rm -rf dist pkg target", "deploy": "wireit", "format": "cargo fmt", "start": "wireit" }, "wireit": { "build": { "command": "node build.js", "dependencies": ["build:wasm"], "files": ["src/*.js", "src/*.json", "*.js", "package.json"], "output": ["dist"] }, "build:rust": { "command": "cargo build --release --target wasm32-unknown-unknown", "output": ["./target/wasm32-unknown-unknown/release/example.wasm"], "files": ["Cargo.lock", "Cargo.toml", "src/**/*.rs", "package.json"] }, "build:wasm": { "command": "wasm-bindgen --out-dir src/pkg --target bundler ./target/wasm32-unknown-unknown/release/example.wasm && wasm-opt src/pkg/example_bg.wasm -Oz -o src/pkg/example_bg.wasm", "dependencies": ["build:rust"], "files": [ "./target/wasm32-unknown-unknown/release/example_bg.wasm", "package.json" ], "output": ["src/pkg"] }, "start": { "command": "node dist/index.js", "dependencies": ["build"] }, "deploy": { "command": "clasp push -f", "dependencies": ["build"], "files": [".clasp.json", ".claspignore"] } }, "author": "Justin Poehnelt ", "license": "Apache-2.0", "devDependencies": { "@google/clasp": "^2.4.2", "esbuild": "^0.20.1", "esbuild-plugin-wasm": "github:Tschrock/esbuild-plugin-wasm#04ce98be15b9471980c150eb142d51b20a7dd8bd", "vitest": "^1.3.1", "wireit": "^0.14.4" }, "dependencies": { "@types/google-apps-script": "^1.0.82", "fastestsmallesttextencoderdecoder": "^1.0.22" }, "type": "module" } ================================================ FILE: wasm/image-add-on/polyfill.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { TextEncoder, TextDecoder, } from "fastestsmallesttextencoderdecoder/EncoderDecoderTogether.min.js"; ================================================ FILE: wasm/image-add-on/src/add-on.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const COLORS = { RED: "#EA4335", }; const properties = PropertiesService.getUserProperties(); async function card(items) { const builder = CardService.newCardBuilder(); const { quality, format, width, height } = loadSettings(); const controls = CardService.newCardSection() .addWidget( CardService.newSelectionInput() .setFieldName("quality") .setTitle("Quality") .setType(CardService.SelectionInputType.RADIO_BUTTON) .addItem("Low", "low", quality === "low") .addItem("Medium", "medium", quality === "medium") .addItem("High", "high", quality === "high"), ) .addWidget( CardService.newTextInput() .setFieldName("height") .setTitle("Height") .setMultiline(false) .setValue(height ?? ""), ) .addWidget( CardService.newTextInput() .setFieldName("width") .setTitle("Width") .setMultiline(false) .setValue(width ?? ""), ) .addWidget( CardService.newTextButton() .setBackgroundColor(COLORS.RED) .setText("Apply Settings") .setOnClickAction( CardService.newAction() .setFunctionName("updateSettings") .setParameters({}) .setLoadIndicator(CardService.LoadIndicator.SPINNER), ), ) .setCollapsible(true) .setNumUncollapsibleWidgets(0); builder.addSection(controls); const sections = await Promise.all( ( items ?? JSON.parse( PropertiesService.getUserProperties().getProperty("selectedItems"), ) ) .filter((item) => item.mimeType.startsWith("image")) .map(async (item) => { const section = CardService.newCardSection(); const bytes = DriveApp.getFileById(item.id).getBlob().getBytes(); const newBytes = await compress_(bytes, { quality: qualityToInt(quality), format: item.mimeType.split("/").pop(), width: Number.parseInt(width ?? "0"), height: Number.parseInt(height ?? "0"), }); const dataUrl = `data:${item.mimeType};base64,${Utilities.base64Encode( newBytes, )}`; section.addWidget(CardService.newImage().setImageUrl(dataUrl)); section.addWidget( CardService.newDecoratedText().setText(bytesToText(newBytes.length)), ); section.addWidget( CardService.newButtonSet() .addButton( CardService.newTextButton() .setBackgroundColor(COLORS.RED) .setText("Save") .setOnClickAction( CardService.newAction() .setFunctionName("save") .setParameters({ bytes: Utilities.base64Encode(newBytes), action: "save", item: JSON.stringify(item), }), ), ) .addButton( CardService.newTextButton() .setBackgroundColor(COLORS.RED) .setText("Save Copy") .setOnClickAction( CardService.newAction() .setFunctionName("save") .setParameters({ bytes: Utilities.base64Encode(newBytes), action: "save-as", item: JSON.stringify(item), }), ), ), ); return section; }), ); for (const section of sections) { builder.addSection(section); } return builder; } /** * Build a simple card that checks selected items' quota usage. Checking * quota usage requires user-permissions, so this add-on provides a button * to request `drive.file` scope for items the add-on doesn't yet have * permission to access. * * @param e The event object passed containing contextual information about * the Drive items selected. * @return {Card} */ async function onItemsSelectedTrigger(e) { PropertiesService.getUserProperties().setProperty( "selectedItems", JSON.stringify(e.drive.selectedItems), ); return (await card(e.drive.selectedItems)).build(); } /** * Callback function for a button action. Instructs Drive to display a * permissions dialog to the user, requesting `drive.file` scope for a * specific item on behalf of this add-on. * * @param {Object} e The parameters object that contains the item's * Drive ID. * @return {DriveItemsSelectedActionResponse} */ function onRequestFileScopeButtonClicked(e) { const idToRequest = e.parameters.id; return CardService.newDriveItemsSelectedActionResponseBuilder() .requestFileScope(idToRequest) .build(); } function onFileScopeGrantedTrigger(e) { console.info("after granting item"); console.info(e); const builder = CardService.newCardBuilder(); return builder.build(); } function onHomePageTrigger() { return CardService.newCardBuilder() .setHeader(CardService.newCardHeader().setTitle("Drive Image Compress")) .addSection( CardService.newCardSection().addWidget( CardService.newTextParagraph().setText( "Select one or more files in Drive to compress the image.", ), ), ) .build(); } function bytesToText(bytes) { const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; if (bytes === 0) return "0 Byte"; const i = Number.parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); return `${Math.round(bytes / 1024 ** i)} ${sizes[i]}`; } async function save(...args) { console.log(args); return CardService.newActionResponseBuilder() .setNavigation(CardService.newNavigation().popToRoot()) .build(); } async function updateSettings(e) { console.log({ e }); const { formInput } = e; persistSettings(formInput); return CardService.newActionResponseBuilder() .setNavigation( CardService.newNavigation() .popToRoot() .updateCard((await card()).build()), ) .build(); } function persistSettings(settings) { properties.setProperty( "settings", JSON.stringify({ ...loadSettings, ...settings, }), ); } function loadSettings() { const defaults = { quality: "medium", }; return { ...defaults, ...JSON.parse(properties.getProperty("settings") ?? "{}"), }; } function qualityToInt(quality) { switch (quality) { case "low": return 50; case "medium": return 80; case "high": return 90; } } ================================================ FILE: wasm/image-add-on/src/appsscript.json ================================================ { "timeZone": "America/Denver", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v3", "serviceId": "drive" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "addOns": { "common": { "logoUrl": "https://ssl.gstatic.com/docs/script/images/logo/script-64.png", "name": "Drive Image Compress Add-on", "universalActions": [] }, "drive": { "homepageTrigger": { "runFunction": "onHomePageTrigger", "enabled": true }, "onItemsSelectedTrigger": { "runFunction": "onItemsSelectedTrigger" } } }, "oauthScopes": [ "https://www.googleapis.com/auth/script.locale", "https://www.googleapis.com/auth/drive.addons.metadata.readonly", "https://www.googleapis.com/auth/drive" ] } ================================================ FILE: wasm/image-add-on/src/lib.rs ================================================ // Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn compress(data: &[u8], quality: u8, width: u32, height: u32) -> JsValue { console_error_panic_hook::set_once(); let img = match image::load_from_memory_with_format(data, image::ImageFormat::Jpeg) { Ok(img) => img, Err(_) => return JsValue::from_str("something went wrong"), }; let img = match width == 0 || height == 0 { true => img, false => img.resize(width, height, image::imageops::FilterType::Lanczos3), }; let mut data = Vec::new(); let _ = img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality( &mut data, quality, )); js_sys::Uint8Array::from(&data[..]).into() } ================================================ FILE: wasm/image-add-on/src/main.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const QUALITY = 80; async function main() { const iterator = DriveApp.getFilesByType("image/jpeg"); while (iterator.hasNext()) { const file = iterator.next(); const bytes = file.getBlob().getBytes(); const dataUrl = await compress_(bytes, QUALITY); if (dataUrl) { console.log(dataUrl); } else { console.warn("failed to decode image"); } } } ================================================ FILE: wasm/image-add-on/src/test.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ async function test() {} async function assert(a, b, message) { const aVal = await a; const bVal = await b; if (aVal !== bVal) { throw message ?? `'${aVal}' !== '${bVal}'`; } } async function latency(func, iterations, argsFunc = () => []) { const executionTimes = []; for (let i = 0; i < iterations; i++) { const args = argsFunc(); const startTime = Date.now(); let endTime; try { await func(...args); endTime = Date.now(); } catch (e) { endTime = Number.POSITIVE_INFINITY; console.error(e); continue; } executionTimes.push(endTime - startTime); } // Calculate statistics const min = Math.min(...executionTimes); const max = Math.max(...executionTimes); const totalTime = executionTimes.reduce((sum, time) => sum + time, 0); const average = totalTime / iterations; return { min: min, max: max, average: average, totalTime, // times: executionTimes // Array of all execution times }; } async function benchmark() { await hello_("world"); // Warmup console.log(await latency(hello_, 100, () => [generateRandomString(10)])); console.log(await latency(hello_, 100, () => [generateRandomString(100)])); console.log(await latency(hello_, 100, () => [generateRandomString(1000)])); console.log(await latency(hello_, 100, () => [generateRandomString(10000)])); console.log(await latency(hello_, 100, () => [generateRandomString(100000)])); console.log(await latency(hello_, 30, () => [generateRandomString(1000000)])); } function generateRandomString(length = 1024) { // Choose your desired character set const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; const charactersLength = characters.length; let result = ""; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } ================================================ FILE: wasm/image-add-on/src/wasm.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ async function compress_(bytes, { quality, format, width, height }) { const wasm = await import("./pkg/example_bg.wasm"); const { __wbg_set_wasm, compress } = await import("./pkg/example_bg.js"); __wbg_set_wasm(wasm); width = width || 0; height = height || 0; console.log({ quality, format, width, height }); const result = compress(bytes, quality, format, width, height); if (typeof result === "string") { throw new Error(result); } return result; } globalThis.compress_ = compress_; ================================================ FILE: wasm/python/.clasp.json ================================================ { "scriptId": "1_tU8IFkT1ZZ-b08YFeC8umntrH92WVQ27jvUmsCo1W4ZqKKqcytBLdcn", "rootDir": "./dist" } ================================================ FILE: wasm/python/.gitattributes ================================================ package-lock.json merge=binary -diff Cargo.lock merge=binary -diff ================================================ FILE: wasm/python/.gitignore ================================================ /target /node_modules /dist /src/pkg .wireit ================================================ FILE: wasm/python/Cargo.toml ================================================ # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. [package] name = "example" version = "0.1.0" edition = "2021" rust-version = "1.57" [lib] crate-type = ["cdylib"] [dependencies] console_error_panic_hook = "0.1.7" getrandom = { version = "0.2", features = ["js"] } rustpython-vm = { version = "0.3.0", features = ["compiler", "serde"] } serde-wasm-bindgen = "0.6.5" wasm-bindgen = { version = "0.2.91", features = [] } web-sys = { version = "0.3.69", features = ["console"] } [dev-dependencies] wasm-bindgen-cli = "0.2.91" [profile.release] opt-level = 's' ================================================ FILE: wasm/python/README.md ================================================ # Unleashing the power of Rust, Python, and WebAssembly in Apps Script This folder is the companion to the talk "Unleashing the power of Rust, Python, and WebAssembly in Apps Script" at Google Cloud Next '24. ## Development The development of this proof of concept requires a deep understanding of the Apps Script runtime, JavaScript bundlers, the Rust programming language, and the WebAssembly ecosystem. Please note that this is a quickly evolving space, and the tools and techniques used in this project may become outdated quickly. ### Prerequisites - Node.js - https://nodejs.org/en/download - Rust - https://www.rust-lang.org/tools/install - Binaryen (for wasm-opt) - https://github.com/WebAssembly/binaryen ### Build 1. `npm i` 1. `npm run build` ================================================ FILE: wasm/python/build.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from "node:fs"; import path from "node:path"; import esbuild from "esbuild"; import { wasmLoader } from "esbuild-plugin-wasm"; const outdir = "dist"; const sourceRoot = "src"; await esbuild.build({ entryPoints: ["./src/wasm.js"], bundle: true, outdir, sourceRoot, platform: "neutral", format: "esm", plugins: [wasmLoader({ mode: "embedded" })], inject: ["polyfill.js"], minify: true, banner: { js: "// Generated code DO NOT EDIT\n" }, }); const passThroughFiles = ["main.js", "test.js", "appsscript.json"]; await Promise.all( passThroughFiles.map(async (file) => fs.promises.copyFile(path.join(sourceRoot, file), path.join(outdir, file)), ), ); ================================================ FILE: wasm/python/package.json ================================================ { "name": "example", "version": "0.1.0", "description": "An example integration of WASM with Rust into Apps Script", "scripts": { "build": "wireit", "build:rust": "wireit", "build:wasm": "wireit", "clean": "rm -rf dist pkg target", "deploy": "wireit", "format": "cargo fmt", "start": "wireit" }, "wireit": { "build": { "command": "node build.js", "dependencies": ["build:wasm"], "files": ["src/*.js", "src/*.json", "*.js", "package.json"], "output": ["dist"] }, "build:rust": { "command": "cargo build --release --target wasm32-unknown-unknown", "output": ["./target/wasm32-unknown-unknown/release/example.wasm"], "files": ["Cargo.lock", "Cargo.toml", "src/**/*.rs", "package.json"] }, "build:wasm": { "command": "wasm-bindgen --out-dir src/pkg --target bundler ./target/wasm32-unknown-unknown/release/example.wasm && wasm-opt src/pkg/example_bg.wasm -Oz -o src/pkg/example_bg.wasm", "dependencies": ["build:rust"], "files": [ "./target/wasm32-unknown-unknown/release/example_bg.wasm", "package.json" ], "output": ["src/pkg"] }, "start": { "command": "node dist/index.js", "dependencies": ["build"] }, "deploy": { "command": "clasp push -f", "dependencies": ["build"], "files": [".clasp.json", ".claspignore"] } }, "author": "Justin Poehnelt ", "license": "Apache-2.0", "devDependencies": { "@google/clasp": "^2.4.2", "esbuild": "^0.20.1", "esbuild-plugin-wasm": "github:Tschrock/esbuild-plugin-wasm#04ce98be15b9471980c150eb142d51b20a7dd8bd", "vitest": "^1.3.1", "wireit": "^0.14.4" }, "dependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22" }, "type": "module" } ================================================ FILE: wasm/python/polyfill.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export { TextEncoder, TextDecoder, } from "fastestsmallesttextencoderdecoder/EncoderDecoderTogether.min.js"; ================================================ FILE: wasm/python/src/appsscript.json ================================================ { "timeZone": "America/Denver", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v3", "serviceId": "drive" } ] }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "oauthScopes": [] } ================================================ FILE: wasm/python/src/lib.rs ================================================ // Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use rustpython_vm::{ builtins::PyBaseException, compiler::Mode, convert::ToPyObject, py_serde, Interpreter, PyObjectRef, PyRef, VirtualMachine, }; use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn python(source: &str, data: &JsValue) -> JsValue { console_error_panic_hook::set_once(); Interpreter::with_init(Default::default(), |_vm| {}) .enter(|vm| { let data = match deserialize(data.clone(), vm) { Ok(data) => data, Err(err) => return PyError::new(err.to_string().into()).into(), }; let scope = vm.new_scope_with_builtins(); match scope.globals.set_item("args", data, vm) { Ok(()) => {} Err(err) => return serialize_exception(err, vm).into(), } let py_obj = match vm .compile(source, Mode::BlockExpr, "".to_owned()) .map_err(|err| vm.new_syntax_error(&err, Some(source))) .and_then(|code_obj| vm.run_code_obj(code_obj, scope).clone()) { Ok(py_obj) => py_obj, Err(err) => return serialize_exception(err, vm).into(), }; serialize(&py_obj, vm) }) } #[wasm_bindgen(inline_js = r" export class PyError extends Error { constructor(message) { super(message); } } ")] extern "C" { pub type PyError; #[wasm_bindgen(constructor)] fn new(message: JsValue) -> PyError; } fn serialize_exception(err: PyRef, vm: &VirtualMachine) -> PyError { PyError::new(serialize(&err.args().to_pyobject(vm), vm)) } fn serialize(py_obj: &PyObjectRef, vm: &VirtualMachine) -> JsValue { py_serde::serialize(vm, py_obj, &serde_wasm_bindgen::Serializer::new()) .unwrap_or(format!("Failed to serialize: {:?}", py_obj).into()) } fn deserialize( value: JsValue, vm: &VirtualMachine, ) -> Result { let deserializer: serde_wasm_bindgen::Deserializer = value.into(); py_serde::deserialize(vm, deserializer) } ================================================ FILE: wasm/python/src/main.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Execute Python code and return the result. * @param {string} code * @param {...*} args - Arguments to pass to the Python code. Accessible as * `args` in the Python code. * * @customfunction */ async function PYTHON(code = "args", ...args) { const result = await python_(`${code}`, ...args); if (result instanceof Error) { throw result; } return result; } ================================================ FILE: wasm/python/src/test.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ async function test() {} async function assert(a, b, message) { const aVal = await a; const bVal = await b; if (aVal !== bVal) { throw message ?? `'${aVal}' !== '${bVal}'`; } } ================================================ FILE: wasm/python/src/wasm.js ================================================ /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ globalThis.crypto = { getRandomValues: (array) => array.map(() => Math.floor(Math.random() * 256)), }; /** * Wrapper function for hello * @param {string} name * @returns */ async function python_(source, ...args) { const wasm = await import("./pkg/example_bg.wasm"); const { __wbg_set_wasm, python } = await import("./pkg/example_bg.js"); __wbg_set_wasm(wasm); return await python(source, args); } globalThis.python_ = python_;