├── .gitignore
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
└── projects
├── GAS001
├── .clasp.json
├── Code.js
└── appsscript.json
├── GAS004
├── .clasp.json
├── Code.js
├── appsscript.json
└── template.html
├── GAS013
├── .clasp.json
├── Code.js
└── appsscript.json
├── GAS067
├── .clasp.json
├── app.js
├── appsscript.json
└── sidebar.html
├── GAS071
├── .clasp.json
├── app.js
└── appsscript.json
├── GAS085
├── .clasp.json
├── Code.js
├── README.md
└── appsscript.json
├── GAS086
├── .clasp.json
├── appsscript.json
├── backend.js
├── css.html
├── index.html
├── utils.html
└── vue
│ ├── components.html
│ ├── formdata.html
│ ├── index.html
│ ├── router.html
│ ├── store.html
│ ├── view
│ ├── dashboard.html
│ ├── history.html
│ ├── received.html
│ ├── sent.html
│ └── users.html
│ └── vuetify.html
├── GAS087
├── .clasp.json
├── appsscript.json
├── backend.js
├── index.html
└── vuejs.html
├── GAS088
├── .clasp.json
├── app.js
├── appsscript.json
└── logEmail.html
├── GAS089
├── .clasp.json
├── app.js
├── appsscript.json
├── configs.js
└── emailForm.html
├── GAS090
├── .clasp.json
├── app.js
├── appsscript.json
└── html
│ └── dialog.html
├── GAS091
├── .clasp.json
├── app.js
└── appsscript.json
├── GAS092
├── .clasp.json
├── appsscript.json
└── main.js
├── GAS093
├── .clasp.json
├── app.js
└── appsscript.json
├── GAS094
├── .clasp.json
├── app.js
├── appsscript.json
└── settings.html
├── GAS095
├── .clasp.json
├── app.js
└── appsscript.json
├── GAS096
├── .clasp.json
├── 404.html
├── app.js
├── appsscript.json
├── email.html
├── sharing.html
└── upload.html
├── GAS097
├── .clasp.json
├── app.js
├── appsscript.json
├── readme.md
└── sidebar.html
├── GAS098
├── .clasp.json
├── Code.js
├── appsscript.json
└── readme.md
├── GAS099
├── .clasp.json
├── 0.utils.js
├── 1.main.js
├── 2.api.js
├── appsscript.json
└── html
│ └── sidebar.html
├── GAS100
├── .clasp.json
├── 0.utils.js
├── 1.app.js
└── appsscript.json
├── GAS101
├── .clasp.json
├── 0.revision.js
├── 0.utils.js
├── 1.api.js
├── 8.main.js
└── appsscript.json
├── GAS102
├── .clasp.json
├── 0.utils.js
├── 1.config.js
├── 2.api.js
├── 9.main.js
├── README.md
└── appsscript.json
├── GAS103
├── README.md
├── appsscript.json
└── src
│ ├── 0.config.js
│ ├── 1.utils.js
│ ├── 2.main.js
│ └── appsscript.json
├── GAS104
├── .clasp.json
├── 1.utils.js
├── 9.main.js
└── appsscript.json
├── GAS105
├── .clasp.json
└── src
│ ├── *.versions.js
│ ├── 0.configs.js
│ ├── 0.utils.js
│ ├── 1.setup.js
│ ├── 9.main.js
│ └── appsscript.json
├── GAS106
├── .clasp.json
├── appsscript.json
├── post.html
└── postViews.js
├── GAS107
├── .clasp.json
├── 0.richTextValue.js
├── 1.demo.js
└── appsscript.json
├── GAS108
├── .clasp.json
├── 0.utils.js
├── 1.main.js
├── appsscript.json
└── index.html
├── GAS109
├── .clasp.json
├── 0.utils.js
├── 1.formSign.js
├── 1.sign.html
├── 2.main.js
└── appsscript.json
├── GAS110
├── .clasp.json
├── appsscript.json
└── main.js
├── GAS111
├── .clasp.json
├── 1.main.js
└── appsscript.json
├── GAS112
├── .clasp.json
├── appsscript.json
├── code.js
└── utils.js
├── GAS113
├── .clasp.json
├── 0.utils.js
├── 1.code.js
├── appsscript.json
└── email.html
├── GAS114
├── .clasp.json
├── appsscript.json
├── forms.js
├── main.js
├── sheets.js
└── vision.js
└── GAS115
├── .clasp.json
├── appsscript.json
└── main.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | GAS999
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Ashton Fei
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "google-apps-script-projects",
3 | "version": "1.0.0",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "google-apps-script-projects",
9 | "version": "1.0.0",
10 | "license": "MIT",
11 | "devDependencies": {
12 | "@types/google-apps-script": "^1.0.45"
13 | }
14 | },
15 | "node_modules/@types/google-apps-script": {
16 | "version": "1.0.45",
17 | "resolved": "https://registry.npmjs.org/@types/google-apps-script/-/google-apps-script-1.0.45.tgz",
18 | "integrity": "sha512-LOkwOeaoxt11splrb1REllLkRkpcwINHgsxzFHLFlwICzC8ld+UEUr8JJN4x4BVvzp7z/ZM1bDXJmD541CGzcw==",
19 | "dev": true
20 | }
21 | },
22 | "dependencies": {
23 | "@types/google-apps-script": {
24 | "version": "1.0.45",
25 | "resolved": "https://registry.npmjs.org/@types/google-apps-script/-/google-apps-script-1.0.45.tgz",
26 | "integrity": "sha512-LOkwOeaoxt11splrb1REllLkRkpcwINHgsxzFHLFlwICzC8ld+UEUr8JJN4x4BVvzp7z/ZM1bDXJmD541CGzcw==",
27 | "dev": true
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "google-apps-script-projects",
3 | "version": "1.0.0",
4 | "description": "My Google Apps Script Projects Shared on YouTube",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/ashtonfei/google-apps-script-projects.git"
12 | },
13 | "keywords": [
14 | "Google",
15 | "AppsScript",
16 | "YouTube",
17 | "GoogleSheet",
18 | "GoogleWorkspace"
19 | ],
20 | "author": "Ashton Fei",
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/ashtonfei/google-apps-script-projects/issues"
24 | },
25 | "homepage": "https://github.com/ashtonfei/google-apps-script-projects#readme",
26 | "devDependencies": {
27 | "@types/google-apps-script": "^1.0.45"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/projects/GAS001/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1A1FthfqVHWJQRGACu2OtY2FOoAJQ_umGc7a7p0uuQf7uTOFvxpGwuWpE"
3 | }
4 |
--------------------------------------------------------------------------------
/projects/GAS001/Code.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Update by Ashton 16 Apr 2024
3 | */
4 |
5 | function onOpen() {
6 | const ui = SpreadsheetApp.getUi();
7 | ui.createMenu("Google Scripts")
8 | .addItem("Weather Forecast", "weatherForecast")
9 | .addToUi();
10 | }
11 |
12 | function weatherForecast() {
13 | const ss = SpreadsheetApp.getActive();
14 | const ui = SpreadsheetApp.getUi();
15 | const sheet = ss.getSheetByName("Weather");
16 | const apiKey = "YOUR OPEN WEATHER MAP API_KEY"; // https://home.openweathermap.org/api_keys
17 |
18 | const cityName = sheet.getRange("B1").getValue();
19 | // Go to https://openweathermap.org, register and get a free API key
20 | const apiCall = "api.openweathermap.org/data/2.5/weather?q=" +
21 | cityName +
22 | "&appid=" +
23 | apiKey;
24 |
25 | const response = UrlFetchApp.fetch(apiCall, { muteHttpExceptions: true });
26 | const data = JSON.parse(response.getContentText());
27 | if (data.message) {
28 | return ui.alert("Error", data.message, ui.ButtonSet.OK);
29 | }
30 |
31 | const weather = data["weather"][0]; //It's an array
32 |
33 | const weatherData = [
34 | ["Location:", data.name],
35 | ["Country:", data.sys.country],
36 | ["Weather:", weather.main],
37 | ["Teaperture:", data.main.temp],
38 | ["Min Temp:", data.main.temp_min],
39 | ["Max Temp:", data.main.temp_max],
40 | ];
41 |
42 | sheet
43 | .getRange(3, 1, weatherData.length, weatherData[0].length)
44 | .setValues(weatherData);
45 | }
46 |
--------------------------------------------------------------------------------
/projects/GAS001/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Hong_Kong",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8"
6 | }
7 |
--------------------------------------------------------------------------------
/projects/GAS004/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"13zRPPdxznwTLK6t5cnk6J7gqGWwsdNKfdmwGEX4ZqDotI9UGGP8js3Hg","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/projects/GAS004"}
2 |
--------------------------------------------------------------------------------
/projects/GAS004/Code.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Script was updated by Ashton on 15 Apr 2024
3 | */
4 |
5 | function sendEmail() {
6 | const subject = "GAS004 Send HTML EMAIL FROM GMAIL";
7 | const recipient = Session.getActiveUser().getEmail();
8 |
9 | const template = HtmlService.createTemplateFromFile("template.html");
10 | // = name ?> for placeholder name in the HTML
11 | template.name = "Ashton Fei";
12 | // = email ?> for placeholder email in the HTML
13 | template.email = recipient;
14 |
15 | const htmlBody = template.evaluate().getContent();
16 | const options = {
17 | htmlBody,
18 | };
19 | GmailApp.sendEmail(recipient, subject, "", options);
20 | }
21 |
--------------------------------------------------------------------------------
/projects/GAS004/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Hong_Kong",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "webapp": {
7 | "executeAs": "USER_DEPLOYING",
8 | "access": "ANYONE_ANONYMOUS"
9 | }
10 | }
--------------------------------------------------------------------------------
/projects/GAS004/template.html:
--------------------------------------------------------------------------------
1 |
2 |
Hi = name ?>,
3 |
This is a test message from = email ?>
4 |
Powered by Google Apps Script
5 |
Follow Me on YouTube
6 |
7 |
--------------------------------------------------------------------------------
/projects/GAS013/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1042LQbfUhmBWLRIIPyYek22GhEjFr_xDz_eep3epuTXy3jn_TaXkvjas",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS013/Code.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {GoogleAppsScript.Events.FormsOnFormSubmit} e
3 | */
4 | const getNamedValues_ = (e) => {
5 | const values = {};
6 | e.response.getItemResponses().forEach((ir) => {
7 | const item = ir.getItem();
8 | const title = item.getTitle().trim().toLowerCase().replace(/\s+/g, "_");
9 | const value = ir.getResponse();
10 | values[title] = value;
11 | });
12 | return values;
13 | };
14 |
15 | const createEvent_ = (values) => {
16 | const { calendar, title, description, guests, location, from, to } = values;
17 | const startTime = new Date(from);
18 | const endTime = new Date(to);
19 | if (startTime >= endTime) {
20 | throw new Error(`Event start time is greater than end time.`);
21 | }
22 | const calendarFound = CalendarApp.getCalendarsByName(calendar)[0] ||
23 | CalendarApp.getDefaultCalendar();
24 | const event = calendarFound.createEvent(title, startTime, endTime);
25 | description && event.setDescription(description);
26 | guests && event.addGuest(guests);
27 | location && event.setLocation(location);
28 | };
29 |
30 | /**
31 | * @param {GoogleAppsScript.Events.FormsOnFormSubmit} e
32 | */
33 | const onFormSubmit_ = (e) => {
34 | if (!e) return console.error("Form submit event object is missing.");
35 | const values = getNamedValues_(e);
36 | createEvent_(values);
37 | };
38 |
39 | const triggerOnFormSubmit = (e) => onFormSubmit_(e);
40 |
41 | const deleteTriggers_ = () => {
42 | ScriptApp.getProjectTriggers().forEach((t) => ScriptApp.deleteTrigger(t));
43 | };
44 |
45 | const installTrigger = () => {
46 | const fn = "triggerOnFormSubmit";
47 | deleteTriggers_();
48 | const form = FormApp.getActiveForm();
49 | ScriptApp.newTrigger(fn).forForm(form).onFormSubmit().create();
50 | const ui = FormApp.getUi();
51 | ui.alert("Trigger has been installed.");
52 | };
53 |
54 | const uninstallTrigger = () => {
55 | deleteTriggers_();
56 | const ui = FormApp.getUi();
57 | ui.alert("Trigger has been uninstalled.");
58 | };
59 |
60 | const onOpen = () => {
61 | const ui = FormApp.getUi();
62 | ui.createMenu("GAS013")
63 | .addItem("Install trigger", "installTrigger")
64 | .addItem("Uninstall trigger", "uninstallTrigger")
65 | .addToUi();
66 | };
67 |
--------------------------------------------------------------------------------
/projects/GAS013/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Hong_Kong",
3 | "exceptionLogging": "STACKDRIVER",
4 | "runtimeVersion": "V8"
5 | }
6 |
--------------------------------------------------------------------------------
/projects/GAS067/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1yhB9MwUyOpZitJRXIWC5liywkTBETP51CSLTCVhSjMcZx9XbhiGRFTD3",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS067/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Hong_Kong",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8"
6 | }
7 |
8 |
--------------------------------------------------------------------------------
/projects/GAS071/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1NmUiKACEzCqXiY7ib8zD0g_0wuJt5nkNxV4crf7wE9tCsNCtrcGCk8Xq","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/projects/GAS071"}
2 |
--------------------------------------------------------------------------------
/projects/GAS071/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Hong_Kong",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS085/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1wcqYuFlcJ8GfXyjooPT2sAxlDDXHcujf8CFIFSxZdnaZESNe7inTeaD9","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS085"}
2 |
--------------------------------------------------------------------------------
/projects/GAS085/Code.js:
--------------------------------------------------------------------------------
1 | const APP_NAME = "💹 SlidePro"
2 | const REPORT_FOLDER_NAME = "Reports"
3 | const SHEET_NAME_REPORTS = "Reports"
4 | const SHEET_NAME_TEXT = "Text"
5 | const SHEET_NAME_IMAGE = "Image"
6 | const SHEET_NAME_TABLE_PATTERN = /^{{.+}}$/
7 | const TRANSPARENT_COLOR = "#ffffff" // take white as transparent for table cell
8 |
9 | class App {
10 | constructor(name = APP_NAME) {
11 | this.name = name
12 | this.ss = SpreadsheetApp.getActive()
13 | this.currentFolder = DriveApp.getFileById(this.ss.getId()).getParents().next()
14 | this.setTextPlaceholdersSheet()
15 | this.setImagePlaceholdersSheet()
16 | this.setTablePlaceholdersSheets()
17 | this.setReportFolder()
18 | this.setSlideTemplate()
19 | }
20 |
21 | getFileIdFromUrl(url){
22 | if (url.indexOf("/d/") === -1) return url
23 | return url.split("/d/")[1].split("/")[0]
24 | }
25 |
26 | setSlideTemplate() {
27 | const templateUrl = this.ss.getSheetByName(SHEET_NAME_REPORTS).getRange("B1").getDisplayValue()
28 | const id = this.getFileIdFromUrl(templateUrl)
29 | this.template = DriveApp.getFileById(id)
30 | return this
31 | }
32 |
33 | setTextPlaceholdersSheet(name = SHEET_NAME_TEXT) {
34 | this.sheetTextPlaceholders = this.ss.getSheetByName(name)
35 | return this
36 | }
37 |
38 | setImagePlaceholdersSheet(name = SHEET_NAME_IMAGE) {
39 | this.sheetImagePlaceholders = this.ss.getSheetByName(name)
40 | return this
41 | }
42 |
43 | setTablePlaceholdersSheets() {
44 | this.sheetsTablePlaceholders = this.ss.getSheets().filter(sheet => {
45 | const name = sheet.getName()
46 | if (SHEET_NAME_TABLE_PATTERN.test(name)) return sheet
47 | })
48 | return this
49 | }
50 |
51 | setReportFolder(name = REPORT_FOLDER_NAME) {
52 | this.sheetReports = this.ss.getSheetByName(SHEET_NAME_REPORTS) || this.ss.insertSheet(SHEET_NAME_REPORTS)
53 | this.sheetReports.getRange(2, 1, 1, 3).setValues([["Report name", "Link", "Created At"]])
54 | const folders = this.currentFolder.getFoldersByName(name)
55 | if (folders.hasNext()) {
56 | this.reportFolder = folders.next()
57 | } else {
58 | this.reportFolder = this.currentFolder.createFolder(name)
59 | }
60 | return this
61 | }
62 |
63 | getTextPlaceholders() {
64 | const placeholders = {}
65 | const values = this.sheetTextPlaceholders.getDataRange().getDisplayValues().slice(1)
66 | values.forEach(([key, value]) => {
67 | placeholders[key.trim()] = value
68 | })
69 | return placeholders
70 | }
71 |
72 | getImagePlaceholders() {
73 | const placeholders = {}
74 | const values = this.sheetImagePlaceholders.getDataRange().getValues().slice(1)
75 | values.forEach(([key, id, url, crop, link]) => {
76 | id = this.getFileIdFromUrl(id)
77 | placeholders[key.trim()] = { id, url, crop, link }
78 | })
79 | return placeholders
80 | }
81 |
82 | getTablePlaceholders() {
83 | const placeholders = {}
84 | this.sheetsTablePlaceholders.forEach(sheet => {
85 | const values = sheet.getDataRange().getDisplayValues()
86 | const colors = sheet.getDataRange().getFontColors()
87 | const bgColors = sheet.getDataRange().getBackgrounds()
88 | placeholders[sheet.getName()] = values.map((rowValues, rowIndex) => {
89 | return rowValues.map((value, colIndex) => ({
90 | value,
91 | color: colors[rowIndex][colIndex],
92 | bgColor: bgColors[rowIndex][colIndex] === TRANSPARENT_COLOR ? null : bgColors[rowIndex][colIndex]
93 | }))
94 | })
95 | })
96 | return placeholders
97 | }
98 |
99 | createReportFilename(placeholders) {
100 | let name = this.template.getName()
101 | Object.entries(placeholders).forEach(([key, value]) => {
102 | name = name.replace(new RegExp(key, 'gi'), value)
103 | })
104 | return name
105 | }
106 |
107 | createReport() {
108 | const ui = SpreadsheetApp.getUi()
109 | const confirm = ui.alert(`${this.name} [Confirm]`, "Are you sure to create a new report?", ui.ButtonSet.YES_NO)
110 | if (confirm !== ui.Button.YES) return
111 | try {
112 | this.ss.toast("working...", `Processing placeholders`)
113 | const textPlaceholders = this.getTextPlaceholders()
114 | const imagePlaceholders = this.getImagePlaceholders()
115 | const tablePlaceholders = this.getTablePlaceholders()
116 |
117 | const name = this.createReportFilename(textPlaceholders)
118 |
119 | this.ss.toast("working...", `Create a copy from template`)
120 | const copy = this.template.makeCopy(name, this.reportFolder)
121 | const presentation = SlidesApp.openById(copy.getId())
122 |
123 | this.ss.toast("working...", `Update images`)
124 | SlidePro.replaceImagePlaceholders(presentation, imagePlaceholders)
125 | this.ss.toast("working...", `Update texts`)
126 | SlidePro.replaceTextPlaceholders(presentation, textPlaceholders)
127 | this.ss.toast("working...", `Update tables`)
128 | SlidePro.replaceTablePlaceholders(presentation, tablePlaceholders)
129 | this.sheetReports.appendRow([presentation.getName(), presentation.getUrl(), new Date()])
130 | this.sheetReports.activate()
131 | this.ss.toast("Done!", `${this.name} [Success]`)
132 | ui.alert(`${this.name} [Success]`, "New report has been created successfully!", ui.ButtonSet.OK)
133 | } catch (err) {
134 | this.ss.toast(err.message, `Error`)
135 | ui.alert(`${this.name} [Error]`, err.message, ui.ButtonSet.OK)
136 | }
137 | }
138 | }
139 |
140 | function createReport() {
141 | const app = new App().createReport()
142 | }
143 |
144 | function onOpen() {
145 | SpreadsheetApp.getUi()
146 | .createMenu(APP_NAME)
147 | .addItem("▶ Create report", "createReport")
148 | .addToUi()
149 | }
--------------------------------------------------------------------------------
/projects/GAS085/README.md:
--------------------------------------------------------------------------------
1 | # GAS-085 Google Slide Automation with SlidePro
2 |
3 | A demo proejct to create Google Slide Report automatically with library [SlidePro](https://github.com/ashtonfei/gas-libs/tree/SlidePro)[YouTube](https://youtu.be/tMruEzRCJD4)
4 |
5 | ## Demo Script
6 |
7 | [Make a copy](https://docs.google.com/spreadsheets/d/1xRJD57AZtZaVhov6Ee7GgaZzj4YqJAaHUrPAt-V3pes/copy)
8 | [Slide Template](https://docs.google.com/presentation/d/1PrkOUeB05DbTSXMsBC8b-0Bin88n19X1eLxvRqUl8L0/copy)
9 |
10 | ## Library Used
11 |
12 | ```bash
13 | 1md6joupTQYKLYQhR4jUGJKIiup2udOGvMi20wLk-rLndpzLn_iNvWWTV
14 | ```
15 |
16 | ## Library source code
17 |
18 | [SlidePro](https://github.com/ashtonfei/gas-libs/tree/SlidePro)
19 |
--------------------------------------------------------------------------------
/projects/GAS085/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "libraries": [
5 | {
6 | "userSymbol": "SlidePro",
7 | "version": "0",
8 | "libraryId": "1md6joupTQYKLYQhR4jUGJKIiup2udOGvMi20wLk-rLndpzLn_iNvWWTV",
9 | "developmentMode": true
10 | }
11 | ]
12 | },
13 | "exceptionLogging": "STACKDRIVER",
14 | "runtimeVersion": "V8"
15 | }
--------------------------------------------------------------------------------
/projects/GAS086/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1XOCpd3wk4CqG1p2-81qEPLJagWcUlBsx0oOq_vQsFjnHVhe0i3HiIonw"}
2 |
--------------------------------------------------------------------------------
/projects/GAS086/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "webapp": {
5 | "executeAs": "USER_DEPLOYING",
6 | "access": "ANYONE_ANONYMOUS"
7 | },
8 | "exceptionLogging": "STACKDRIVER",
9 | "runtimeVersion": "V8"
10 | }
--------------------------------------------------------------------------------
/projects/GAS086/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | != include_("css.html"); ?>
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Ashton Fei © {{new Date().getFullYear()}}
21 |
22 |
23 |
24 |
25 |
26 |
27 | != include_("vue/index.html"); ?>
28 |
29 |
30 |
--------------------------------------------------------------------------------
/projects/GAS086/utils.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/formdata.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | != include_("utils.html"); ?>
4 | != include_("vue/formdata.html"); ?>
5 | != include_("vue/vuetify.html"); ?>
6 | != include_("vue/store.html"); ?>
7 | != include_("vue/components.html"); ?>
8 | != include_("vue/router.html"); ?>
9 |
10 |
22 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/view/dashboard.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | mdi-plus
6 | Create
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ receivedQty }} Received
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ sentQty }} Sent
22 |
23 |
24 |
25 |
26 |
27 |
28 | {{ historyQty }} History
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {{ dialog.icon }}
47 | {{ dialog.type }}
48 |
49 |
50 | mdi-cancel
51 | Cancel
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/view/history.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ item.createdOn ? new Date(item.createdOn).toLocaleString() : item.createdOn}}
5 |
6 |
7 | {{ item.createdOn ? new Date(item.createdOn).toLocaleString() : item.createdOn}}
8 |
9 |
10 | {{ item.status }}
11 |
12 |
13 |
14 |
15 |
16 | Approval Details
17 |
18 |
19 |
21 |
22 |
23 | {{subitem.status}}
24 |
25 |
26 | {{subitem.email}}
27 |
28 | {{subitem.comments}}
29 |
30 |
31 | {{subitem.timestamp}}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/view/received.html:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 | {{ item.createdOn ? new Date(item.createdOn).toLocaleString() : item.createdOn}}
14 |
15 |
16 | {{ item.createdOn ? new Date(item.createdOn).toLocaleString() : item.createdOn}}
17 |
18 |
19 | {{ item.status }}
20 |
21 |
22 |
23 | mdi-check
24 |
25 |
26 | mdi-close
27 |
28 |
29 | mdi-arrow-right
30 |
31 |
32 |
33 |
36 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
52 |
53 |
54 | {{item.status}}
55 |
56 |
57 | {{item.email}}
58 |
59 | {{item.comments}}
60 |
61 |
62 | {{item.timestamp}}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | {{ dialog.icon }}
74 | {{ dialog.type }}
75 |
76 |
77 | mdi-cancel
78 | Cancel
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/view/sent.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ item.createdOn ? new Date(item.createdOn).toLocaleString() : item.createdOn}}
5 |
6 |
7 | {{ item.createdOn ? new Date(item.createdOn).toLocaleString() : item.createdOn}}
8 |
9 |
10 | {{ item.status }}
11 |
12 |
13 |
14 |
15 |
16 | Approval Details
17 |
18 |
19 |
21 |
22 |
23 | {{subitem.status}}
24 |
25 |
26 | {{subitem.email}}
27 |
28 | {{subitem.comments}}
29 |
30 |
31 | {{subitem.timestamp}}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/view/users.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | mdi-plus Create
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | mdi-pencil
15 |
16 |
17 | mdi-delete
18 |
19 |
20 |
21 |
22 | * Only the latest {{ pageSize }} records are shown here, use the search to find old records.
23 |
24 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {{ dialog.icon }}
44 | {{ dialog.type }}
45 |
46 |
47 | mdi-cancel
48 | Cancel
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/projects/GAS086/vue/vuetify.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/projects/GAS087/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1Qbqb9xSCLj3Q9gSnAlzG-xNctlmlW8yAqzp279DjDqqYa5Nl-5CEMZDz","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS087"}
2 |
--------------------------------------------------------------------------------
/projects/GAS087/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "sheets": {
7 | "macros": [
8 | {
9 | "menuName": "Untitled Macro",
10 | "functionName": "UntitledMacro"
11 | }
12 | ]
13 | },
14 | "webapp": {
15 | "executeAs": "USER_DEPLOYING",
16 | "access": "ANYONE_ANONYMOUS"
17 | }
18 | }
--------------------------------------------------------------------------------
/projects/GAS087/backend.js:
--------------------------------------------------------------------------------
1 | const SETTINGS = {
2 | APP_NAME: "GAS-087 Custom Form with Signature",
3 | SHEET_NAME: {
4 | RESPONSES: "Responses"
5 | },
6 | HEADERS: [
7 | { key: "timestamp", value: "Timestamp" },
8 | { key: "id", value: "ID" },
9 | { key: "name", value: "Name" },
10 | { key: "email", value: "Email" },
11 | { key: "phone", value: "Phone" },
12 | { key: "gender", value: "Gender" },
13 | { key: "city", value: "City" },
14 | { key: "date", value: "Date" },
15 | { key: "signature", value: "Signature" },
16 | ]
17 | }
18 |
19 | function link(filename) {
20 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent()
21 | }
22 |
23 | function doGet() {
24 | return HtmlService.createTemplateFromFile("index.html")
25 | .evaluate()
26 | .setTitle(SETTINGS.APP_NAME)
27 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
28 | }
29 |
30 | function submit(data) {
31 | data = JSON.parse(data)
32 | const headers = SETTINGS.HEADERS.map(({value}) => value)
33 | const id = Utilities.getUuid()
34 | const signatures = []
35 | const values = SETTINGS.HEADERS.map(({key}, index) => {
36 | if (key === "id") return id
37 | if (key === "timestamp") return new Date()
38 | if (!key in data) return null
39 | if (Array.isArray(data[key])) return data[key].join(",")
40 | if (data[key].startsWith("data:image")) {
41 | signatures.push(index)
42 | return SpreadsheetApp.newCellImage().setSourceUrl(data[key]).build().toBuilder()
43 | }
44 | return data[key]
45 | })
46 | const ws = SpreadsheetApp.getActive().getSheetByName(SETTINGS.SHEET_NAME.RESPONSES) || SpreadsheetApp.getActive().insertSheet(SETTINGS.SHEET_NAME.RESPONSES)
47 | ws.getRange(1,1, 1, headers.length).setValues([headers])
48 | const lastRow = ws.getLastRow()
49 | ws.getRange(lastRow + 1, 1, 1, values.length).setValues([values])
50 | signatures.forEach(index => {
51 | ws.getRange(lastRow + 1, index + 1).setValue(values[index])
52 | })
53 | return JSON.stringify({success: true, message: `Thanks for your submission! ID: ${id}`})
54 | }
55 |
56 |
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/projects/GAS087/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ title }}
22 | {{ subtitle }}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | Submit
49 |
50 |
51 |
52 |
53 |
54 | {{ snackbar.message }}
55 |
56 |
57 | mdi-close
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | != link("vuejs.html"); ?>
67 |
68 |
69 |
--------------------------------------------------------------------------------
/projects/GAS087/vuejs.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/projects/GAS088/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1wXzBysSHUMU4OA5EjqJMA0WawGI-M9_qgl5jGx-mQrpgDrmI9SO31sv4","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS088"}
2 |
--------------------------------------------------------------------------------
/projects/GAS088/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS088/logEmail.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Here is a list the logs from = appName ?> :
8 |
9 |
10 | Timestamp
11 | Subject
12 | Status
13 | Links
14 |
15 | for (let i = 0; i < logs.length; i ++) { ?>
16 |
17 | = logs[i][0].toLocaleString() ?>
18 | = logs[i][1] ?>
19 | = logs[i][3] ?>
20 | = logs[i][4] ?>
21 |
22 | } ?>
23 |
24 | Created by Ashton Fei .
25 |
26 |
27 |
--------------------------------------------------------------------------------
/projects/GAS089/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1UVDjgf1c-RPH4muTqLeIQz4BNVnoCAG8CGV2weCtXmSmMyS4_0sjSzuE","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS089"}
2 |
--------------------------------------------------------------------------------
/projects/GAS089/app.js:
--------------------------------------------------------------------------------
1 | const onOpen = (e) => _app.onOpen(e)
2 | const doGet = (e) => _api.doGet(e)
3 |
4 |
5 | const include = (filename) => _utils.include(filename)
6 | const openEmailDialog = () => _app.openEmailDialog()
7 | const sendEmail = (payload) => _app.sendEmail(JSON.parse(payload))
8 |
9 | const Utils = class {
10 | constructor() {
11 | }
12 |
13 | /**
14 | * @param {string} filename The name of the html file
15 | * @returns {string} HTML content as string
16 | */
17 | include(filename) {
18 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent()
19 | }
20 |
21 | alert(message, type = "warning") {
22 | const ui = SpreadsheetApp.getUi()
23 | return ui.alert(`${CONFIG.NAME} [${type}]`, message, ui.ButtonSet.OK)
24 | }
25 |
26 | /**
27 | * @param {string} message - the cconfirm message
28 | */
29 | confirm(message) {
30 | const ui = SpreadsheetApp.getUi()
31 | return ui.alert(`${CONFIG.NAME} [confirm]`, message, ui.ButtonSet.YES_NO)
32 | }
33 |
34 | toast(message, timeoutSeconds = 15) {
35 | return SpreadsheetApp.getActive().toast(message, CONFIG.NAME, timeoutSeconds)
36 | }
37 |
38 | render(filename, title, data) {
39 | const template = HtmlService.createTemplateFromFile(filename)
40 | if (data) template = { ...template, ...data }
41 | return template.evaluate().setTitle(title).setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
42 | }
43 |
44 | createJsonResponse(data) {
45 | return ContentService.createTextOutput().setContent(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON)
46 | }
47 |
48 | createKeys(headers) {
49 | return headers.map(v => v.toString().toCamelCase())
50 | }
51 |
52 | getSheetByName(name, createIfNotFound = true) {
53 | const ss = SpreadsheetApp.getActive()
54 | const ws = ss.getSheetByName(name)
55 | if (ws) return ws
56 | if (!createIfNotFound) return ws
57 | return ss.insertSheet(ws)
58 | }
59 |
60 | createItemObject(keys, values) {
61 | const item = {}
62 | keys.forEach((key, index) => item[key] = values[index])
63 | return item
64 | }
65 |
66 | isValidItem(item, filters, partial = true) {
67 | const results = Object.entries(filters).map(([key, value]) => {
68 | if (!(key in item)) return false
69 | return item[key] == value
70 | })
71 | if (partial) return results.indexOf(true) !== -1
72 | return results.indexOf(false) === -1
73 | }
74 |
75 | insertRecord(sheetName, headers, record) {
76 | const keys = this.createKeys(headers)
77 | const values = keys.map((key) => {
78 | if (key in record) return record[key]
79 | return null
80 | })
81 | const ws = this.getSheetByName(sheetName, true)
82 | ws.getRange(1, 1, 1, headers.length).setValues([headers])
83 | const lastRow = ws.getLastRow()
84 | ws.getRange(lastRow + 1, 1, 1, values.length).setValues([values])
85 | return { sheet: ws, item: this.createItemObject(keys, values) }
86 | }
87 |
88 | updateRecord(sheetName, filters, data) {
89 | const ws = this.getSheetByName(sheetName)
90 | if (!ws) return
91 | const [headers, ...records] = ws.getDataRange().getValues()
92 | const keys = this.createKeys(headers)
93 |
94 | const findRecordIndex = records.findIndex((record) => {
95 | const item = this.createItemObject(keys, record)
96 | return this.isValidItem(item, filters, true)
97 | })
98 | if (findRecordIndex === -1) return
99 | const record = records[findRecordIndex]
100 | const newRecord = keys.map((key, index) => {
101 | console.log({ key })
102 | if (key in data) return data[key]
103 | return record[index]
104 | })
105 | console.log(newRecord)
106 | ws.getRange(findRecordIndex + 2, 1, 1, newRecord.length).setValues([newRecord])
107 | return { sheet: ws, item: this.createItemObject(keys, newRecord) }
108 | }
109 | }
110 |
111 | const App = class {
112 | constructor() { }
113 |
114 | openEmailDialog() {
115 | const title = `${CONFIG.NAME} [New Email]`
116 | const htmlOutput = HtmlService.createTemplateFromFile("emailForm.html").evaluate()
117 | htmlOutput.setTitle(title).setWidth(600).setHeight(450)
118 | SpreadsheetApp.getActive().show(htmlOutput)
119 | }
120 |
121 | getSentEmailBySubject(subject) {
122 | const query = `in:sent subject:${subject}`
123 | return GmailApp.search(query, 0, 1)[0]
124 | }
125 |
126 | /**
127 | * @param {GmailApp.GmailThread} thread The sent Gmail thread
128 | */
129 | saveLog(thread, trackingNumber) {
130 | const log = {
131 | to: thread.getMessages()[0].getTo(),
132 | sentAt: thread.getLastMessageDate(),
133 | subject: thread.getFirstMessageSubject(),
134 | threadId: thread.getId(),
135 | permalink: thread.getPermalink(),
136 | trackingNumber: trackingNumber,
137 | opened: false,
138 | openedAt: null
139 | }
140 | const { sheet } = _utils.insertRecord(CONFIG.SHEET_NAME.TRACKING_EMAILS, CONFIG.HEADER.TRACKING_EMAILS, log)
141 | sheet.getRange(`G2:G${sheet.getLastRow()}`).insertCheckboxes()
142 | }
143 |
144 | sendEmail({ to, subject, body }) {
145 | const trackingNumber = Utilities.getUuid()
146 | const trackingUrl = CONFIG.API_URL + "?id=" + trackingNumber
147 | const htmlBody = `
148 | ${body}
149 |
150 |
151 |
152 |
163 | `
164 | GmailApp.sendEmail(to, subject, '', { htmlBody })
165 | const sentEmail = this.getSentEmailBySubject(subject)
166 | if (!sentEmail) _utils.alert("No sent email found!", "Error")
167 | this.saveLog(sentEmail, trackingNumber)
168 | _utils.toast('Tracking email has been sent!')
169 | }
170 |
171 | onOpen(e) {
172 | const ui = SpreadsheetApp.getUi()
173 | ui.createMenu(CONFIG.NAME)
174 | .addItem("Send New Email", 'openEmailDialog')
175 | .addToUi()
176 | }
177 | }
178 |
179 | const Api = class {
180 | constructor() { }
181 | /**
182 | * @param {ScriptApp.EventType} e
183 | */
184 |
185 | updateTrackingStatus(trackingNumber) {
186 | const ss = SpreadsheetApp.getActive()
187 | const ws = ss.getSheetByName(CONFIG.SHEET_NAME.TRACKING_EMAILS)
188 | if (!ws) return
189 | const openedAt = new Date()
190 | return _utils.updateRecord(CONFIG.SHEET_NAME.TRACKING_EMAILS, { trackingNumber }, { opened: true, openedAt, trackingNumber })
191 | }
192 |
193 | doGet(e) {
194 | console.log(e)
195 | const { id } = e.parameter
196 | if (!id) return _utils.createJsonResponse({ error: true, message: "Invalid API query!" })
197 | const result = this.updateTrackingStatus(id)
198 | console.log(result)
199 | if (!result) return _utils.createJsonResponse({ error: false, message: "No tracking number found!" })
200 | return _utils.createJsonResponse({ item: result.item })
201 | }
202 | }
--------------------------------------------------------------------------------
/projects/GAS089/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "webapp": {
7 | "executeAs": "USER_DEPLOYING",
8 | "access": "ANYONE_ANONYMOUS"
9 | }
10 | }
--------------------------------------------------------------------------------
/projects/GAS089/configs.js:
--------------------------------------------------------------------------------
1 | this.CONFIG = {
2 | NAME: 'Gmail Tracker',
3 | ACTIVE_USER: Session.getActiveUser(),
4 | SHEET_NAME: {
5 | TRACKING_EMAILS: "Tracking Emails"
6 | },
7 | HEADER: {
8 | TRACKING_EMAILS: ["Sent At", "To", "Subject", "Thread ID", "Permalink", "Tracking Number", "Opened", "Opened At"],
9 | },
10 | API_URL: "https://script.google.com/macros/s/AKfycbwXxyjKQvc1CYp0sQ92a1OpCeASAcUqtj42QI66NICQkL-dl1CysIX2jh57ZKLwTnk1Yw/exec",
11 | }
12 |
13 | this._app = new App()
14 | this._utils = new Utils()
15 | this._api = new Api()
16 |
17 | String.prototype.toPascalCase = function(){
18 | const words = this.concat().toLowerCase().split(/\s+/).filter(v => v !== "").map(v => v.slice(0, 1).toUpperCase() + v.slice(1))
19 | const pascalCase = words.join("")
20 | return pascalCase
21 | }
22 |
23 | String.prototype.toCamelCase = function(){
24 | const words = this.concat().toLowerCase().split(/\s+/).filter(v => v !== "").map(v => v.slice(0, 1).toUpperCase() + v.slice(1))
25 | const pascalCase = words.join("")
26 | return pascalCase.slice(0,1).toLowerCase() + pascalCase.slice(1)
27 | }
--------------------------------------------------------------------------------
/projects/GAS089/emailForm.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
24 |
25 |
26 |
45 |
46 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/projects/GAS090/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1l5LQTpL6evHbRYCFsTVFYouA44ct1NQoxMjoiH8w78foB53iukLtSNeL","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS090"}
2 |
--------------------------------------------------------------------------------
/projects/GAS090/app.js:
--------------------------------------------------------------------------------
1 | /**
2 | * APP CONFIGURATION
3 | */
4 | this.APP = {
5 | NAME: "Product Entry Form",
6 | SN: {
7 | FORM_DATA: "Product",
8 | RESPONSE: "Products",
9 | },
10 | FN: {
11 | DEFAULT: "Uploads",
12 | },
13 | };
14 |
15 | class Utils {
16 | constructor(name = APP.NAME) {
17 | this.name = name;
18 | this.ss = SpreadsheetApp.getActive();
19 | }
20 |
21 | toPascalCase(text) {
22 | const value = text.toString().toLowerCase();
23 | const words = value.split(/\s+/).filter((v) => v !== "");
24 | return words.map((v) => v.slice(0, 1).toUpperCase() + v.slice(1)).join("");
25 | }
26 |
27 | toCamelCase(text) {
28 | const pascalCase = this.toPascalCase(text);
29 | return pascalCase.slice(0, 1).toLowerCase() + pascalCase.slice(1);
30 | }
31 |
32 | toast(message, timeoutSeconds = 15) {
33 | return this.ss.toast(message, this.name, timeoutSeconds);
34 | }
35 |
36 | alert(message, type = "warning") {
37 | const ui = SpreadsheetApp.getUi();
38 | const title = `${this.name} [${type}]`;
39 | return ui.alert(title, message, ui.ButtonSet.OK);
40 | }
41 |
42 | confirm(message) {
43 | const ui = SpreadsheetApp.getUi();
44 | const title = `${this.name} [confirm]`;
45 | return ui.alert(title, message, ui.ButtonSet.YES_NO);
46 | }
47 |
48 | createKeys(headers, useCamelCase = true) {
49 | return headers.map((header) => {
50 | header = header.replace(/[^a-zA-Z0-9\s]+/gi, "");
51 | return useCamelCase
52 | ? this.toCamelCase(header)
53 | : this.toPascalCase(header);
54 | });
55 | }
56 |
57 | createItemObject(keys, values) {
58 | const item = {};
59 | keys.forEach((key, index) => (item[key] = values[index]));
60 | return item;
61 | }
62 |
63 | getDataFromSheetByName(name) {
64 | const ws = this.ss.getSheetByName(name);
65 | if (!ws) return;
66 | const [headers, ...records] = ws.getDataRange().getValues();
67 | const keys = this.createKeys(headers);
68 | return records.map((record, index) => {
69 | const item = this.createItemObject(keys, record);
70 | item["rowIndex"] = index + 2;
71 | return item;
72 | });
73 | }
74 |
75 | getCurrentFolder() {
76 | const id = this.ss.getId();
77 | const parents = DriveApp.getFileById(id).getParents();
78 | if (parents.hasNext()) return parents.next();
79 | return DriveApp.getRootFolder();
80 | }
81 |
82 | getFolderByName(parentFolder, name, createIfNotFound = true) {
83 | const folders = parentFolder.getFoldersByName(name);
84 | if (folders.hasNext()) return folders.next();
85 | if (createIfNotFound) return parentFolder.createFolder(name);
86 | }
87 | }
88 |
89 | /**
90 | * App Class
91 | */
92 | class App {
93 | constructor() {
94 | this.ss = SpreadsheetApp.getActive();
95 | this.name = APP.NAME;
96 | this.user = Session.getActiveUser().getEmail();
97 | }
98 |
99 | /**
100 | *
101 | * @param {Object} e On Spreadsheet Open Event Object
102 | */
103 | onOpen(e) {
104 | const ui = SpreadsheetApp.getUi();
105 | const menu = ui.createMenu(APP.NAME);
106 | menu.addItem("Run", "run");
107 | menu.addToUi();
108 | }
109 |
110 | createHtmlContent() {
111 | const formData = _utils.getDataFromSheetByName(APP.SN.FORM_DATA);
112 | const template = HtmlService.createTemplateFromFile("html/dialog.html");
113 | template.formData = formData;
114 | template.name = this.name;
115 | template.user = this.user;
116 | return template
117 | .evaluate()
118 | .setTitle(APP.NAME)
119 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
120 | }
121 | run() {
122 | const htmlOuput = this.createHtmlContent();
123 | SpreadsheetApp.getUi().showSidebar(htmlOuput);
124 | }
125 |
126 | doGet() {
127 | return this.createHtmlContent();
128 | }
129 |
130 | createFiles({ files, folderId }) {
131 | let folder = null;
132 | try {
133 | folder = DriveApp.getFolderById(folderId);
134 | } catch (error) {
135 | const currentFolder = _utils.getCurrentFolder();
136 | folder = _utils.getFolderByName(currentFolder, APP.FN.DEFAULT);
137 | }
138 | return files.map(({ data, name, type }) => {
139 | const decodedData = Utilities.base64Decode(data.split("base64,")[1]);
140 | const blob = Utilities.newBlob(decodedData);
141 | blob.setName(name).setContentType(type);
142 | return folder.createFile(blob);
143 | });
144 | }
145 |
146 | submit(payload) {
147 | const headers = ["Timestamp", "UUID", "Created By"];
148 | const uuid = Utilities.getUuid();
149 | const values = [new Date(), uuid, this.user];
150 | payload.forEach((item) => {
151 | headers.push(item.label);
152 | if (item.type === "file") {
153 | const files = this.createFiles(item);
154 | values.push(files.map((v) => v.getUrl()).join("\n"));
155 | } else {
156 | if (Array.isArray(item.default)) {
157 | values.push(JSON.stringify(item.default));
158 | } else {
159 | values.push(item.default);
160 | }
161 | }
162 | });
163 | const ws =
164 | this.ss.getSheetByName(APP.SN.RESPONSE) ||
165 | this.ss.insertSheet(APP.SN.RESPONSE);
166 | ws.getRange(1, 1, 1, headers.length).setValues([headers]);
167 | const lastRow = ws.getLastRow();
168 | ws.getRange(lastRow + 1, 1, 1, values.length).setValues([values]);
169 | return `Item ${uuid} has been created!`;
170 | }
171 | }
172 |
173 | this._utils = new Utils();
174 | this._app = new App();
175 |
176 | const onOpen = (e) => _app.onOpen(e);
177 | const run = () => _app.run();
178 | const doGet = (e) => _app.doGet(e);
179 |
180 | const submit = (payload) => _app.submit(JSON.parse(payload));
181 |
--------------------------------------------------------------------------------
/projects/GAS090/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "webapp": {
7 | "executeAs": "USER_ACCESSING",
8 | "access": "ANYONE"
9 | }
10 | }
--------------------------------------------------------------------------------
/projects/GAS091/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1q3-LfRf1DvBGyh2ywxdVcUgVDV03TLitvbvSLGEK3D4v59FeMso-Y1Ew","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS091"}
2 |
--------------------------------------------------------------------------------
/projects/GAS091/app.js:
--------------------------------------------------------------------------------
1 | this.CONFIG = {
2 | DEBUG: false,
3 | DEBUG_EMAIL: "gas.test.fei@gmail.com",
4 | NAME: "GAS-091",
5 | SUBJECT: 'Share request for',
6 | FROM: "drive-shares-dm-noreply@google.com",
7 | CHECK_FREQUENCY_IN_MINIS: 10, // 1, 5, 10, 15, 30
8 | SHARED_LABEL: "SHARED",
9 | LOGS: "Logs",
10 | FILE_WHITELIST: "File Whitelist",
11 | USER_WHITELIST: "User Whitelist",
12 | PERMISSION: {
13 | READ: "Read",
14 | EDIT: "Edit",
15 | COMMENT: "Comment",
16 | }
17 | }
18 |
19 |
20 | function addDataValidation_(){
21 | const values = Object.entries(CONFIG.PERMISSION).map(([,value]) => value)
22 | const validation = SpreadsheetApp.newDataValidation().requireValueInList(values).build()
23 | const ss = SpreadsheetApp.getActive()
24 | const wsFileWhitelist = ss.getSheetByName(CONFIG.FILE_WHITELIST)
25 | if (wsFileWhitelist) wsFileWhitelist.getRange(`B2:B${wsFileWhitelist.getLastRow()}`).setDataValidation(validation)
26 | const wsUserWhitelist = ss.getSheetByName(CONFIG.USER_WHITELIST)
27 | if (wsUserWhitelist) wsUserWhitelist.getRange(`B2:B${wsUserWhitelist.getLastRow()}`).setDataValidation(validation)
28 | }
29 |
30 | function addFilesToWhitelist() {
31 | const ui = SpreadsheetApp.getUi()
32 | const ss = SpreadsheetApp.getActive()
33 | const propmt = ui.prompt(CONFIG.NAME, "Enter a folder ID to add files to whitelist:", ui.ButtonSet.OK_CANCEL)
34 | if (propmt.getSelectedButton() !== ui.Button.OK) return ss.toast("Cancelled!", CONFIG.NAME)
35 | const id = propmt.getResponseText().trim()
36 | if (!id) return ss.toast("Folder id is required!", CONFIG.NAME)
37 | try {
38 | const folder = DriveApp.getFolderById(id)
39 | const folderName = folder.getName()
40 | const folderUrl = folder.getUrl()
41 | const files = folder.getFiles()
42 | const newFiles = []
43 | while (files.hasNext()) {
44 | const file = files.next()
45 | newFiles.push([
46 | file.getId(),
47 | CONFIG.PERMISSION.READ,
48 | file.getName(),
49 | file.getUrl(),
50 | id,
51 | folderName,
52 | folderUrl,
53 | ])
54 | }
55 | if (newFiles.length === 0) return ss.toast("No files in the folder!", CONFIG.NAME)
56 | const ws = ss.getSheetByName(CONFIG.FILE_WHITELIST) || ss.insertSheet(CONFIG.FILE_WHITELIST)
57 | ws.getRange("1:1").clear()
58 | const headers = ["ID *", "Permission *", "Name", "URL", "Folder ID", "Folder Name", "Folder URL"]
59 | ws.getRange(1, 1, 1, headers.length).setValues([headers])
60 | ws.getRange(ws.getLastRow() + 1, 1, newFiles.length, newFiles[0].length).setValues(newFiles)
61 | addDataValidation_()
62 | ws.activate()
63 | ss.toast("New files have been added.")
64 | } catch (error) {
65 | return ss.toast(error.message, CONFIG.NAME)
66 | }
67 | }
68 |
69 | function getFileWhitelist_() {
70 | const ws = SpreadsheetApp.getActive().getSheetByName(CONFIG.FILE_WHITELIST)
71 | const [, ...items] = ws.getDataRange().getDisplayValues()
72 | const whitelist = {}
73 | items.forEach(([id, permission]) => whitelist[id] = { id, permission })
74 | return whitelist
75 | }
76 |
77 | function getUserWhitelist_() {
78 | const ws = SpreadsheetApp.getActive().getSheetByName(CONFIG.USER_WHITELIST)
79 | const [, ...items] = ws.getDataRange().getDisplayValues()
80 | const whitelist = {}
81 | items.forEach(([email, permission]) => whitelist[email] = { email, permission })
82 | return whitelist
83 | }
84 |
85 | function getSharedLabel_() {
86 | const label = GmailApp.getUserLabelByName(CONFIG.SHARED_LABEL)
87 | if (label) return label
88 | return GmailApp.createLabel(CONFIG.SHARED_LABEL)
89 | }
90 |
91 | /**
92 | * @param {GmailApp.GmailThread} thread
93 | * @param {GmailApp.GmailLabel} label
94 | */
95 | function isLableApplied_(thread, label) {
96 | return thread.getLabels().some(v => v == label)
97 | }
98 |
99 | function searchForShareRequests_(label) {
100 | const query = `subject:${CONFIG.SUBJECT} from:${CONFIG.FROM} newer_than:1d`
101 | console.log(query)
102 | return GmailApp.search(query).filter(v => !isLableApplied_(v, label))
103 | }
104 |
105 |
106 | function parseEmailBody_(body) {
107 | const [email, , url] = body.split("\n").filter(v => v.trim() !== "")
108 | return {
109 | email: email.split(" ")[0],
110 | id: url.split("/d/")[1].split("/")[0]
111 | }
112 | }
113 |
114 | /**
115 | * @param {GmailApp.GmailThread} thread
116 | */
117 | function shareFile_(thread, label, fileWhitelist, userWhitelist) {
118 | const message = thread.getMessages()[0]
119 | const subject = message.getSubject()
120 | const body = message.getPlainBody()
121 | const { email, id } = parseEmailBody_(body)
122 | if (!(email && id)) return [new Date(), subject, body, email, id, "Failed", null, "Email or ID not found in the email body!"]
123 | try {
124 | if (!(id in fileWhitelist)) return [new Date(), subject, body, email, id, "Failed", null, "File not in the whitelist!"]
125 | if (!(email in userWhitelist)) return [new Date(), subject, body, email, id, "Faild", null, "Email not in the whitelist!"]
126 | const file = DriveApp.getFileById(id)
127 | const permission = userWhitelist[email].permission || fileWhitelist[id].permission
128 | if (permission === CONFIG.PERMISSION.EDIT) {
129 | file.addEditor(CONFIG.DEBUG ? CONFIG.DEBUG_EMAIL : email)
130 | } else if (permission === CONFIG.PERMISSION.COMMENT) {
131 | file.addCommenter(CONFIG.DEBUG ? CONFIG.DEBUG_EMAIL : email)
132 | } else {
133 | file.addViewer(CONFIG.DEBUG ? CONFIG.DEBUG_EMAIL : email)
134 | }
135 | thread.addLabel(label)
136 | return [new Date(), subject, body, email, id, "Shared", permission, "Success"]
137 | } catch (error) {
138 | return [new Date(), subject, body, email, id, "Failed", null, error.message]
139 | }
140 | }
141 |
142 | function addLogs_(logs) {
143 | const headers = ["Timestamp", 'Subject', "Body", "Email", "ID", "Status", "Permission", "Notes"]
144 | const ss = SpreadsheetApp.getActive()
145 | const ws = ss.getSheetByName(CONFIG.LOGS) || ss.insertSheet(CONFIG.LOGS)
146 | ws.getRange("1:1").clear()
147 | ws.getRange(1, 1, 1, headers.length).setValues([headers])
148 | ws.getRange(ws.getLastRow() + 1, 1, logs.length, logs[0].length).setValues(logs)
149 | }
150 |
151 | function checkFileShareRequests() {
152 | const label = getSharedLabel_()
153 | const threads = searchForShareRequests_(label)
154 | const fileWhitelist = getFileWhitelist_()
155 | const userWhitelist = getUserWhitelist_()
156 | const logs = []
157 | threads.forEach(thread => {
158 | logs.push(shareFile_(thread, label, fileWhitelist, userWhitelist))
159 | })
160 | if (logs.length) {
161 | addLogs_(logs)
162 | }
163 | }
164 |
165 | function createTrigger(){
166 | const ui = SpreadsheetApp.getUi()
167 | const ss = SpreadsheetApp.getActive()
168 | const confirm = ui.alert(`${CONFIG.NAME} [confirm]`, "Are you sure to create a trigger to check the file sharing requests?", ui.ButtonSet.YES_NO)
169 | if (confirm !== ui.Button.YES) return ss.toast("Cancelled!", CONFIG.NAME)
170 | const triggers = ScriptApp.getScriptTriggers()
171 | triggers.forEach(trigger => ScriptApp.deleteTrigger(trigger))
172 | ScriptApp.newTrigger("checkFileShareRequests")
173 | .timeBased()
174 | .everyMinutes(CONFIG.CHECK_FREQUENCY_IN_MINIS)
175 | .create()
176 | }
177 |
178 | function onOpen() {
179 | const ui = SpreadsheetApp.getUi()
180 | const menu = ui.createMenu(CONFIG.NAME)
181 | menu.addItem("Add files to whitelist", "addFilesToWhitelist")
182 | menu.addItem("Check file share request", "checkFileShareRequests")
183 | menu.addItem("Create trigger", "createTrigger")
184 | menu.addToUi()
185 | }
--------------------------------------------------------------------------------
/projects/GAS091/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS092/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1bRV8ySFoVE625WcACei7euDdorFmu-YQuz6Vu0IMEfDs7XiQFQcy0YCK","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS092"}
2 |
--------------------------------------------------------------------------------
/projects/GAS092/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "Tasks",
7 | "version": "v1",
8 | "serviceId": "tasks"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8"
14 | }
--------------------------------------------------------------------------------
/projects/GAS093/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1YZu199d40gn8vG8TlNj38WohcrFdPcpZaqk9gegG5XV4WfJNAk--Liac"}
2 |
--------------------------------------------------------------------------------
/projects/GAS093/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "Calendar",
7 | "version": "v3",
8 | "serviceId": "calendar"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8"
14 | }
--------------------------------------------------------------------------------
/projects/GAS094/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1WgXBWHu8sHfG4Mp88asH2Sg9x03wpEpBt9czlWP39fndqBNIlgh9WwOf","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS094"}
2 |
--------------------------------------------------------------------------------
/projects/GAS094/app.js:
--------------------------------------------------------------------------------
1 | class Form {
2 | constructor() {
3 | this.name = "💌 Mailman";
4 | this.triggerFunctionName = "onSubmit";
5 | this.form = FormApp.getActiveForm();
6 | this.props = PropertiesService.getUserProperties();
7 | this.user = Session.getActiveUser().getEmail();
8 | this.key = {
9 | settings: "mailman.settings",
10 | emails: "mailman.emails",
11 | };
12 | }
13 |
14 | getUi() {
15 | return FormApp.getUi();
16 | }
17 |
18 | alert(message, title = this.name) {
19 | const ui = this.getUi();
20 | ui.alert(title, message, ui.ButtonSet.OK);
21 | }
22 |
23 | onOpen(e) {
24 | const ui = FormApp.getUi();
25 | const menu = ui.createMenu(this.name);
26 | menu.addItem("Settings", "openSettings");
27 | menu.addToUi();
28 | }
29 |
30 | createTrigger() {
31 | ScriptApp.getProjectTriggers().forEach((trigger) =>
32 | ScriptApp.deleteTrigger(trigger)
33 | );
34 | ScriptApp.newTrigger(this.triggerFunctionName)
35 | .forForm(this.form)
36 | .onFormSubmit()
37 | .create();
38 | }
39 |
40 | /**
41 | * @param {FormApp.FormResponse} response
42 | */
43 | getResponseItem(response) {
44 | // response = this.form.getResponses()[0]
45 | const item = {};
46 | response.getItemResponses().map((v) => {
47 | const title = v.getItem().getTitle();
48 | const value = v.getResponse();
49 | item[title] = value;
50 | });
51 | item.url_ = this.form.getPublishedUrl();
52 | item.id_ = response.getId();
53 | item.editUrl_ = response.getEditResponseUrl();
54 | item.prefilledUrl_ = response.toPrefilledUrl();
55 | item.email_ = response.getRespondentEmail();
56 | return item;
57 | }
58 |
59 | replacePlaceholders(text, placeholders) {
60 | Object.entries(placeholders).forEach(([key, value]) => {
61 | if (Array.isArray(value)) value = value.join(", ");
62 | text = text.replace(new RegExp(`\{\{${key}\}\}`, "gi"), value);
63 | });
64 | return text;
65 | }
66 |
67 | sendEmails(item) {
68 | const settings = this.getSettings();
69 | const emails = this.getEmails();
70 | const recipientNames = item[settings.mailman.title]
71 | ? typeof item[settings.mailman.title] == "string"
72 | ? [item[settings.mailman.title]]
73 | : item[settings.mailman.title]
74 | : [];
75 | const cc = settings.cc;
76 | const bcc = settings.bcc;
77 |
78 | recipientNames.forEach((name) => {
79 | const recipient = emails[name];
80 | item[settings.mailman.title] = name;
81 | const subject = this.replacePlaceholders(settings.subject, item);
82 | const body = this.replacePlaceholders(settings.body, item).replace(
83 | /\n/g,
84 | " "
85 | );
86 | const options = {
87 | htmlBody: `${body}
`,
88 | cc,
89 | bcc,
90 | };
91 | if (recipient) {
92 | GmailApp.sendEmail(recipient, subject, "", options);
93 | }
94 | });
95 | }
96 | /**
97 | * @param {Object} e
98 | * @param {FormApp.FormResponse} e.response
99 | */
100 | onSubmit(e = {}) {
101 | const response = e.response;
102 | if (!response) return;
103 | const item = this.getResponseItem(response);
104 | this.sendEmails(item);
105 | }
106 |
107 | getFormItems() {
108 | return this.form.getItems().map((item) => {
109 | const type = item.getType();
110 | let items = null;
111 | switch (type) {
112 | case FormApp.ItemType.LIST:
113 | items = item
114 | .asListItem()
115 | .getChoices()
116 | .map((v) => v.getValue());
117 | break;
118 | case FormApp.ItemType.MULTIPLE_CHOICE:
119 | items = item
120 | .asMultipleChoiceItem()
121 | .getChoices()
122 | .map((v) => v.getValue());
123 | break;
124 | case FormApp.ItemType.CHECKBOX:
125 | items = item
126 | .asCheckboxItem()
127 | .getChoices()
128 | .map((v) => v.getValue());
129 | break;
130 | }
131 | const data = {
132 | text: item.getTitle(),
133 | value: {
134 | id: item.getId(),
135 | type,
136 | items,
137 | title: item.getTitle(),
138 | },
139 | };
140 | return data;
141 | });
142 | }
143 |
144 | getSettings() {
145 | const settings = this.props.getProperty(this.key.settings);
146 | if (!settings)
147 | return {
148 | mailman: null,
149 | cc: null,
150 | bcc: null,
151 | subject: null,
152 | body: null,
153 | };
154 | return JSON.parse(settings);
155 | }
156 |
157 | getEmails() {
158 | const emails = this.props.getProperty(this.key.emails);
159 | if (!emails) return {};
160 | return JSON.parse(emails);
161 | }
162 |
163 | isMailmanEnabled() {
164 | const triggers = ScriptApp.getProjectTriggers();
165 | const trigger = triggers.find(
166 | (trigger) => trigger.getHandlerFunction() == this.triggerFunctionName
167 | );
168 | return trigger ? true : false;
169 | }
170 |
171 | saveSettings({ settings, emails }) {
172 | this.props.setProperty(this.key.settings, JSON.stringify(settings));
173 | this.props.setProperty(this.key.emails, JSON.stringify(emails));
174 | return { settings: this.getSettings(), emails: this.getEmails() };
175 | }
176 |
177 | getAppData() {
178 | const appData = {
179 | settings: this.getSettings(),
180 | emails: this.getEmails(),
181 | formItems: this.getFormItems(),
182 | enabled: this.isMailmanEnabled(),
183 | };
184 | return appData;
185 | }
186 |
187 | openSettings() {
188 | const name = `Settings`;
189 | const template = HtmlService.createTemplateFromFile("settings.html");
190 | template.appData = JSON.stringify(this.getAppData());
191 | const ui = this.getUi();
192 | const userInterface = template
193 | .evaluate()
194 | .setTitle(name)
195 | .setHeight(700)
196 | .setWidth(600);
197 | ui.showDialog(userInterface);
198 | }
199 |
200 | toggleMailman({ toggle }) {
201 | if (toggle) {
202 | this.createTrigger();
203 | } else {
204 | ScriptApp.getProjectTriggers().forEach((trigger) =>
205 | ScriptApp.deleteTrigger(trigger)
206 | );
207 | }
208 | }
209 |
210 | sendTestEmail({ settings }) {
211 | const options = {
212 | htmlBody: settings.body,
213 | cc: settings.cc,
214 | bcc: settings.bcc,
215 | };
216 | GmailApp.sendEmail(this.user, settings.subject, "", options);
217 | }
218 |
219 | openHelp() {
220 | this.alert("Help");
221 | }
222 | }
223 |
224 | const onOpen = (e) => new Form().onOpen(e);
225 | const onSubmit = (e) => new Form().onSubmit(e);
226 | const openSettings = () => new Form().openSettings();
227 | const saveSettings = (payload) =>
228 | JSON.stringify(new Form().saveSettings(JSON.parse(payload)));
229 | const sendTestEmail = (payload) =>
230 | new Form().sendTestEmail(JSON.parse(payload));
231 | const openHelp = () => new Form().openHelp();
232 |
233 | const toggleMailman = (payload) =>
234 | new Form().toggleMailman(JSON.parse(payload));
235 | const getAppData = () => JSON.stringify(new Form().getAppData());
236 |
--------------------------------------------------------------------------------
/projects/GAS094/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS095/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1qe1MO72RFbOIYt6JRbfSi52SrEAkvy89-v1dInfnRTbEWuGq_0den_cc","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS095"}
2 |
--------------------------------------------------------------------------------
/projects/GAS095/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS096/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1HVvQvTh4GBI6S2dvPHuSjvdzp0E51I91NZYRDPFcQIX2xFm1uQ1F5pbR","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS096"}
2 |
--------------------------------------------------------------------------------
/projects/GAS096/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "Drive",
7 | "version": "v2",
8 | "serviceId": "drive"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8",
14 | "webapp": {
15 | "executeAs": "USER_DEPLOYING",
16 | "access": "ANYONE_ANONYMOUS"
17 | }
18 | }
--------------------------------------------------------------------------------
/projects/GAS097/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1dcu-JJ9wrJUQQLdaFvvBcYauLXlg9BGm9lACxsPVKJi6jvlSWFaK-m_h","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS097"}
2 |
--------------------------------------------------------------------------------
/projects/GAS097/app.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | APP_NAME: "💮 Multiple Select",
3 | DELIMITER: ","
4 | }
5 |
6 | class App {
7 | constructor(delimiter = ",") {
8 | this.name = CONFIG.APP_NAME
9 | this.ss = SpreadsheetApp.getActive()
10 | this.delimiter = CONFIG.DELIMITER
11 | }
12 |
13 | getUi() {
14 | return SpreadsheetApp.getUi()
15 | }
16 |
17 | onOpen(e) {
18 | const ui = this.getUi()
19 | const menu = ui.createMenu(this.name)
20 | menu.addItem("Open", "openSidebar")
21 | menu.addToUi()
22 | }
23 |
24 | getAppData() {
25 | const activeCell = this.ss.getActiveCell()
26 | const values = activeCell.getValue().toString().split(this.delimiter).filter(v => v !== "")
27 | const dataValidation = activeCell.getDataValidation()
28 | let items = []
29 | if (dataValidation) {
30 | const type = dataValidation.getCriteriaType().toString()
31 | if (type === "VALUE_IN_LIST") {
32 | items = [... new Set(dataValidation.getCriteriaValues()[0].filter(v => !v.includes(this.delimiter)))]
33 | } else if (type === "VALUE_IN_RANGE") {
34 | items = [...new Set(dataValidation.getCriteriaValues()[0].getValues().map(v => v[0]).filter(v => !v.includes(this.delimiter)))]
35 | }
36 | }
37 | items.sort()
38 | return {
39 | items,
40 | values,
41 | rangeName: activeCell.getA1Notation(),
42 | sheetName: activeCell.getSheet().getName(),
43 | }
44 | }
45 |
46 | openSidebar() {
47 | const template = HtmlService.createTemplateFromFile('sidebar.html')
48 | template.appData = JSON.stringify(this.getAppData())
49 | this.getUi().showSidebar(template.evaluate().setTitle(this.name))
50 | }
51 |
52 | toast(msg, title = this.name, timeout = 15){
53 | return this.ss.toast(msg, title, timeout)
54 | }
55 | }
56 |
57 | class MultipleSelectApp extends App {
58 | constructor() {
59 | super()
60 | }
61 |
62 | getValues(){
63 | const data = this.getAppData()
64 | return data
65 | }
66 |
67 | setValues(payload){
68 | let {values, items, rangeName, sheetName} = JSON.parse(payload)
69 | const value = values.join(this.delimiter)
70 | if (values.length) {
71 | items = [...new Set([...items, ...values])]
72 | items.sort()
73 | items.push(values.join(this.delimiter))
74 | }
75 |
76 | const activeRangeList = this.ss.getSelection().getActiveRangeList()
77 | let ranges = activeRangeList.getRanges()
78 | if (rangeName !== 'Selected Ranges') {
79 | const range = this.ss.getSheetByName(sheetName).getRange(rangeName)
80 | ranges = [range]
81 | }
82 |
83 | const dataValidation = SpreadsheetApp.newDataValidation()
84 | .requireValueInList(items)
85 | .build()
86 |
87 | ranges.forEach(range => {
88 | range.setDataValidation(dataValidation)
89 | range.setValue(value)
90 | range.activate()
91 | })
92 | if (rangeName === "Selected Ranges") {
93 | activeRangeList.activate()
94 | }
95 | const addresses = ranges.map(v => v.getA1Notation()).join(",")
96 | SpreadsheetApp.flush()
97 | this.toast(`Updated for "${addresses}"!`)
98 | return this.getValues()
99 | }
100 |
101 | clearValues(){
102 | const activeRangeList = this.ss.getSelection().getActiveRangeList()
103 | activeRangeList.getRanges().forEach(range => {
104 | range.clearContent()
105 | range.clearDataValidations()
106 | })
107 | this.toast(`Values and validations removed!`)
108 | }
109 | }
110 |
111 | const onOpen = (e) => new App().onOpen(e)
112 | const openSidebar = () => new App().openSidebar()
113 | const getValues = () => JSON.stringify(new MultipleSelectApp().getValues())
114 | const setValues = (payload) => JSON.stringify(new MultipleSelectApp().setValues(payload))
115 | const clearValues = () => new MultipleSelectApp().clearValues()
116 |
117 |
--------------------------------------------------------------------------------
/projects/GAS097/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS097/readme.md:
--------------------------------------------------------------------------------
1 | # GAS-097 Multiple Select in Google Sheets
2 |
3 | ### Description
4 | A sidebar app for Google Sheets to enable multiple select
5 | [
6 | ](https://youtu.be/et_yAekJDf0)
--------------------------------------------------------------------------------
/projects/GAS098/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"19pUr_G9VresyTRDFnq_EQZAr2gpsjlWL0JddAd7NjtakPuzWWs4-bxKh","rootDir":"/Users/ashton/youtube/google-apps-script-projects/GAS098"}
2 |
--------------------------------------------------------------------------------
/projects/GAS098/Code.js:
--------------------------------------------------------------------------------
1 | class Maze {
2 | constructor(
3 | { rows, columns, size, color, nextColor, bgColor, refreshPerCells, start, ws }
4 | ) {
5 | this.rows = rows || 10
6 | this.columns = columns || 10
7 | this.size = size || 21
8 | this.color = color || "#FFF3F3"
9 | this.nextColor = nextColor || "#5383EC"
10 | this.bgColor = bgColor || "#F3F3F3"
11 | this.refreshPerCells = refreshPerCells || 10
12 | this.start = start || {row: 2, columns: 2}
13 | this.cells = null
14 | this.ws = ws
15 | this.walls = []
16 | this.init()
17 | }
18 |
19 | init() {
20 | this.cells = Array(this.rows).fill().map((_, x) => {
21 | return Array(this.columns).fill().map((_, y) => ({
22 | x,
23 | y,
24 | color: 0
25 | }))
26 | })
27 | const requiredRows = this.rows + this.start.row + 2
28 | const requiredColumns = this.columns + this.start.column + 2
29 |
30 | if (requiredRows > this.ws.getMaxRows()) {
31 | this.ws.insertRows(this.ws.getMaxRows(), requiredRows - this.ws.getMaxRows())
32 | }
33 | if (requiredColumns > this.ws.getMaxColumns()) {
34 | this.ws.insertColumns(this.ws.getMaxColumns(), requiredColumns - this.ws.getMaxColumns())
35 | }
36 | this.ws.setRowHeights(this.start.row, this.rows + 2, this.size)
37 | this.ws.setColumnWidths(this.start.column, this.columns + 2, this.size)
38 | return this
39 | }
40 |
41 | ramdonIndex(length) {
42 | return Math.floor(Math.random() * length)
43 | }
44 |
45 | updateMaze() {
46 | const bgColors = this.cells.map(v => {
47 | return [
48 | this.bgColor,
49 | ...v.map(v => {
50 | if (v.color === 1) return this.color
51 | if (v.color === 0) return this.bgColor
52 | if (v.color === -1) return this.nextColor
53 | }),
54 | this.bgColor]
55 | })
56 | bgColors.unshift(Array(this.columns + 2).fill(this.bgColor))
57 | bgColors.push(Array(this.columns + 2).fill(this.bgColor))
58 | this.ws.getRange(this.start.row, this.start.column, bgColors.length, bgColors[0].length).setBackgrounds(bgColors)
59 | return this
60 | }
61 |
62 | updateWalls() {
63 | const walls = []
64 | this.cells.forEach((cellsInRow, x) => {
65 | cellsInRow.forEach((cell, y) => {
66 | if (cell.color === 1) return
67 | let count = 0
68 | const topCell = x - 1 >= 0 ? this.cells[x - 1][y] : {}
69 | if (topCell.color === 1) count++
70 | const rightCell = y + 1 < this.columns ? this.cells[x][y + 1] : {}
71 | if (rightCell.color === 1) count++
72 | const bottomCell = x + 1 < this.rows ? this.cells[x + 1][y] : {}
73 | if (bottomCell.color === 1) count++
74 | const leftCell = y - 1 >= 0 ? this.cells[x][y - 1] : {}
75 | if (leftCell.color === 1) count++
76 | if (count === 1) {
77 | cell.color = -1
78 | walls.push(cell)
79 | } else {
80 | cell.color = 0
81 | }
82 | })
83 | })
84 | this.walls = walls
85 | return this
86 | }
87 |
88 | build() {
89 | const startCell = this.cells[this.ramdonIndex(this.rows)][this.ramdonIndex(this.columns)]
90 | startCell.color = 1
91 | this.updateWalls().updateMaze()
92 | let count = 0
93 | // SpreadsheetApp.flush()
94 | while (this.walls.length) {
95 | let nextCellIndex = this.ramdonIndex(this.walls.length)
96 | let nextCell = this.walls[nextCellIndex]
97 | nextCell.color = 1
98 | this.updateWalls()
99 | // this.updateMaze()
100 | // SpreadsheetApp.flush()
101 | if (count % this.refreshPerCells === 0) this.updateMaze()
102 | count++
103 | }
104 | return this.updateMaze()
105 | }
106 | }
107 |
108 | class App {
109 | constructor() {
110 | this.name = "🧩 Maze"
111 | this.ss = SpreadsheetApp.getActive()
112 | this.sheetName = {
113 | maze: "🧩 Maze",
114 | settings: "⚙️ Settings"
115 | }
116 | }
117 |
118 | getUi() {
119 | return SpreadsheetApp.getUi()
120 | }
121 |
122 | toast(msg, title = this.name, timeout = 12) {
123 | return this.ss.toast(msg, title, timeout)
124 | }
125 |
126 | randomHexColor(exceptions = null) {
127 | function toHexString(number) {
128 | const hexString = (number).toString(16)
129 | return `0${hexString}`.slice(-2)
130 | }
131 | const red = toHexString(Math.floor(Math.random() * 256))
132 | const green = toHexString(Math.floor(Math.random() * 256))
133 | const blue = toHexString(Math.floor(Math.random() * 256))
134 | const color = `#${red}${green}${blue}`.toUpperCase()
135 | if (!exceptions) return color
136 | if (exceptions.includes(color)) return randomHexColor(exceptions)
137 | return color
138 | }
139 |
140 | onOpen() {
141 | const ui = this.getUi();
142 | ui.createMenu(this.name)
143 | .addItem("Create", "createMaze")
144 | .addToUi()
145 | }
146 |
147 | getSettings() {
148 | const data = {}
149 | const ws = this.ss.getSheetByName(this.sheetName.settings)
150 | if (!ws) return data
151 | const dataRange = ws.getDataRange()
152 | const colors = dataRange.getBackgrounds().slice(1)
153 | ws.getDataRange().getValues().slice(1)
154 | .forEach(([key, value], index) => {
155 | if (/color/gi.test(key)) {
156 | data[key] = value || colors[index][1].toUpperCase()
157 | } else {
158 | data[key] = value
159 | }
160 | })
161 | data.start = {
162 | row: data.startRow,
163 | column: data.startColumn
164 | }
165 | data.matrix = {
166 | rows: data.matrixRows,
167 | columns: data.matrixColumns,
168 | color: data.matrixColor,
169 | colors: data.matrixColor.includes(",") ? data.matrixColor.split(/\s*,\s*/) : null
170 | }
171 | return data
172 | }
173 |
174 | createMaze() {
175 | const settings = this.getSettings()
176 | const ws = this.ss.getSheetByName(this.sheetName.maze) || this.ss.insertSheet(this.sheetName.maze);
177 | settings.ws = ws
178 | ws.clear()
179 | ws.activate()
180 | const originalStartColumn = settings.start.column
181 | let count = 0
182 | for (let i = 0; i < settings.matrix.rows; i ++) {
183 | for (let j = 0; j < settings.matrix.columns; j ++) {
184 | if (/^#[A-Z0-9]{6}$/.test(settings.matrix.color)) {
185 | settings.color = settings.matrix.color
186 | } else if (settings.matrix.colors) {
187 | settings.color = settings.matrix.colors[count] || this.randomHexColor([settings.bgColor, settings.nextColor, settings.color])
188 | } else {
189 | settings.color = this.randomHexColor([settings.bgColor, settings.nextColor, settings.color])
190 | }
191 | const maze = new Maze(settings)
192 | maze.build()
193 | settings.start.column += settings.columns + 3
194 | count ++
195 | }
196 | settings.start.row += settings.rows + 3
197 | settings.start.column = originalStartColumn
198 | }
199 | this.toast("Done!")
200 | }
201 | }
202 |
203 | const onOpen = e => new App().onOpen(e)
204 | const createMaze = () => new App().createMaze()
205 |
206 |
--------------------------------------------------------------------------------
/projects/GAS098/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS098/readme.md:
--------------------------------------------------------------------------------
1 | # GAS-098 Maze Generator
2 |
3 | ### Description
4 | A maze generator in Google Sheets build with Apps Script and [Randomized Prim's algorithm](https://en.wikipedia.org/wiki/Maze_generation_algorithm).
5 | [](https://youtu.be/EwgH-7BnOZ0)
--------------------------------------------------------------------------------
/projects/GAS099/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1a6gEJypQbo8_FKBBwglRLboQS0_qZZyNJCqUZIVPBq-T_LIJJHQsqxfp",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS099/0.utils.js:
--------------------------------------------------------------------------------
1 | function _toCamelCase_(text) {
2 | const words = text
3 | .toString()
4 | .trim()
5 | .toLowerCase()
6 | .split(/\s*[-_\*\s]+\s*/);
7 | return words
8 | .filter((v) => v)
9 | .map((v, i) => (i === 0 ? v : v.charAt(0).toUpperCase() + v.slice(1)))
10 | .join("");
11 | }
12 |
13 | function _getUi_() {
14 | return SpreadsheetApp.getUi();
15 | }
16 |
17 | function _alert_(message, title) {
18 | const ui = _getUi_();
19 | return ui.alert(title, message, ui.ButtonSet.OK);
20 | }
21 |
22 | function _confirm_(message, title) {
23 | const ui = _getUi_();
24 | return ui.alert(title, message, ui.ButtonSet.YES_NO);
25 | }
26 |
27 | function _prompt_(message, title) {
28 | const ui = _getUi_();
29 | return ui.prompt(title, message, ui.ButtonSet.OK_CANCEL);
30 | }
31 |
32 | function _toast_(message, title, timeOutSecond = 5) {
33 | return SpreadsheetApp.getActive().toast(message, title, timeOutSecond);
34 | }
35 |
36 | function _getSheetById_(id) {
37 | return SpreadsheetApp.getActive()
38 | .getSheets()
39 | .find((sheet) => sheet.getSheetId() == id);
40 | }
41 |
42 | function _getSettings_(sheetId = 0) {
43 | const settings = {};
44 | const sheet = _getSheetById_(sheetId);
45 | sheet
46 | .getDataRange()
47 | .getValues()
48 | .slice(1)
49 | .forEach(([key, value]) => {
50 | key = key.toString().trim();
51 | if (/^\[.*\]$/.test(key)) return;
52 | key = _toCamelCase_(key);
53 | settings[key] = value;
54 | });
55 | return settings;
56 | }
57 |
58 | function _getNestedValueFromObject_(item, path) {
59 | path.split(".").forEach((k) => (item = item?.[k]));
60 | return item;
61 | }
62 |
63 | function _getNextSyncToken_(id) {
64 | return Calendar.Events.list(id, { maxResult: 1 }).nextSyncToken;
65 | }
66 |
67 | function _getLastestEvent_(id, syncToken) {
68 | let res = null;
69 | try {
70 | res = Calendar.Events.list(id, { syncToken });
71 | } catch (error) {
72 | syncToken = _getNextSyncToken_(id);
73 | res = Calendar.Events.list(id, { syncToken });
74 | }
75 | return {
76 | nextSyncToken: res.nextSyncToken,
77 | event: res.items[0],
78 | };
79 | }
80 |
81 | function _createQueryString_(queryObject) {
82 | return Object.entries(queryObject)
83 | .map(([key, value]) => `${key}=${value}`)
84 | .join("&");
85 | }
86 |
87 | function _exportSheetAsPdf_(
88 | sheetId,
89 | spreadsheetId = SpreadsheetApp.getActive().getId(),
90 | { size, portrait }
91 | ) {
92 | size =
93 | [
94 | "Letter",
95 | "Tabloid",
96 | "Legal",
97 | "Statement",
98 | "Executive",
99 | "Folio",
100 | "A3",
101 | "A4",
102 | "B4",
103 | "B5",
104 | ].indexOf(size) || 7;
105 | const queryObject = {
106 | format: "pdf",
107 | fzr: true,
108 | size,
109 | portrait,
110 | gid: sheetId,
111 | gridlines: false,
112 | download: true,
113 | };
114 | const queryString = _createQueryString_(queryObject);
115 | const url = `https://docs.google.com/spreadsheets/d/${spreadsheetId}/export?${queryString}`;
116 | const token = ScriptApp.getOAuthToken();
117 | return UrlFetchApp.fetch(url, {
118 | headers: { Authorization: `Bearer ${token}` },
119 | }).getBlob();
120 | }
121 |
122 | function _getErrorMessage_(error) {
123 | return error.stack
124 | ? error.stack.split("\n").slice(0, 2).join("\n")
125 | : error.message;
126 | }
127 |
128 | function _tryFunction_(functionName, title = "Script") {
129 | try {
130 | functionName();
131 | } catch (error) {
132 | const msg = _getErrorMessage_(error);
133 | _toast_(msg, title, 0.01);
134 | return _alert_(msg, title);
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/projects/GAS099/2.api.js:
--------------------------------------------------------------------------------
1 | const apiGetAppData = () => {
2 | let appData = PropertiesService.getUserProperties().getProperty(
3 | CONFIG.KEY.APP_DATA
4 | );
5 | appData = appData
6 | ? JSON.parse(appData)
7 | : { isWeeklyOn: false, isMonthlyOn: false, isYearlyOn: false };
8 | const data = {
9 | ..._getSettings_(),
10 | calendars: getMyCalendars_(),
11 | ...appData,
12 | };
13 | data.name = data.appName;
14 | return JSON.stringify(data);
15 | };
16 |
17 | const apiAddToTracker = (payload) => {
18 | const { calendars, isWeeklyOn, isMonthlyOn, isYearlyOn } =
19 | JSON.parse(payload);
20 | if (!calendars) {
21 | throw new Error("No calendars in the request.");
22 | }
23 | deleteAllTriggers_();
24 |
25 | createReportTriggers_({ isWeeklyOn, isMonthlyOn, isYearlyOn });
26 | const props = PropertiesService.getUserProperties();
27 | props.setProperty(
28 | CONFIG.KEY.APP_DATA,
29 | JSON.stringify({
30 | isWeeklyOn,
31 | isMonthlyOn,
32 | isYearlyOn,
33 | })
34 | );
35 |
36 | const functionName = CONFIG.TRIGGER.CALENDAR;
37 | calendars.forEach((calendarId) => {
38 | const trigger = ScriptApp.newTrigger(functionName)
39 | .forUserCalendar(calendarId)
40 | .onEventUpdated()
41 | .create();
42 | const data = {
43 | triggerId: trigger.getUniqueId(),
44 | syncToken: _getNextSyncToken_(calendarId),
45 | };
46 | props.setProperty(calendarId, JSON.stringify(data));
47 | });
48 | return apiGetAppData();
49 | };
50 |
51 | const apiDisableAllCalendars = (payload) => {
52 | const { calendars } = JSON.parse(payload);
53 | deleteAllTriggers_();
54 | const props = PropertiesService.getUserProperties();
55 | props.deleteProperty(CONFIG.KEY.APP_DATA);
56 | calendars.forEach((calendar) => props.deleteProperty(calendar));
57 | return apiGetAppData();
58 | };
59 |
--------------------------------------------------------------------------------
/projects/GAS099/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "Calendar",
7 | "version": "v3",
8 | "serviceId": "calendar"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8"
14 | }
15 |
--------------------------------------------------------------------------------
/projects/GAS100/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1UW18Wch8Dv-bKB1OQ01hiOucrGGlFFOS9z5SvUaqUM7s7XaUXDyCRhdP","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/GAS100","parentId":["1IWdy4AF2KZd5PQKIJlvIeATzIX1ZQkxcWFrya-uYnGs"]}
2 |
--------------------------------------------------------------------------------
/projects/GAS100/0.utils.js:
--------------------------------------------------------------------------------
1 | const _getUi_ = () => SpreadsheetApp.getUi();
2 |
3 | const _toast_ = (msg, title = "Toast", timeoutSeconds = 6) =>
4 | SpreadsheetApp.getActive().toast(msg, title, timeoutSeconds);
5 |
6 | const _alert_ = (msg, title, type = null) => {
7 | type && (msg = [type, "", msg].join("\n"));
8 | const ui = _getUi_();
9 | _toast_(msg, title, 0.01);
10 | return ui.alert(title, msg, ui.ButtonSet.OK);
11 | };
12 |
13 | const _prompt_ = (msg, title) => {
14 | const ui = _getUi_();
15 | return ui.prompt(title, msg, ui.ButtonSet.OK_CANCEL);
16 | };
17 |
18 | const _getErrorMessage_ = (error) => {
19 | return error.stack
20 | ? error.stack.split("\n").slice(0, 2).join("\n")
21 | : error.message;
22 | };
23 |
24 | const _tryAction_ = (action, title = APP_NAME) => {
25 | try {
26 | action();
27 | } catch (error) {
28 | const msg = _getErrorMessage_(error);
29 | _toast_(msg, title, 0.01);
30 | _alert_(msg, title, "🟥 Error");
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/projects/GAS100/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS101/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"19xDHERsB7vt0aduX-YJlqy7I6a_dPyoJ-7hKZv5RbG1tXnXa1sDADdQO","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/GAS101"}
2 |
--------------------------------------------------------------------------------
/projects/GAS101/0.revision.js:
--------------------------------------------------------------------------------
1 | const REVISIONS = [
2 | {
3 | date: new Date("18/Jul/2023"),
4 | items: ["Initial release"],
5 | },
6 | ];
7 |
--------------------------------------------------------------------------------
/projects/GAS101/8.main.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | NAME: "YT subtitles",
3 | VERSION: "1.00",
4 | ACTION: {
5 | FETCH_SUBTITLES: {
6 | ACTION: "actionFetchSubtitles",
7 | CAPTION: "Fetch subtitles",
8 | },
9 | UPDATE_SUBTITLES: {
10 | ACTION: "actionUpdateSubtitles",
11 | CAPTION: "Update subtitles",
12 | },
13 | FETCH_LANGUAGES: {
14 | ACTION: "actionFetchLanguages",
15 | CAPTION: "Fetch languages",
16 | },
17 | GOOGLE_TRANSLATE: {
18 | ACTION: "actionGoogleTranslate",
19 | CAPTION: "Google translate",
20 | },
21 | VERSIONS: {
22 | ACTION: "actionShowVersions",
23 | CAPTION: "Versions",
24 | },
25 | },
26 | RANGE_NAME: {
27 | VIDEO: "video",
28 | DEFAULT_LANGUAGE: "defaultLanguage",
29 | CATEGORY_ID: "categoryID",
30 | SUBTITLES: "subtitles",
31 | LANGUAGES: "languages",
32 | },
33 | KEY: {
34 | LANGUAGE_CACHE: "languageCache",
35 | },
36 | };
37 |
38 | function onOpen() {
39 | const title = CONFIG.NAME;
40 | const ui = _getUi_();
41 | const menu = ui.createMenu(title);
42 | menu.addItem(
43 | CONFIG.ACTION.FETCH_LANGUAGES.CAPTION,
44 | CONFIG.ACTION.FETCH_LANGUAGES.ACTION
45 | );
46 | menu.addItem(CONFIG.ACTION.VERSIONS.CAPTION, CONFIG.ACTION.VERSIONS.ACTION);
47 | menu.addToUi();
48 | _showUpdates_(REVISIONS, title);
49 | }
50 |
51 | function actionFetchSubtitles() {
52 | _tryAction_(fetchSubtitles_, CONFIG.ACTION.FETCH_SUBTITLES.CAPTION);
53 | }
54 |
55 | function actionUpdateSubtitles() {
56 | _tryAction_(updateSubtitles_, CONFIG.ACTION.UPDATE_SUBTITLES.CAPTION);
57 | }
58 |
59 | function actionFetchLanguages() {
60 | _tryAction_(fetchLanguages_, CONFIG.ACTION.FETCH_LANGUAGES.CAPTION);
61 | }
62 |
63 | function actionGoogleTranslate() {
64 | _tryAction_(googleTranslate_, CONFIG.ACTION.GOOGLE_TRANSLATE.CAPTION);
65 | }
66 |
67 | function actionShowVersions() {
68 | _tryAction_(() => _showVersions_(REVISIONS), CONFIG.ACTION.VERSIONS.CAPTION);
69 | }
70 |
--------------------------------------------------------------------------------
/projects/GAS101/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "YouTube",
7 | "version": "v3",
8 | "serviceId": "youtube"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8"
14 | }
--------------------------------------------------------------------------------
/projects/GAS102/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1drIOGwu79w57mFHCdoFfA7-VxY4Lok5r2jqvvBjeV-Fjh3bm5pfDs582",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS102/0.utils.js:
--------------------------------------------------------------------------------
1 | function _updateTextWithPlaceholders_(
2 | text,
3 | placeholders,
4 | caseSensitive = false,
5 | ) {
6 | if (text == "") return text;
7 | const flags = caseSensitive ? "g" : "gi";
8 | Object.entries(placeholders).forEach(([key, value]) => {
9 | text = text.replace(new RegExp(`{{${key}}}`, flags), value);
10 | });
11 | return text;
12 | }
13 |
14 | function _getTimezone_() {
15 | return (
16 | SpreadsheetApp.getActive().getSpreadsheetTimeZone() ||
17 | Session.getScriptTimeZone()
18 | );
19 | }
20 |
21 | function _getSheetByNameOrId_(nameOrId, ss = SpreadsheetApp.getActive()) {
22 | return ss
23 | .getSheets()
24 | .find(
25 | (sheet) => sheet.getSheetId() == nameOrId || sheet.getName() == nameOrId,
26 | );
27 | }
28 |
29 | /**
30 | * Convert a string to camel case
31 | * @param {string} str
32 | * @returns {string}
33 | */
34 | function _toCamelCase_(str) {
35 | return str
36 | .trim()
37 | .toLowerCase()
38 | .split(/\s+/)
39 | .filter((v) => v)
40 | .map((v, i) => (i == 0 ? v : `${v.charAt(0).toUpperCase()}${v.slice(1)}`))
41 | .join("");
42 | }
43 |
44 | function _getSettings_(sheetName = "Settings") {
45 | const settings = {};
46 | const sheet = _getSheetByNameOrId_(sheetName);
47 | if (!sheet) return settings;
48 | sheet
49 | .getDataRange()
50 | .getValues()
51 | .slice(1)
52 | .forEach(([key, value]) => {
53 | key = _toCamelCase_(key);
54 | if (key == "") return;
55 | if (key.endsWith("*")) return;
56 | settings[key] = value;
57 | });
58 | return settings;
59 | }
60 |
61 | function _getUi_() {
62 | return SpreadsheetApp.getUi();
63 | }
64 |
65 | function _alert_(msg, title = "Alert") {
66 | const ui = _getUi_();
67 | return ui.alert(title, msg, ui.ButtonSet.OK);
68 | }
69 |
70 | function _confirm_(msg, title = "Confirm") {
71 | const ui = _getUi_();
72 | return ui.alert(title, msg, ui.ButtonSet.YES_NO);
73 | }
74 |
75 | function _toast_(msg, title = "Toast", timeInSeconds = 5) {
76 | SpreadsheetApp.getActive().toast(msg, title, timeInSeconds);
77 | }
78 |
79 | function _getErrorMessage_(error) {
80 | return error.stack
81 | ? error.stack.split("\n").slice(0, 2).join("\n")
82 | : error.message;
83 | }
84 |
85 | function _tryAction_(action, title) {
86 | try {
87 | return action();
88 | } catch (error) {
89 | const msg = _getErrorMessage_(error);
90 | console.log(error.stack);
91 | _toast_(msg, title, 0.1);
92 | _alert_(msg, title);
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/projects/GAS102/1.config.js:
--------------------------------------------------------------------------------
1 | const ACTION = {
2 | UPDATE_EVENTS_IN_BOOKING_FORM: {
3 | ACTION: "actionUpdateEventsInBookingForm",
4 | CAPTION: "Update events in booking form",
5 | },
6 | ON_CALENDAR_CHANGE: {
7 | ACTION: "triggerOnCalendarChange",
8 | CAPTION: "On calendar change",
9 | },
10 | ON_FORM_SUBMIT: {
11 | ACTION: "triggerOnFormSubmit",
12 | CAPTION: "On form submit",
13 | },
14 | EVERY_30_MINS: {
15 | ACTION: "triggerEvery30Mins",
16 | CAPTION: "Every 30 mins",
17 | },
18 | INSTALL_TRIGGERS: {
19 | ACTION: "actionInstallTriggers",
20 | CAPTION: "Install triggers",
21 | },
22 | UNINSTALL_TRIGGERS: {
23 | ACTION: "actionUninstallTriggers",
24 | CAPTION: "Uninstall triggers",
25 | },
26 | };
27 |
28 | const TRIGGERS = [
29 | {
30 | name: ACTION.ON_CALENDAR_CHANGE.ACTION,
31 | note: "Update events in the booking form",
32 | },
33 | {
34 | name: ACTION.ON_FORM_SUBMIT.ACTION,
35 | note: "Handle events when form submit",
36 | },
37 | {
38 | name: ACTION.EVERY_30_MINS.ACTION,
39 | note: "Update events in the booking form every 30 mins",
40 | },
41 | ];
42 |
43 | const CONFIG = {
44 | NAME: "GAS102",
45 | ACTION,
46 | KEY: {
47 | TRIGGERS_INSTALLED: "triggersInstalled",
48 | },
49 | };
50 |
--------------------------------------------------------------------------------
/projects/GAS102/2.api.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @returns {GoogleAppsScript.Calendar.CalendarEvent[]}
3 | */
4 | function getEventsFromCalendar_(calendarId, days = 30, minutes = 30) {
5 | const calendar = CalendarApp.getCalendarById(calendarId);
6 | const now = new Date();
7 | const startTime = new Date(now.getTime() + minutes * 60 * 1000);
8 | const endTime = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
9 | return calendar.getEvents(startTime, endTime);
10 | }
11 |
12 | /**
13 | * @param {string} formId
14 | * @param {string} fieldName
15 | * @param {string[]} events
16 | */
17 | function updateEventsInForm_(formUrl, fieldName, events) {
18 | const form = FormApp.openByUrl(formUrl);
19 |
20 | const field = form.getItems().find((item) => item.getTitle() == fieldName);
21 | if (!field) {
22 | throw new Error(`Field title "${fieldName}" was not foud in the form.`);
23 | }
24 | events = [...new Set(events)];
25 | field.asListItem().setChoiceValues(events);
26 | return form;
27 | }
28 |
29 | function uninstallTriggers_() {
30 | ScriptApp.getProjectTriggers().forEach((trigger) =>
31 | ScriptApp.deleteTrigger(trigger)
32 | );
33 | PropertiesService.getScriptProperties().deleteProperty(
34 | CONFIG.KEY.TRIGGERS_INSTALLED,
35 | );
36 | onOpen();
37 | }
38 |
39 | function createCalendarTrigger_(calendarId) {
40 | return ScriptApp.newTrigger(ACTION.ON_CALENDAR_CHANGE.ACTION)
41 | .forUserCalendar(calendarId)
42 | .onEventUpdated()
43 | .create();
44 | }
45 |
46 | function createFormSubmitTrigger_(spreadsheetId) {
47 | return ScriptApp.newTrigger(ACTION.ON_FORM_SUBMIT.ACTION)
48 | .forSpreadsheet(spreadsheetId)
49 | .onFormSubmit()
50 | .create();
51 | }
52 |
53 | function installTriggers_() {
54 | uninstallTriggers_();
55 | const { calendarId } = _getSettings_();
56 | createCalendarTrigger_(calendarId);
57 | const spreadsheetId = SpreadsheetApp.getActive().getId();
58 | createFormSubmitTrigger_(spreadsheetId);
59 | ScriptApp.newTrigger(ACTION.EVERY_30_MINS.ACTION)
60 | .timeBased()
61 | .everyMinutes(30)
62 | .create();
63 | PropertiesService.getScriptProperties().setProperty(
64 | CONFIG.KEY.TRIGGERS_INSTALLED,
65 | true,
66 | );
67 | onOpen();
68 | }
69 |
--------------------------------------------------------------------------------
/projects/GAS102/README.md:
--------------------------------------------------------------------------------
1 | # GAS102 Google Forms for Booking & Cacellation
2 |
3 | [ ](https://youtube.com/ashtonfei)
4 | [ ](https://twitter.com/ashton_fei)
5 | [ ](https://upwork.com/workwith/ashtonfei)
6 | 
7 |
8 | ## Links
9 |
10 | [ ](https://docs.google.com/spreadsheets/d/17yFFhxs6Wbt2FBCAaSqtzmSB5IX_iHbzzyCEJDsO6U0/copy)
11 | [ ](https://forms.gle/Cx5aRK8QgsZ3neEQA)
12 | [ ](https://forms.gle/Rqs7UccxtjbzF3ma8)
13 |
14 | ## Screenshots
15 |
--------------------------------------------------------------------------------
/projects/GAS102/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8"
6 | }
7 |
--------------------------------------------------------------------------------
/projects/GAS103/README.md:
--------------------------------------------------------------------------------
1 | # GAS103 Watch Updates in a Google Drive Folder
2 |
3 | [ ](https://youtube.com/ashtonfei)
4 | [ ](https://twitter.com/ashton_fei)
5 | [ ](https://upwork.com/workwith/ashtonfei)
6 | [](https://youtu.be/iRqvvS0F9Bg)
7 |
8 | ## Links
9 |
10 | [ ](https://docs.google.com/spreadsheets/d/1Ir20tp3mzzeEj2Q4C9lhkUmeDkJ42MT4kekvK8JQLBE/copy)
11 |
--------------------------------------------------------------------------------
/projects/GAS103/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS103/src/0.config.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | NAME: "GAS103",
3 | VERSION: "1.0.1",
4 | SHEET_NAME: "Files",
5 | WATCH_FOLDER_ID: "1VjzmYFV2mPZL_61ckM6dG__ouHcPeNJb",
6 | KEY: {
7 | LAST_RUN_AT: "lastRunAt",
8 | INSTALLED: "installed",
9 | },
10 | STATUS: {
11 | TRASHED: "Trashed",
12 | CREATED: "Created",
13 | MODIFIED: "Modified",
14 | },
15 | FIELDS: [
16 | "id",
17 | "name",
18 | "mimeType",
19 | "createdTime",
20 | "modifiedTime",
21 | "trashed",
22 | "shared",
23 | "webViewLink",
24 | "webContentLink",
25 | "size",
26 | "driveId",
27 | ],
28 | HEADERS: [
29 | { key: "name", value: "Name" },
30 | { key: "id", value: "ID" },
31 | { key: "mimeType", value: "MimeType" },
32 | { key: "size", value: "Size" },
33 | { key: "_status", value: "Status" },
34 | { key: "modifiedTime", value: "Modified At" },
35 | { key: "createdTime", value: "Created At" },
36 | { key: "trashed", value: "Trashed" },
37 | { key: "shared", value: "Shared" },
38 | { key: "webViewLink", value: "Web View Link" },
39 | { key: "webContentLink", value: "Web Content Link" },
40 | ],
41 | ACTION: {
42 | GET_ALL_FILES: {
43 | CAPTION: "Get all files",
44 | ACTION: "actionGetAllFiles",
45 | },
46 | GET_UPDATED_FILES: {
47 | CAPTION: "Get updated files",
48 | ACTION: "actionGetUpdatedFiles",
49 | TRIGGER: "triggerGetUpdatedFiles",
50 | RUN_EVERY_MINUTE: 1, // valid values 1, 5, 10, 15, 30
51 | },
52 | INSTALL: {
53 | CAPTION: "Install trigger",
54 | ACTION: "actionInstallTrigger",
55 | },
56 | UNINSTALL: {
57 | CAPTION: "Uninstall trigger",
58 | ACTION: "actionUninstallTrigger",
59 | },
60 | },
61 | };
62 |
--------------------------------------------------------------------------------
/projects/GAS103/src/1.utils.js:
--------------------------------------------------------------------------------
1 | const _getUi_ = () => SpreadsheetApp.getUi();
2 |
3 | const _createAlert_ = (title = "Alert") => (msg) => {
4 | const ui = _getUi_();
5 | return ui.alert(title, msg, ui.ButtonSet.OK);
6 | };
7 |
8 | const _createConfirm_ = (title = "Confirm") => (msg) => {
9 | const ui = _getUi_();
10 | return ui.alert(title, msg, ui.ButtonSet.YES_NO);
11 | };
12 |
13 | const _getProperty_ = (ps = PropertiesService.getScriptProperties()) => (key) =>
14 | ps.getProperty(key);
15 |
16 | const _setProperty_ =
17 | (ps = PropertiesService.getScriptProperties()) => (key, value) =>
18 | ps.setProperty(key, value);
19 |
20 | const _getHeaders_ = (items) => {
21 | const headers = [];
22 | const keys = [];
23 | items.forEach(({ key, value }) => {
24 | headers.push(value);
25 | keys.push(key);
26 | });
27 | return { keys, headers };
28 | };
29 |
30 | const _createRowValues_ = (keys) => (item) => {
31 | return keys.map((key) => item[key]);
32 | };
33 |
34 | const _getSheetByName_ =
35 | (ss = SpreadsheetApp.getActive()) => (name, createNewIfNotFound = true) => {
36 | const sheet = ss.getSheetByName(name);
37 | if (sheet) return sheet;
38 | if (createNewIfNotFound) return ss.insertSheet(name);
39 | };
40 |
41 | /**
42 | * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet
43 | */
44 | const _valuesToSheet_ = (sheet) => (values) => {
45 | sheet.clearContents();
46 | sheet.getRange(1, 1, values.length, values[0].length).setValues(values);
47 | return sheet;
48 | };
49 |
50 | const _action_ = (fn) => {
51 | try {
52 | return fn();
53 | } catch (err) {
54 | _createAlert_("Error")(err.message);
55 | }
56 | };
57 |
--------------------------------------------------------------------------------
/projects/GAS103/src/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "Drive",
7 | "version": "v3",
8 | "serviceId": "drive"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8"
14 | }
15 |
--------------------------------------------------------------------------------
/projects/GAS104/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "179AfUnigK9y-axE6VFIm3UhPU2CG_nIL0_WU63IksOAFS20muR15wNNZ",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS104/1.utils.js:
--------------------------------------------------------------------------------
1 | const _getSheetByName_ =
2 | (ss = SpreadsheetApp.getActive()) => (name, create = true) => {
3 | const sheet = ss.getSheetByName(name);
4 | if (sheet) return sheet;
5 | if (create) return ss.insertSheet(name);
6 | };
7 |
8 | const _getSheetById_ = (ss = SpreadsheetApp.getActive()) => (id) => {
9 | return ss.getSheets().find((sheet) => sheet.getSheetId() == id);
10 | };
11 |
12 | const _valuesToSheet_ = (sheet) => (values, row = 1, col = 1) => {
13 | if (typeof sheet === "string") {
14 | sheet = _getSheetByName_()(sheet);
15 | }
16 | return sheet
17 | .getRange(row, col, values.length, values[0].length)
18 | .setValues(values);
19 | };
20 |
21 | const _getUi_ = () => SpreadsheetApp.getUi();
22 |
23 | const _createAlert_ = (title = "Alert") => (msg) => {
24 | const ui = _getUi_();
25 | return ui.alert(title, msg, ui.ButtonSet.OK);
26 | };
27 |
28 | const _createConfirm_ = (title = "Confirm") => (msg) => {
29 | const ui = _getUi_();
30 | return ui.alert(title, msg, ui.ButtonSet.YES_NO);
31 | };
32 |
33 | const _getIdFromUrl_ = (url) => {
34 | const keywords = [
35 | "/projects/",
36 | "/spreadsheets/d/",
37 | "/presentation/d/",
38 | "/document/d/",
39 | "/forms/d/",
40 | ];
41 | const key = keywords.find((key) => url.includes(key));
42 | if (!key) return url;
43 | return url.split(key)[1].split("/")[0];
44 | };
45 |
46 | const _action_ = (fn) => {
47 | try {
48 | return fn();
49 | } catch (err) {
50 | console.log(err);
51 | _createAlert_("Error")(err.message);
52 | }
53 | };
54 |
55 | const _updateCell_ = (range, col) => (row, value) => {
56 | range.getCell(row, col).setValue(value);
57 | };
58 |
--------------------------------------------------------------------------------
/projects/GAS104/9.main.js:
--------------------------------------------------------------------------------
1 | const MENU = {
2 | FETCH_SCRIPTS: {
3 | caption: "Fetch scripts",
4 | fn: "fnFetchScripts",
5 | },
6 | COPY_SCRIPTS: {
7 | caption: "Copy scripts",
8 | fn: "fnCopyScripts",
9 | },
10 | };
11 | const RN = {
12 | FROM: "startFrom",
13 | TO: "startTo",
14 | SCRIPT_ID: "scriptId",
15 | };
16 |
17 | const CONFIG = {
18 | NAME: "GAS104",
19 | MENU,
20 | };
21 |
22 | const getAccessToken_ = () => ScriptApp.getOAuthToken();
23 |
24 | const createRequest_ = (
25 | url,
26 | method = "GET",
27 | token = getAccessToken_(),
28 | payload = null,
29 | ) => {
30 | const request = {
31 | url,
32 | method,
33 | headers: {
34 | Authorization: `Bearer ${token}`,
35 | },
36 | contentType: "application/json",
37 | muteHttpExceptions: true,
38 | };
39 | if (payload) {
40 | request.payload = JSON.stringify(payload);
41 | }
42 | return request;
43 | };
44 |
45 | const processRequests_ = (requests) => {
46 | return UrlFetchApp.fetchAll(requests).map((res) => {
47 | const result = JSON.parse(res.getContentText());
48 | const code = res.getResponseCode();
49 | if (code != 200) {
50 | const error = `${code}: ${result?.error?.message}`;
51 | throw new Error(error);
52 | }
53 | return result;
54 | });
55 | };
56 |
57 | const getScriptFilesById_ = (scriptId = ScriptApp.getScriptId()) => {
58 | const urlMeta = `https://script.googleapis.com/v1/projects/${scriptId}`;
59 | const urlContent =
60 | `https://script.googleapis.com/v1/projects/${scriptId}/content`;
61 | const requests = [createRequest_(urlMeta), createRequest_(urlContent)];
62 | const [file, { files }] = processRequests_(requests);
63 | return { ...file, files };
64 | };
65 |
66 | const getAllScriptProjects_ = () => {
67 | const api = "https://www.googleapis.com/drive/v3/files";
68 | const q = "mimeType = 'application/vnd.google-apps.script'";
69 | let projects = [];
70 | const runQuery = (pageToken) => {
71 | const url = pageToken
72 | ? `${api}?q=${q}&pageToken=${pageToken}`
73 | : `${api}?q=${q}`;
74 | const request = createRequest_(url);
75 | console.log(request);
76 | const { nextPageToken, files } = processRequests_([request])[0];
77 | projects = [...projects, ...files];
78 | if (nextPageToken) {
79 | runQuery(nextPageToken);
80 | }
81 | };
82 | runQuery();
83 | return projects;
84 | };
85 |
86 | const getScriptContainers_ = (projects) => {
87 | const token = getAccessToken_();
88 | const requests = projects.map(({ id }) => {
89 | const url = `https://script.googleapis.com/v1/projects/${id}`;
90 | return createRequest_(url, "GET", token);
91 | });
92 | return processRequests_(requests);
93 | };
94 |
95 | const createProject_ = (title, parentId, token) => {
96 | const payload = { title, parentId };
97 | const url = "https://script.googleapis.com/v1/projects";
98 | const request = createRequest_(url, "POST", token, payload);
99 | console.log(payload);
100 | console.log(request);
101 | return processRequests_([request])[0];
102 | };
103 |
104 | const copyToFile_ = ({ scriptId, parentId, title }, files, token) => {
105 | if (!scriptId) {
106 | scriptId = createProject_(title, parentId, token).scriptId;
107 | }
108 | const url = `https://script.googleapis.com/v1/projects/${scriptId}/content`;
109 | const request = createRequest_(url, "PUT", token, { files });
110 | console.log(request);
111 | return processRequests_([request])[0];
112 | };
113 |
114 | const getScriptId_ = (ss, rangeName = RN.SCRIPT_ID) => {
115 | const range = ss.getRange(rangeName);
116 | return _getIdFromUrl_(range.getValue());
117 | };
118 |
119 | const fetchScripts_ = () => {
120 | const success = _createAlert_("Success");
121 | const error = _createAlert_("Error");
122 | const ss = SpreadsheetApp.getActive();
123 | const scriptId = getScriptId_(ss, RN.SCRIPT_ID);
124 | if (!scriptId) {
125 | return error("No script to be fetched.");
126 | }
127 |
128 | const { files } = getScriptFilesById_(scriptId);
129 | const values = files.map(({ name, type }) => [name, type, true]);
130 | values.unshift(["File Name", "File Type", "Selected"]);
131 | const startRange = ss.getRange(RN.FROM);
132 | startRange.getDataRegion().clearContent();
133 | const sheet = startRange.getSheet();
134 |
135 | _valuesToSheet_(sheet)(values, startRange.getRow(), startRange.getColumn());
136 | SpreadsheetApp.flush();
137 | success(
138 | "All scripts in the project has been fetched, you can now select the files to be copied to the target files.",
139 | );
140 | };
141 |
142 | const copyScripts_ = () => {
143 | const success = _createAlert_("Success");
144 | const error = _createAlert_("Error");
145 | const ss = SpreadsheetApp.getActive();
146 |
147 | const scriptId = getScriptId_(ss, RN.SCRIPT_ID);
148 | if (!scriptId) {
149 | return error("No script to be copied from.");
150 | }
151 |
152 | const fromValues = ss.getRange(RN.FROM).getDataRegion().getValues();
153 | const selectedFiles = fromValues
154 | .filter((v) => v[2] === true)
155 | .map((v) => v[0]);
156 | if (selectedFiles.length === 0) {
157 | return error("You didn't select any file to copy.");
158 | }
159 | const rangeTo = ss.getRange(RN.TO);
160 | const valuesTo = rangeTo.getDataRegion().getValues();
161 | const targetFiles = valuesTo
162 | .slice(1)
163 | .map(([url, scriptId, updatedAt, status]) => {
164 | const parentId = _getIdFromUrl_(url);
165 | return {
166 | parentId,
167 | url,
168 | scriptId,
169 | status,
170 | updatedAt,
171 | };
172 | });
173 | const filter = (file) =>
174 | file.name == "appsscript" || selectedFiles.includes(file.name);
175 | const { files, title } = getScriptFilesById_(scriptId);
176 | const filteredFiles = files.filter(filter);
177 |
178 | // const updatedValues = [["URL", "Script ID", "Updated At", "Status"]];
179 | const token = getAccessToken_();
180 | const dataRegionTo = rangeTo.getDataRegion();
181 | const updateScriptId = _updateCell_(dataRegionTo, 2);
182 | const updateUpdatedAt = _updateCell_(dataRegionTo, 3);
183 | const updateStatus = _updateCell_(dataRegionTo, 4);
184 | targetFiles.forEach((file, index) => {
185 | file.title = title;
186 | const row = index + 2;
187 | updateUpdatedAt(row, null);
188 | updateStatus(row, "Copying");
189 | SpreadsheetApp.flush();
190 | try {
191 | const { scriptId } = copyToFile_(file, filteredFiles, token);
192 | updateScriptId(row, scriptId);
193 | updateUpdatedAt(row, new Date());
194 | updateStatus(row, "Success");
195 | } catch (err) {
196 | updateUpdatedAt(row, new Date());
197 | updateStatus(row, err.message);
198 | }
199 | });
200 | SpreadsheetApp.flush();
201 | success("Script copy request has been completed.");
202 | };
203 |
204 | const fnFetchScripts = () => _action_(fetchScripts_);
205 | const fnCopyScripts = () => _action_(copyScripts_);
206 |
--------------------------------------------------------------------------------
/projects/GAS104/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | "enabledAdvancedServices": [
5 | {
6 | "userSymbol": "Drive",
7 | "version": "v3",
8 | "serviceId": "drive"
9 | }
10 | ]
11 | },
12 | "exceptionLogging": "STACKDRIVER",
13 | "runtimeVersion": "V8",
14 | "oauthScopes": [
15 | "https://www.googleapis.com/auth/spreadsheets",
16 | "https://www.googleapis.com/auth/script.external_request",
17 | "https://www.googleapis.com/auth/drive.readonly",
18 | "https://www.googleapis.com/auth/script.projects"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/projects/GAS105/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1-JtvS0uiQuF8YNXGc9wE-_APyCtGo7ScibXPn2YU37EKf2s-szjHztZL",
3 | "rootDir": "./src"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS105/src/*.versions.js:
--------------------------------------------------------------------------------
1 | const VERSIONS = [
2 | {
3 | date: new Date("14/Jan/2024"),
4 | items: ["Initial release"],
5 | },
6 | ];
7 |
--------------------------------------------------------------------------------
/projects/GAS105/src/0.configs.js:
--------------------------------------------------------------------------------
1 | const MENU = {
2 | RUN: {
3 | caption: "Run",
4 | fn: "fnRun",
5 | trigger: "triggerRun",
6 | },
7 | ADD_SAMPLE_RULE: {
8 | caption: "Add sample rule",
9 | fn: "fnAddSampleRule",
10 | },
11 | SETUP: {
12 | caption: "Setup",
13 | fn: "fnSetup",
14 | },
15 | RESET: {
16 | caption: "Reset",
17 | fn: "fnReset",
18 | },
19 | VERSION: {
20 | caption: "Version",
21 | fn: "fnVersion",
22 | },
23 | };
24 |
25 | const CHAT_GPT = {
26 | ENDPOINT: "https://api.openai.com/v1/chat/completions",
27 | MODEL: "gpt-3.5-turbo", // default model to use
28 | TEMPERATURE: 1, // default temperature What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.
29 | };
30 |
31 | const CONFIG = {
32 | NAME: "GAS105",
33 | SHEET_NAME: { RULES: "Rules", LOGS: "Logs" },
34 | KEY: {
35 | NAME: "name",
36 | API_KEY: "apiKey",
37 | INTERVAL: "interval",
38 | INSTALLED: "installed",
39 | AFTER: "after",
40 | },
41 | INTERVALS: ["1", "5", "10", "15", "30"],
42 | };
43 |
--------------------------------------------------------------------------------
/projects/GAS105/src/0.utils.js:
--------------------------------------------------------------------------------
1 | const _getUi_ = () => SpreadsheetApp.getUi();
2 |
3 | const _createAlert_ = (ui = _getUi_()) => (title) => (msg) =>
4 | ui.alert(title, msg, ui.ButtonSet.OK);
5 |
6 | const _createConfirm_ = (ui = _getUi_()) => (title) => (msg) => {
7 | return ui.alert(title, msg, ui.ButtonSet.YES_NO);
8 | };
9 |
10 | const _createMenu_ = (ui = _getUi_()) => (items, caption = null) => {
11 | const menu = caption ? ui.createMenu(caption) : ui.createAddonMenu();
12 | const createMenuItem = ({ title, items, caption, fn, sep }) => {
13 | if (title && items) {
14 | return menu.addSubMenu(_createMenu_(ui)(items, title));
15 | }
16 | if (caption && fn) return menu.addItem(caption, fn);
17 |
18 | if (sep) return menu.addSeparator();
19 | };
20 | items.forEach(createMenuItem);
21 | return menu;
22 | };
23 |
24 | /**
25 | * @param {Function[]} rules - A list of functions which return true or an error message
26 | * @param {any} value - The value to be checked
27 | * @returns {string[]} A list of error messages
28 | */
29 | const _createValidator_ = (rules) => (value) =>
30 | rules
31 | .map((rule) => rule(value))
32 | .filter((v) => v !== true)
33 | .map((v) => `${v}\nYour input: ${value}`);
34 |
35 | /**
36 | * @param {string} title - The title of the prompt
37 | * @param {string} msg - The message body of the prompt
38 | * @param {Function[]|undefined} rules - A list of validator functions
39 | * @returns {string|null} return null if cancelled else return the value entered
40 | */
41 | const _createInput_ = (title) => (msg) => (rules) => {
42 | const ui = SpreadsheetApp.getUi();
43 | const input = ui.prompt(title, msg, ui.ButtonSet.OK_CANCEL);
44 | if (input.getSelectedButton() !== ui.Button.OK) return null;
45 | const value = input.getResponseText();
46 | if (!rules) return value;
47 | const validator = _createValidator_(rules);
48 | const firstErrorMessage = validator(value)[0];
49 | if (!firstErrorMessage) return value;
50 | return _createInput_(title)(firstErrorMessage)(rules);
51 | };
52 |
53 | const _try_ = (fn) => {
54 | try {
55 | return fn();
56 | } catch (err) {
57 | console.log(err.stack);
58 | _createAlert_()("Error")(err.message);
59 | }
60 | };
61 |
62 | const _setProps_ =
63 | (ps = PropertiesService.getScriptProperties()) => (props) => {
64 | ps.setProperties(props);
65 | };
66 |
67 | const _getProp_ = (ps = PropertiesService.getScriptProperties()) => (key) => {
68 | return ps.getProperty(key);
69 | };
70 |
71 | const _getProps_ = (ps = PropertiesService.getScriptProperties()) => {
72 | return ps.getProperties();
73 | };
74 |
75 | const _getSheetByName_ =
76 | (ss = SpreadsheetApp.getActive()) => (name, createIfNotFound) => {
77 | const sheet = ss.getSheetByName(name);
78 | if (sheet) return sheet;
79 | if (createIfNotFound) return ss.insertSheet(name);
80 | };
81 |
--------------------------------------------------------------------------------
/projects/GAS105/src/1.setup.js:
--------------------------------------------------------------------------------
1 | const required_ = (v) => !!v || "This is required";
2 | const validateApiKey_ = (key) => {
3 | const request = createOpenaiPromptRequest_(
4 | "Hello world",
5 | CHAT_GPT.MODEL,
6 | CHAT_GPT.TEMPERATURE,
7 | key,
8 | );
9 | const [response] = UrlFetchApp.fetchAll([request]);
10 | const result = JSON.parse(response.getContentText());
11 | return result?.error?.message || true;
12 | };
13 |
14 | const validateInterval_ = (v) =>
15 | CONFIG.INTERVALS.includes(v) ||
16 | `Invalid interval value, select one from ${CONFIG.INTERVALS.join(", ")}`;
17 |
18 | const INPUT_ITEMS = [
19 | {
20 | title: "Name",
21 | key: CONFIG.KEY.NAME,
22 | msg: "Enter your name for the email signature:",
23 | rules: [required_],
24 | },
25 | {
26 | title: "Interval",
27 | key: CONFIG.KEY.INTERVAL,
28 | msg: `Enter the interval (mins) to check your Gmail account:\n(Options: ${
29 | CONFIG.INTERVALS.join(
30 | ", ",
31 | )
32 | })`,
33 | rules: [required_, validateInterval_],
34 | },
35 | {
36 | title: "API Key",
37 | key: CONFIG.KEY.API_KEY,
38 | msg: "Enter your OpenAI API key for creating reply message:",
39 | rules: [required_, validateApiKey_],
40 | },
41 | ];
42 |
43 | const getSettings_ = () => {
44 | const ps = PropertiesService.getScriptProperties();
45 | const settings = _getProps_(ps);
46 | settings[CONFIG.KEY.INTERVAL] = settings[CONFIG.KEY.INTERVAL] * 1;
47 | return settings;
48 | };
49 |
50 | const getUser_ = () => Session.getActiveUser().getEmail();
51 |
52 | const deleteTriggers_ = () => {
53 | ScriptApp.getProjectTriggers().forEach((t) => ScriptApp.deleteTrigger(t));
54 | };
55 |
56 | const createTrigger_ = () => {
57 | deleteTriggers_();
58 | const settings = getSettings_();
59 | const fn = MENU.RUN.trigger;
60 | ScriptApp.newTrigger(fn)
61 | .timeBased()
62 | .everyMinutes(settings[CONFIG.KEY.INTERVAL])
63 | .create();
64 | _setProps_()({ [CONFIG.KEY.INSTALLED]: getUser_() });
65 | };
66 |
67 | const setup_ = () => {
68 | let isCancelled = false;
69 | const settings = {};
70 | INPUT_ITEMS.forEach(({ title, key, msg, rules }, index) => {
71 | if (isCancelled) return;
72 | title = `${title} (${index + 1}/${INPUT_ITEMS.length})`;
73 | const value = _createInput_(title)(msg)(rules);
74 | settings[key] = value;
75 | if (!value) isCancelled = true;
76 | });
77 | settings[CONFIG.KEY.AFTER] = new Date().toISOString();
78 | if (isCancelled) return;
79 | const ps = PropertiesService.getScriptProperties();
80 | _setProps_(ps)(settings);
81 | createTrigger_();
82 | createMenu_();
83 | _createAlert_()("Success")(
84 | "The automation has been setup for creating replies with ChatGPT.",
85 | );
86 | };
87 |
88 | const reset_ = () => {
89 | const error = _createAlert_()("Error");
90 | const user = getUser_();
91 | const { installed } = getSettings_();
92 | if (!installed) {
93 | return error("The automation was not setup, no need to reset it.");
94 | }
95 | if (user !== installed) {
96 | return error(
97 | `You are not able to reset the automation created by ${installed}.`,
98 | );
99 | }
100 | const confirm = _createConfirm_()("Confirm")(
101 | "Are you sure to reset the automation for creating replies with ChatGPT?",
102 | );
103 | if (confirm !== _getUi_().Button.YES) return;
104 | const ps = PropertiesService.getScriptProperties();
105 | ps.deleteAllProperties();
106 | deleteTriggers_();
107 | createMenu_();
108 | _createAlert_()("Success")(
109 | "The automation has been disabled for creating replies with ChatGPT.",
110 | );
111 | };
112 |
113 | const addSampleRule_ = () => {
114 | const headers = [
115 | "Gmail Query",
116 | "ChatGPT Prompt",
117 | "Messages",
118 | "Reply All",
119 | "Reply in Draft",
120 | "Enabled",
121 | ];
122 | const rule = [
123 | "from:yunjia.fei@gmail.com subject:GAS105",
124 | "My name is Ashton. Create a reply message according to the the messages below:",
125 | 1,
126 | false,
127 | true,
128 | false,
129 | ];
130 | const sheet = _getSheetByName_()(CONFIG.SHEET_NAME.RULES, true);
131 | sheet.insertRowBefore(2);
132 | sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
133 | sheet.getRange(2, 1, 1, rule.length).setValues([rule]).activate();
134 | };
135 |
136 | const fnSetup = () => _try_(setup_);
137 | const fnReset = () => _try_(reset_);
138 |
139 | const fnAddSampleRule = () => _try_(addSampleRule_);
140 |
141 | const fnVersion = () =>
142 | _try_(() => {
143 | const count = 3;
144 | const lines = [`The latest ${count} versions:`];
145 | VERSIONS.slice(0, 3).forEach(({ items, date }) => {
146 | lines.push("");
147 | lines.push(date.toLocaleDateString());
148 | items.forEach((item, index) => lines.push(`${index + 1}. ${item}`));
149 | });
150 | _createAlert_()(MENU.VERSION.caption)(lines.join("\n"));
151 | });
152 |
--------------------------------------------------------------------------------
/projects/GAS105/src/9.main.js:
--------------------------------------------------------------------------------
1 | const createMenu_ = () => {
2 | const { installed } = getSettings_();
3 | const menuItems = installed
4 | ? [MENU.RUN, MENU.ADD_SAMPLE_RULE, { sep: true }, MENU.RESET, MENU.VERSION]
5 | : [MENU.SETUP, { sep: true }, MENU.VERSION];
6 | _createMenu_()(menuItems, CONFIG.NAME).addToUi();
7 | };
8 |
9 | const onOpen = () => {
10 | createMenu_();
11 | };
12 |
13 | const getRules_ = () => {
14 | const name = CONFIG.SHEET_NAME.RULES;
15 | const sheet = _getSheetByName_()(name);
16 | if (!sheet) {
17 | throw new Error(`Sheet "" was not found.`);
18 | }
19 | return sheet
20 | .getDataRange()
21 | .getValues()
22 | .slice(1)
23 | .map((v) => {
24 | return {
25 | query: v[0],
26 | prompt: v[1],
27 | messages: v[2],
28 | replyAll: v[3],
29 | replyInDraft: v[4],
30 | enabled: v[5],
31 | };
32 | })
33 | .filter((v) => v.query && v.enabled === true);
34 | };
35 |
36 | function getNewEmails_(query, after, start = 0, max = 50) {
37 | const emails = [];
38 | query = `${query} newer_than:1d`;
39 | const email = getUser_();
40 | const runQuery_ = (start, max) => {
41 | const threads = GmailApp.search(query, start, max);
42 | threads.forEach((v) => {
43 | const emailFrom = v.getMessages().at(-1).getFrom();
44 | if (emailFrom.includes(email)) return;
45 | const date = v.getLastMessageDate().toISOString();
46 | if (date <= after) return;
47 | emails.push(v);
48 | });
49 | if (threads.length == max) {
50 | runQuery_(start + max, max);
51 | }
52 | };
53 | runQuery_(start, max);
54 | return emails;
55 | }
56 |
57 | function createResponseMessage_(prompt, apiKey) {
58 | const request = createOpenaiPromptRequest_(
59 | prompt,
60 | CHAT_GPT.MODEL,
61 | CHAT_GPT.TEMPERATURE,
62 | apiKey,
63 | );
64 | const [response] = UrlFetchApp.fetchAll([request]);
65 | const result = JSON.parse(response.getContentText());
66 | if (result?.error?.message) {
67 | throw new Error(result.error.message);
68 | }
69 | return getFirstChoice_(result);
70 | }
71 |
72 | function getFirstChoice_({ choices, candidates }) {
73 | if (choices) {
74 | return choices[0]?.message?.content;
75 | }
76 | if (candidates) {
77 | return candidates[0]?.content;
78 | }
79 | return "No response";
80 | }
81 |
82 | function createOpenaiPromptRequest_(
83 | prompt,
84 | model = CHAT_GPT.MODEL,
85 | temperature = CHAT_GPT.TEMPERATURE,
86 | apiKey,
87 | ) {
88 | const payload = {
89 | model,
90 | temperature,
91 | messages: [{ role: "user", content: prompt }],
92 | };
93 |
94 | return {
95 | url: CHAT_GPT.ENDPOINT,
96 | method: "POST",
97 | headers: {
98 | Authorization: `Bearer ${apiKey}`,
99 | },
100 | muteHttpExceptions: true,
101 | contentType: "application/json",
102 | payload: JSON.stringify(payload),
103 | };
104 | }
105 |
106 | /**
107 | * @param {GmailApp.GmailThread} thread
108 | */
109 | function createReply_(
110 | thread,
111 | { query, prompt, messages, replyAll, replyInDraft },
112 | settings,
113 | ) {
114 | let start = -1;
115 | if (messages === "All") {
116 | start = 0;
117 | } else if (typeof messages !== "number" || messages <= 0) {
118 | start = -1;
119 | } else {
120 | start = messages * -1;
121 | }
122 | const items = thread.getMessages();
123 | const message = items.at(-1);
124 |
125 | const contents = items
126 | .slice(start)
127 | .map((v) => v.getPlainBody())
128 | .join("\n");
129 | const rowValues = [query, thread.getId(), thread.getFirstMessageSubject()];
130 |
131 | let body = null;
132 |
133 | try {
134 | body = createResponseMessage_(
135 | [
136 | prompt
137 | ? `My name is ${settings.name}. ${prompt}`
138 | : `My name is ${settings.name}, create a reply email according to the messages below:`,
139 | contents,
140 | ].join("\n"),
141 | settings.apiKey,
142 | );
143 | } catch (err) {
144 | return settings.sheetLogs.appendRow([
145 | ...rowValues,
146 | err.message,
147 | "Error",
148 | new Date(),
149 | ]);
150 | }
151 |
152 | rowValues.push(body);
153 |
154 | if (replyInDraft) {
155 | message.createDraftReply(body);
156 | rowValues.push("Draft Reply Created");
157 | } else if (replyAll) {
158 | message.replyAll(body);
159 | rowValues.push("Replied All");
160 | } else {
161 | message.reply(body);
162 | rowValues.push("Replied");
163 | }
164 | rowValues.push(new Date());
165 | settings.sheetLogs.appendRow(rowValues);
166 | }
167 |
168 | const processRule_ = (rule, settings) => {
169 | const threads = getNewEmails_(rule.query, settings.after);
170 | threads.forEach((thread) => createReply_(thread, rule, settings));
171 | };
172 |
173 | const run_ = () => {
174 | const settings = getSettings_();
175 | settings.sheetLogs = _getSheetByName_()(CONFIG.SHEET_NAME.LOGS, true);
176 | const now = new Date();
177 | getRules_().forEach((rule) => processRule_(rule, settings));
178 | _setProps_()({ [CONFIG.KEY.AFTER]: now.toISOString() });
179 | };
180 |
181 | const fnRun = () => _try_(run_);
182 |
183 | const triggerRun = () => _try_(run_);
184 |
--------------------------------------------------------------------------------
/projects/GAS105/src/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS106/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"1gfKpbTFrtBe9dxiKE5fNZCcjlCCR5Qh1rebWaXpkyaXTrYnq_enu4xPe","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/projects/GAS106","parentId":["17G-icBeFygcoV5qOXKL4GXIV96BbrZGxF-RUfTIW-CQ"]}
2 |
--------------------------------------------------------------------------------
/projects/GAS106/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "webapp": {
7 | "executeAs": "USER_DEPLOYING",
8 | "access": "ANYONE_ANONYMOUS"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/projects/GAS106/post.html:
--------------------------------------------------------------------------------
1 |
66 |
--------------------------------------------------------------------------------
/projects/GAS106/postViews.js:
--------------------------------------------------------------------------------
1 | /**
2 | * From Ashton (13/Aug/2024):
3 | * I updated the API to use the post/page URL as a unique key
4 | * so we don't need to use the blog id and post id as the key any more
5 | */
6 |
7 | /**
8 | * Ignore requests from other websites
9 | */
10 | const PREFIX_WHITE_LIST = [
11 | "https://ashtonfei.blogspot.com/",
12 | "https://ashtontheroad.blogspot.com/",
13 | "https://miaomiaofriends.blogspot.com/",
14 | "https://automatetheboring.blogspot.com/",
15 | "https://yougastube.blogspot.com/",
16 | ];
17 |
18 | function getBaseUrl_(url) {
19 | if (url.includes("://")) {
20 | url = url.split("://")[1];
21 | }
22 | if (url.includes("?")) {
23 | url = url.split("?")[0];
24 | }
25 | if (url.includes("#")) {
26 | url = url.split("#")[0];
27 | }
28 | return url;
29 | }
30 |
31 | function createJsonResponse_(data) {
32 | return ContentService.createTextOutput()
33 | .setMimeType(ContentService.MimeType.JSON)
34 | .setContent(JSON.stringify(data));
35 | }
36 |
37 | function updatePostViews_(url) {
38 | const key = getBaseUrl_(url);
39 | const props = PropertiesService.getScriptProperties();
40 | const views = (props.getProperty(key) || 1) * 1;
41 | props.setProperty(key, views + 1);
42 | return views;
43 | }
44 |
45 | /**
46 | * The Web App will be used as an API for the post views
47 | * @param {GoogleAppsScript.Events.DoGet} e
48 | */
49 | function doGet(e) {
50 | const { url } = e.parameter;
51 | if (!url) {
52 | return;
53 | }
54 | if (!PREFIX_WHITE_LIST.find((v) => url.startsWith(v))) {
55 | return;
56 | }
57 | const views = updatePostViews_(url);
58 | return createJsonResponse_({
59 | views,
60 | });
61 | }
62 |
--------------------------------------------------------------------------------
/projects/GAS107/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1TIk_XKe7yP4qPhgJGQYN-wLu2DeXaMp9IZkcBeg459xsOcp-8g9a2sfk",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS107/0.richTextValue.js:
--------------------------------------------------------------------------------
1 | const createDefaultTextStyle_ = () => {
2 | return SpreadsheetApp.newTextStyle()
3 | .setBold(false)
4 | .setStrikethrough(false)
5 | .setUnderline(false)
6 | .setItalic(false)
7 | .setFontSize(10)
8 | .setFontFamily("Default")
9 | .setForegroundColor("#000000")
10 | .build();
11 | };
12 |
13 | /**
14 | * @param {GoogleAppsScript.Spreadsheet.RichTextValue[]} values A list of rich text values
15 | * @param {string} by
16 | * @return {GoogleAppsScript.Spreadsheet.RichTextValue} A single rich text value
17 | */
18 | const join_ = (values, by = "\n") => {
19 | if (values.length === 0) return null;
20 | const richTextValue = SpreadsheetApp.newRichTextValue();
21 | const text = values.map((v) => (v ? v.getText() : "")).join(by);
22 | if (text === "") return null;
23 | richTextValue.setText(text);
24 | let start = 0;
25 | const defaultStyle = createDefaultTextStyle_();
26 | values.forEach((value, index) => {
27 | if (value === null) return;
28 | const runs = value.getRuns();
29 | runs.forEach((run) => {
30 | const text = run.getText();
31 | if (text === "") return;
32 | const link = run.getLinkUrl();
33 | const style = run.getTextStyle();
34 | const end = start + text.length;
35 | link && richTextValue.setLinkUrl(start, end, link);
36 | richTextValue.setTextStyle(start, end, style);
37 | start = end;
38 | });
39 | if (index == values.length - 1) return;
40 | richTextValue.setTextStyle(start, start + by.length, defaultStyle);
41 | start = start + by.length;
42 | });
43 | return richTextValue.build();
44 | };
45 |
46 | /**
47 | * @param {GoogleAppsScript.Spreadsheet.RichTextValue} value
48 | * @param {string} by
49 | * @return {GoogleAppsScript.Spreadsheet.RichTextValue[]} A list of rich text
50 | * values
51 | */
52 | const split_ = (value, by = "\n") => {
53 | const text = value.getText();
54 | const splittedValues = text.split(by);
55 | const values = splittedValues.map((v) =>
56 | SpreadsheetApp.newRichTextValue().setText(v)
57 | );
58 | let index = 0;
59 | let start = 0;
60 | value.getRuns().forEach((run) => {
61 | const text = run.getText();
62 | const style = run.getTextStyle();
63 | const link = run.getLinkUrl();
64 | const end = start + text.length;
65 | if (text.includes(by) === false) {
66 | link && values[index].setLinkUrl(start, end, link);
67 | values[index].setTextStyle(start, end, style);
68 | start = end;
69 | return;
70 | }
71 | if (text === by) {
72 | index++;
73 | start = 0;
74 | return;
75 | }
76 | text.split(by).forEach((v) => {
77 | if (v === "") return;
78 | const end = start + v.length;
79 | url && values[index].setLinkUrl(start, end, url);
80 | values[index].setTextStyle(start, end, style);
81 | index++;
82 | start = 0;
83 | });
84 | });
85 | return values.map((v) => v.build());
86 | };
87 |
--------------------------------------------------------------------------------
/projects/GAS107/1.demo.js:
--------------------------------------------------------------------------------
1 | const getNamedValues_ = () => {
2 | const ss = SpreadsheetApp.getActive();
3 | const values = {};
4 | const specialValues = {
5 | "\\n": "\n",
6 | "\\t": "\t",
7 | "\\s": " ",
8 | };
9 | ss.getNamedRanges().forEach((r) => {
10 | const name = r.getName();
11 | const value = r.getRange().getDisplayValue();
12 | if (value in specialValues) {
13 | return (values[name] = specialValues[value]);
14 | }
15 | values[name] = value;
16 | });
17 | return values;
18 | };
19 |
20 | const demoSplit = () => {
21 | const ss = SpreadsheetApp.getActive();
22 | const sheet = ss.getActiveSheet();
23 | const { inputSplit, outputSplit, splitBy } = getNamedValues_();
24 | const outputRange = sheet.getRange(outputSplit);
25 | const dataRegion = outputRange.getDataRegion();
26 | dataRegion.getNumColumns() === 1 && dataRegion.clearContent();
27 | SpreadsheetApp.flush();
28 |
29 | const value = sheet.getRange(inputSplit).getRichTextValue();
30 | const splitValues = split_(value, splitBy || "\n");
31 | const values = splitValues.map((v) => [v]);
32 | sheet
33 | .getRange(
34 | outputRange.getRow(),
35 | outputRange.getColumn(),
36 | values.length,
37 | values[0].length,
38 | )
39 | .setRichTextValues(values);
40 | };
41 |
42 | const demoJoin = () => {
43 | const ss = SpreadsheetApp.getActive();
44 | const sheet = ss.getActiveSheet();
45 | const { inputJoin, outputJoin, joinBy } = getNamedValues_();
46 | const outputRange = sheet.getRange(outputJoin);
47 | outputRange.clearContent();
48 | SpreadsheetApp.flush();
49 |
50 | const values = sheet
51 | .getRange(inputJoin)
52 | .getRichTextValues()
53 | .map((v) => v[0]);
54 | const joinedValue = join_(values, joinBy || "\n");
55 | outputRange.setRichTextValue(joinedValue);
56 | };
57 |
--------------------------------------------------------------------------------
/projects/GAS107/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8"
6 | }
7 |
--------------------------------------------------------------------------------
/projects/GAS108/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1CFjyD1nzOyj0Akse9IjgY4dL5uVEKlagmYxA9n9Iuhq85GNPsob1A3zj",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS108/0.utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {GoogleAppsScript.Spreadsheet.Spreadsheet} ss
3 | * @param {string|number} nameOrId
4 | * @returns {GoogleAppsScript.Spreadsheet.Sheet|undefined}
5 | */
6 | const _getSheet_ = (ss = SpreadsheetApp.getActive()) => (nameOrId) => {
7 | return ss
8 | .getSheets()
9 | .find(
10 | (sheet) => sheet.getName() == nameOrId || sheet.getSheetId() == nameOrId,
11 | );
12 | };
13 |
14 | const _getSettings_ =
15 | (ss = SpreadsheetApp.getActive()) => (sheetName = "Settings") => {
16 | const sheet = _getSheet_(ss)(sheetName);
17 | const settings = {};
18 | sheet
19 | .getDataRange()
20 | .getValues()
21 | .forEach(([key, value], index) => {
22 | if (index === 0) return;
23 | key = key.trim();
24 | if (!key) return;
25 | settings[key] = value;
26 | });
27 | return settings;
28 | };
29 |
30 | /**
31 | * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet
32 | * @param {Function} filter
33 | */
34 | const _getItemsFromSheet_ = (sheet, filter = null) => {
35 | const items = [];
36 | const createItem = (keys, values) => {
37 | const item = {};
38 | keys.forEach((key, index) => (item[key] = values[index]));
39 | return item;
40 | };
41 | const [headers, ...values] = sheet.getDataRange().getDisplayValues();
42 | values.forEach((rowValues, index) => {
43 | const item = createItem(headers, rowValues);
44 | item.row_ = index + 1;
45 | if (!filter) return items.push(item);
46 | filter(item) && items.push(item);
47 | });
48 | return items;
49 | };
50 |
--------------------------------------------------------------------------------
/projects/GAS108/1.main.js:
--------------------------------------------------------------------------------
1 | const getAppData_ = () => {
2 | const ss = SpreadsheetApp.getActive();
3 | const settings = _getSettings_(ss)("Settings");
4 | const sheetData = _getSheet_(ss)(settings.sheetNameData);
5 | const data = _getItemsFromSheet_(sheetData);
6 | return {
7 | data,
8 | settings,
9 | };
10 | };
11 |
12 | const doGet = () => {
13 | const data = getAppData_();
14 | const { app } = data.settings;
15 | const template = HtmlService.createTemplateFromFile("index.html");
16 | template.data = JSON.stringify(data);
17 | const output = template
18 | .evaluate()
19 | .setTitle(app || "App Name Not Assigned")
20 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
21 | .addMetaTag("viewport", "width=device-width, initial-scale=1");
22 | return output;
23 | };
24 |
--------------------------------------------------------------------------------
/projects/GAS108/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "webapp": {
7 | "executeAs": "USER_DEPLOYING",
8 | "access": "ANYONE_ANONYMOUS"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/projects/GAS109/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1BFHlJo3kYvwpUiPXQ9vh4vP6u8T1QCMDptYseDjqf_KW7JjGqw_EK7vn",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS109/0.utils.js:
--------------------------------------------------------------------------------
1 | const testLinebreaks = () => {
2 | const settings = getSettings_();
3 | const body = settings.signedBody;
4 | console.log(body);
5 | console.log(addLineBreaks_(body));
6 | };
7 |
8 | const error_ = (...data) => console.log(...data);
9 |
10 | const alert_ = (msg, title = "Alert") => {
11 | const ui = SpreadsheetApp.getUi();
12 | return ui.alert(title, msg, ui.ButtonSet.OK);
13 | };
14 |
15 | const confirm_ = (msg, title = "Confirm") => {
16 | const ui = SpreadsheetApp.getUi();
17 | return ui.alert(title, msg, ui.ButtonSet.YES_NO);
18 | };
19 |
20 | const createMenu_ = (ui) => (items, caption = null) => {
21 | const menu = caption ? ui.createMenu(caption) : ui.createAddonMenu();
22 | const createMenuItem = ({ title, items, caption, fn, sep }) => {
23 | if (title && items) {
24 | return menu.addSubMenu(createMenu_(ui)(items, title));
25 | }
26 | if (caption && fn) return menu.addItem(caption, fn);
27 |
28 | if (sep) return menu.addSeparator();
29 | };
30 | items.forEach(createMenuItem);
31 | return menu;
32 | };
33 |
34 | const getIdFromUrl_ = (url) => {
35 | if (url.includes("/folders/")) {
36 | return url.split("/folders/")[1].split("/")[0];
37 | }
38 | if (url.includes("/d/")) {
39 | return url.split("/d/")[1].split("/")[0];
40 | }
41 | };
42 |
43 | const getLinkedSheet_ = (ss = SpreadsheetApp.getActive()) => {
44 | return ss.getSheets().find((sheet) => sheet.getFormUrl());
45 | };
46 |
47 | const getLinkedForm_ = (ss = SpreadsheetApp.getActive()) => {
48 | const sheet = getLinkedSheet_(ss);
49 | if (!sheet) return;
50 | return FormApp.openByUrl(sheet.getFormUrl());
51 | };
52 |
53 | const getSettings_ = () => {
54 | const ss = SpreadsheetApp.getActive();
55 | const sheet = ss.getSheetByName("Settings");
56 | const settings = {};
57 | sheet
58 | .getDataRange()
59 | .getValues()
60 | .forEach(([key, value], index) => {
61 | if (index === 0) return;
62 | key = key
63 | .toString()
64 | .replace(/[\[\]]+/g, "")
65 | .trim();
66 | if (key.endsWith(":")) return;
67 | if (!key) return;
68 | settings[key] = value;
69 | });
70 | return settings;
71 | };
72 |
73 | const updateTextWithPlaceholders_ = (text, placeholders) => {
74 | Object.entries(placeholders).forEach(([key, value]) => {
75 | text = text.replace(new RegExp(`{{${key}}}`, "gi"), value);
76 | });
77 | return text;
78 | };
79 |
80 | const createItem_ = (keys, values) => {
81 | const item = {};
82 | keys.forEach((key, index) => {
83 | item[key] = values[index];
84 | });
85 | return item;
86 | };
87 |
88 | /**
89 | * @param {GoogleAppsScript.Forms.FormResponse} response
90 | */
91 | const getResponseValues_ = (response) => {
92 | const values = {};
93 | response.getItemResponses().forEach((itemResponse) => {
94 | const title = itemResponse.getItem().getTitle();
95 | const type = itemResponse.getItem().getType();
96 | if (type == FormApp.ItemType.PAGE_BREAK) return;
97 | if (type == FormApp.ItemType.SECTION_HEADER) return;
98 | values[title] = itemResponse.getResponse();
99 | });
100 | values["_id"] = response.getId();
101 | values["_email"] = response.getRespondentEmail();
102 | values["_timestamp"] = response.getTimestamp();
103 | return values;
104 | };
105 |
106 | const updateRowValues_ = (sheet) => (row, values) => {
107 | const headers = sheet.getRange("1:1").getDisplayValues()[0];
108 | const rowValues = sheet.getRange(`${row}:${row}`).getValues()[0];
109 | const newValues = rowValues.map((value, index) => {
110 | const header = headers[index];
111 | if (header in values) return values[header];
112 | return value;
113 | });
114 | sheet.getRange(row, 1, 1, newValues.length).setValues([newValues]);
115 | };
116 |
117 | const getItemsFromSheet_ = (sheet, filter = null) => {
118 | const items = [];
119 | const [headers, ...values] = sheet.getDataRange().getValues();
120 | values.forEach((rowData, index) => {
121 | const item = createItem_(headers, rowData);
122 | item._row = index + 2;
123 | if (!filter) return items.push(item);
124 | filter(item) && items.push(item);
125 | });
126 | return items;
127 | };
128 |
129 | const addLineBreaks_ = (body) => {
130 | body = body.replace(/\n/g, " ");
131 | return body.startsWith("<") ? body : `${body}
`;
132 | };
133 |
--------------------------------------------------------------------------------
/projects/GAS109/1.formSign.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {GoogleAppsScript.Events.DoGet} e
3 | */
4 | function doGet(e) {
5 | const { id } = e.parameter;
6 | const settings = getSettings_();
7 | const data = getAppData_(id, settings);
8 | const template = HtmlService.createTemplateFromFile("1.sign.html");
9 | template.data = JSON.stringify(data);
10 | template.name = settings.name;
11 | template.url = settings.urlDocumentPub + "?embedded=true";
12 | return template
13 | .evaluate()
14 | .setTitle(settings.name || "FormSign")
15 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
16 | .addMetaTag("viewport", "width=device-width, initial-scale=1");
17 | }
18 |
19 | const getAppData_ = (
20 | id,
21 | { headerSignStatus, headerUuid, headerName, headerEmail },
22 | ) => {
23 | const signature = {
24 | error: "",
25 | };
26 | if (!id) {
27 | signature.error = "Page Not Found";
28 | return signature;
29 | }
30 | try {
31 | const sheet = getLinkedSheet_();
32 | const filter = (v) => v[headerUuid] == id;
33 | const item = getItemsFromSheet_(sheet, filter)[0];
34 | if (!item) {
35 | return {
36 | error: `No item found for the id "${id}".`,
37 | };
38 | }
39 | item.status = item[headerSignStatus];
40 | item.id = item[headerUuid];
41 | item.email = item[headerEmail];
42 | item.fullname = item[headerName];
43 | return { ...item, ...signature };
44 | } catch (err) {
45 | signature.error = err.message;
46 | return signature;
47 | }
48 | };
49 |
50 | const sign_ = ({ data, id }) => {
51 | const sheet = getLinkedSheet_();
52 | const settings = getSettings_();
53 | const filter = (v) => v[settings.headerUuid] == id;
54 | const item = getItemsFromSheet_(sheet, filter)[0];
55 | if (!item) {
56 | throw new Error(`No record found for id "${id}".`);
57 | }
58 | if (item[settings.headerSignStatus] === "Signed") {
59 | return item;
60 | }
61 | const encodedData = data.split(",")[1];
62 | const decodedData = Utilities.base64Decode(encodedData);
63 | const imageBlob = Utilities.newBlob(decodedData);
64 | const templateId = getIdFromUrl_(settings.urlDocument);
65 | const template = DriveApp.getFileById(templateId);
66 | const copy = template.makeCopy();
67 | const doc = DocumentApp.openById(copy.getId());
68 | const body = doc.getBody();
69 |
70 | body.getTables().forEach((table) => {
71 | const foundElement = table.findText(
72 | settings.signaturePlaceholder || "{{signature}}",
73 | );
74 | if (!foundElement) return;
75 | const cell = foundElement
76 | .getElement()
77 | .getParent()
78 | .getParent()
79 | .asTableCell();
80 | const image = cell.clear().insertImage(0, imageBlob);
81 | const width = settings.signatureWidth || 200;
82 | const ratio = image.getHeight() / image.getWidth();
83 | const height = width * ratio;
84 | image.setWidth(width).setHeight(height);
85 | });
86 |
87 | const signedDate = new Date();
88 | item["date"] = signedDate.toLocaleDateString();
89 | Object.entries(item).forEach(([key, value]) => {
90 | const text = typeof value == "string" ? value : String(value);
91 | body.replaceText(`{{${key}}}`, text);
92 | });
93 | doc.saveAndClose();
94 | const filename = updateTextWithPlaceholders_(template.getName(), item);
95 | const pdf = copy.getAs("application/pdf").setName(`${filename}.pdf`);
96 | const folder = DriveApp.getFolderById(getIdFromUrl_(settings.urlFolder));
97 | copy.setName(filename);
98 | copy.moveTo(folder);
99 |
100 | const pdfFile = folder.createFile(pdf);
101 |
102 | const {
103 | signedSubject,
104 | signedBody,
105 | signedEmailEnabled,
106 | headerEmail,
107 | includeSignedPdf,
108 | signedBcc,
109 | headerSignStatus,
110 | headerSignedDate,
111 | headerSignedDocument,
112 | headerSignedPdf,
113 | } = settings;
114 | if (signedEmailEnabled) {
115 | const subject = updateTextWithPlaceholders_(signedSubject, item);
116 | const htmlBody = updateTextWithPlaceholders_(
117 | addLineBreaks_(signedBody),
118 | item,
119 | );
120 | const options = {
121 | htmlBody,
122 | };
123 | if (includeSignedPdf) {
124 | options.attachments = [pdf];
125 | }
126 | if (signedBcc) {
127 | options.bcc = settings.signedBcc;
128 | }
129 | GmailApp.sendEmail(item[headerEmail], subject, "", options);
130 | }
131 | const updatedValues = {
132 | [headerSignStatus]: "Signed",
133 | [headerSignedDate]: signedDate,
134 | [headerSignedDocument]: doc.getUrl(),
135 | [headerSignedPdf]: pdfFile.getUrl(),
136 | };
137 | updateRowValues_(sheet)(item._row, updatedValues);
138 | return getAppData_(id, settings);
139 | };
140 |
141 | const apiSign = (payload) => JSON.stringify(sign_(JSON.parse(payload)));
142 |
--------------------------------------------------------------------------------
/projects/GAS109/2.main.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | NAME: "FormSign",
3 | MENU: {
4 | INSTALL: {
5 | caption: "Install",
6 | fn: "actionInstallFormSign",
7 | },
8 | UNINSTALL: {
9 | caption: "Uninstall",
10 | fn: "actionUninstallFormSign",
11 | },
12 | },
13 | TRIGGER: {
14 | ON_FORM_SUBMIT: {
15 | fn: "triggerOnFormSubmit",
16 | },
17 | },
18 | KEY: {
19 | INSTALLED: "installed",
20 | },
21 | };
22 |
23 | const triggerOnFormSubmit = (e) => onFormSubmit_(e);
24 |
25 | const sendSignRequest_ = (values, settings) => {
26 | const recipient = values[settings.headerEmail];
27 | const { signSubject, signBody, signBcc } = settings;
28 | const subject = updateTextWithPlaceholders_(signSubject, values);
29 | const htmlBody = updateTextWithPlaceholders_(
30 | addLineBreaks_(signBody),
31 | values,
32 | );
33 | const options = {
34 | htmlBody,
35 | name: settings.name,
36 | };
37 | if (signBcc) {
38 | options.bcc = signBcc;
39 | }
40 | GmailApp.sendEmail(recipient, subject, "", options);
41 | };
42 |
43 | const deleteTriggers_ = (filter = null) => {
44 | ScriptApp.getProjectTriggers().forEach((t) => {
45 | if (!filter) ScriptApp.deleteTrigger(t);
46 | filter(t) && ScriptApp.deleteTrigger(t);
47 | });
48 | };
49 |
50 | const installTriggers_ = () => {
51 | const confirm = confirm_(
52 | "Are you sure to install FormSign for the linked form in the Spreadsheet?",
53 | );
54 | const ui = SpreadsheetApp.getUi();
55 | if (confirm !== ui.Button.YES) return;
56 |
57 | const { fn } = CONFIG.TRIGGER.ON_FORM_SUBMIT;
58 | const filter = (trigger) => trigger.getHandlerFunction() == fn;
59 | deleteTriggers_(filter);
60 | const form = getLinkedForm_();
61 | if (!form) throw new Error("No form is linked to current Spreadsheet.");
62 | const ss = SpreadsheetApp.getActive();
63 | ScriptApp.newTrigger(fn).forSpreadsheet(ss).onFormSubmit().create();
64 | const key = CONFIG.KEY.INSTALLED;
65 | const user = Session.getActiveUser().getEmail();
66 | PropertiesService.getScriptProperties().setProperty(key, user);
67 | onOpen();
68 | alert_("FormSign trigger has been installed.", "Success");
69 | };
70 |
71 | const uninstallTriggers_ = () => {
72 | const key = CONFIG.KEY.INSTALLED;
73 | const user = Session.getActiveUser().getEmail();
74 | const installed = PropertiesService.getScriptProperties().getProperty(key);
75 | if (installed !== user) {
76 | return alert_(
77 | `You can't uninstall it since you are not the owner(${installed}).`,
78 | "Error",
79 | );
80 | }
81 | const confirm = confirm_(
82 | "Are you sure to uninstall FormSign for the linked form in the Spreadsheet?",
83 | );
84 | const ui = SpreadsheetApp.getUi();
85 | if (confirm !== ui.Button.YES) return;
86 |
87 | const { fn } = CONFIG.TRIGGER.ON_FORM_SUBMIT;
88 | const filter = (trigger) => trigger.getHandlerFunction() == fn;
89 | deleteTriggers_(filter);
90 | PropertiesService.getScriptProperties().deleteProperty(key);
91 | onOpen();
92 | alert_("FormSign trigger has been uninstalled.", "Success");
93 | };
94 |
95 | const installFormSign_ = () => {
96 | installTriggers_();
97 | };
98 |
99 | /**
100 | * @param {GoogleAppsScript.Events.SheetsOnFormSubmit} e
101 | */
102 | const onFormSubmit_ = (e) => {
103 | const { range } = e || {};
104 | if (!range) {
105 | error_("Invalid event for SheetOnFormSubmit");
106 | }
107 | const settings = getSettings_();
108 | const sheet = e.range.getSheet();
109 | settings.sheet = sheet;
110 | const row = range.rowStart;
111 | settings.row = row;
112 |
113 | const headers = sheet.getRange("1:1").getDisplayValues()[0];
114 | const values = sheet.getRange(`${row}:${row}`).getDisplayValues()[0];
115 | const item = createItem_(headers, values);
116 | const uuid = Utilities.getUuid();
117 | item[settings.headerUuid] = uuid;
118 | const signUrl = `${settings.url}?id=${uuid}`;
119 | item[settings.headerSignUrl] = signUrl;
120 | item[settings.headerSignStatus] = "Pending";
121 |
122 | sendSignRequest_(item, settings);
123 |
124 | const updates = {
125 | [settings.headerUuid]: uuid,
126 | [settings.headerSignUrl]: signUrl,
127 | [settings.headerSignStatus]: item[settings.headerSignStatus],
128 | };
129 | updateRowValues_(settings.sheet)(settings.row, updates);
130 | };
131 |
132 | const onOpen = () => {
133 | const ui = SpreadsheetApp.getUi();
134 | const name = CONFIG.NAME;
135 | const key = CONFIG.KEY.INSTALLED;
136 | const installed = PropertiesService.getScriptProperties().getProperty(key);
137 | let menuItems = [CONFIG.MENU.INSTALL];
138 | if (installed) {
139 | menuItems = [CONFIG.MENU.UNINSTALL];
140 | }
141 | const menu = createMenu_(ui)(menuItems, name);
142 | menu.addToUi();
143 | };
144 |
145 | const actionInstallFormSign = () => installTriggers_();
146 | const actionUninstallFormSign = () => uninstallTriggers_();
147 |
--------------------------------------------------------------------------------
/projects/GAS109/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "webapp": {
7 | "executeAs": "USER_DEPLOYING",
8 | "access": "ANYONE_ANONYMOUS"
9 | }
10 | }
--------------------------------------------------------------------------------
/projects/GAS110/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1aFmPDBhMWLg1quPBD7LpKeu_VQ1ITJwLNxDU_olRGKDDRcOmdKYWauHO",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS110/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS110/main.js:
--------------------------------------------------------------------------------
1 | const mergeDocs = () => {
2 | const updateStatus_ = (status, message) => {
3 | rangeStatus.setValue(status);
4 | rangeMergedDoc.setValue(message);
5 | SpreadsheetApp.flush();
6 | };
7 | const ss = SpreadsheetApp.getActive();
8 | const sheet = ss.getSheetByName("App");
9 | const docUrlFilter = (v) =>
10 | v && v.startsWith("https://docs.google.com/document/");
11 | const rangeStatus = sheet.getRange("status");
12 | const rangeMergedDoc = sheet.getRange("mergedDoc");
13 | const rangeUrlFolderMerged = sheet.getRange("urlFolderMerged");
14 | const rangeKeepPageBreak = sheet.getRange("keepPagebreak");
15 | const keepPagebreak = rangeKeepPageBreak.getValue() === true;
16 |
17 | const tableInput = sheet.getRange("B3").getDataRegion();
18 |
19 | const urls = tableInput
20 | .getValues()
21 | .slice(1)
22 | .map((v) => v[1])
23 | .filter(docUrlFilter);
24 |
25 | if (urls.length <= 1) {
26 | updateStatus_(
27 | "Error",
28 | "At least two docs should be entered in the input table to merge.",
29 | );
30 | return;
31 | }
32 | let folder = null;
33 | const folderUrl = rangeUrlFolderMerged.getValue();
34 | const folderId = folderUrl.split("?id=")[1] ||
35 | folderUrl.split("/folders/")[1];
36 |
37 | if (folderId) {
38 | try {
39 | folder = DriveApp.getFolderById(folderId);
40 | } catch (err) {
41 | updateStatus_("Error", err.message);
42 | return;
43 | }
44 | }
45 |
46 | updateStatus_("Merging ...", null);
47 |
48 | try {
49 | const doc = mergeDocs_(urls, keepPagebreak);
50 | folder && DriveApp.getFileById(doc.getId()).moveTo(folder);
51 | updateStatus_("Success", doc.getUrl());
52 | } catch (err) {
53 | updateStatus_("Error", err.message);
54 | }
55 | };
56 |
57 | /**
58 | * @param {string[]} urlsOrIds a list of Google Docs urls or ids
59 | * @param {boolean} keepPagebreak Insert a pagebreak between docs to be merged
60 | * @param {string} filename The file name of the merged file
61 | * @return {GoogleAppsScript.Drive.File} a merged Google Docs file
62 | */
63 | const mergeDocs_ = (urlsOrIds, keepPagebreak = true, filename) => {
64 | const urlFilter = (v) => v.startsWith("https://");
65 |
66 | /**
67 | * @param {GoogleAppsScript.Document.Document} targetDoc
68 | * @param {boolean} keepPagebreak Insert a pagebreak between docs to be merged
69 | * @param {GoogleAppsScript.Document.Document} doc
70 | */
71 | const appendDoc_ = (targetDoc, doc, keepPagebreak = true) => {
72 | const bodyTarget = targetDoc.getBody();
73 | const body = doc.getBody();
74 | const countOfElement = body.getNumChildren();
75 | if (countOfElement === 0) return;
76 | keepPagebreak && bodyTarget.appendPageBreak();
77 |
78 | for (let i = 0; i < countOfElement; i++) {
79 | const child = body.getChild(i);
80 | const type = child.getType();
81 | const copy = child.copy();
82 | if (type === DocumentApp.ElementType.TABLE) {
83 | bodyTarget.appendTable(copy.asTable());
84 | } else if (type === DocumentApp.ElementType.PAGE_BREAK) {
85 | bodyTarget.appendPageBreak(copy.asPageBreak());
86 | } else if (type === DocumentApp.ElementType.INLINE_IMAGE) {
87 | bodyTarget.appendImage(copy.asInlineImage());
88 | } else if (type === DocumentApp.ElementType.HORIZONTAL_RULE) {
89 | bodyTarget.appendHorizontalRule();
90 | } else if (type === DocumentApp.ElementType.LIST_ITEM) {
91 | const listItem = copy.asListItem();
92 | bodyTarget
93 | .appendListItem(listItem)
94 | .setAttributes(listItem.getAttributes());
95 | } else {
96 | bodyTarget.appendParagraph(copy.asParagraph());
97 | }
98 | }
99 |
100 | return targetDoc;
101 | };
102 |
103 | const [firstDoc, ...restDocs] = urlsOrIds
104 | .map((v) => {
105 | try {
106 | return urlFilter(v)
107 | ? DocumentApp.openByUrl(v)
108 | : DocumentApp.openById(v);
109 | } catch (err) {
110 | return err.message;
111 | }
112 | })
113 | .filter((v) => typeof v !== "string");
114 |
115 | filename = filename || "Merged doc " + new Date().toLocaleString();
116 | const copy = DriveApp.getFileById(firstDoc.getId())
117 | .makeCopy()
118 | .setName(filename);
119 | const mergedDoc = DocumentApp.openById(copy.getId());
120 |
121 | restDocs.forEach((doc) => appendDoc_(mergedDoc, doc, keepPagebreak));
122 | mergedDoc.saveAndClose();
123 |
124 | return copy;
125 | };
126 |
--------------------------------------------------------------------------------
/projects/GAS111/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "1HS25IBbQ3MRZ3s-iHkIXvfzgNIDD6flqBE1ekcXJQ6umU8wBNPEV62bj",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS111/1.main.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | NAME: "GAS111",
3 | RANGE_NAME: {
4 | QUERY: "query",
5 | MAX: "max",
6 | PRINT_MESSAGES_ONLY: "printMessagesOnly",
7 | FILENAME: "filename",
8 | FOLDER: "folder",
9 | STATUS: "status",
10 | MESSAGE: "message",
11 | TABLE_THREADS: "tableThreads",
12 | MERGE: "merge",
13 | },
14 | STATUS: {
15 | ERROR: "Error",
16 | SUCCESS: "Success",
17 | WARNING: "Warning",
18 | },
19 | };
20 |
21 | /**
22 | * @param {GoogleAppsScript.Base.Blob} blob
23 | */
24 | const _toBase64ImageUri_ = (blob) => {
25 | const encodedData = Utilities.base64Encode(blob.getBytes());
26 | return `data:${blob.getContentType()};base64,${encodedData}`;
27 | };
28 |
29 | /**
30 | * @param {GoogleAppsScript.Gmail.GmailThread} thread
31 | */
32 | const createThreadContent_ = (thread, printMessagesOnly = false) => {
33 | const content = [];
34 | const imageLinks = [];
35 | thread.getMessages().forEach((message) => {
36 | let body = message.getBody();
37 | const images = message
38 | .getAttachments()
39 | .filter((v) => v.getName() === "image.png");
40 | if (images.length) {
41 | const cids = body.match(/img src="cid:[^"]+" /gi);
42 | if (cids) {
43 | cids.forEach((cid, index) => {
44 | const image = images[index];
45 | if (!image) return;
46 | const uri = _toBase64ImageUri_(image);
47 | body = body.replace(cid, `img src="${uri}"`);
48 | });
49 | }
50 | }
51 | const links = body.match(/img[^\<\>]+src="http[^"]+"/gi);
52 | if (links) {
53 | const urls = links.map((v) => v.split("src=")[1].slice(1, -1));
54 | imageLinks.push(...urls);
55 | }
56 | if (!printMessagesOnly) {
57 | const date = message.getDate().toLocaleString();
58 | const subject = message.getSubject();
59 | const from = message.getFrom();
60 | const to = message.getTo();
61 | const cc = message.getCc();
62 | const bcc = message.getBcc();
63 | content.push(`Subject: ${subject}
`);
64 | content.push(`Date: ${date}
`);
65 | content.push(`From: ${from}
`);
66 | content.push(`To: ${to}
`);
67 | cc && content.push(`CC: ${cc}
`);
68 | bcc && content.push(`BCC: ${bcc}
`);
69 | }
70 | content.push(`${body}
`);
71 | });
72 | let htmlBody = content.join("");
73 | if (imageLinks.length) {
74 | const requests = [...new Set(imageLinks)].map((url) => {
75 | return {
76 | url,
77 | method: "GET",
78 | };
79 | });
80 | UrlFetchApp.fetchAll(requests).forEach((response, index) => {
81 | const blob = response.getBlob();
82 | const uri = _toBase64ImageUri_(blob);
83 | const url = requests[index].url;
84 | htmlBody = htmlBody.replace(new RegExp(url, "g"), uri);
85 | });
86 | }
87 | return htmlBody;
88 | };
89 |
90 | const htmlToPdf_ = (body, name) => {
91 | const head = [
92 | "",
93 | ' ',
94 | ' ',
95 | "",
96 | ].join("");
97 | const html = [
98 | "",
99 | '',
100 | head,
101 | `${body}`,
102 | "",
103 | ].join("");
104 | const file = DriveApp.createFile(name, html, "text/html").setName(
105 | `${name}.html`,
106 | );
107 | const pdf = DriveApp.createFile(file.getAs("application/pdf")).setName(
108 | `${name}.pdf`,
109 | );
110 | file.setTrashed(true);
111 | return pdf;
112 | };
113 |
114 | const _updateStatus_ = (status, message) => {
115 | const ss = SpreadsheetApp.getActive();
116 | ss.getRange(CONFIG.RANGE_NAME.STATUS).setValue(status);
117 | ss.getRange(CONFIG.RANGE_NAME.MESSAGE).setValue(message);
118 | SpreadsheetApp.flush();
119 | };
120 |
121 | const _try_ = (fn) => {
122 | try {
123 | return fn();
124 | } catch (err) {
125 | console.log(err);
126 | _updateStatus_("Error", err.message);
127 | }
128 | };
129 |
130 | const print_ = () => {
131 | const ss = SpreadsheetApp.getActive();
132 | const tableStart = ss.getRange(CONFIG.RANGE_NAME.TABLE_THREADS);
133 | const printMessagesOnly = ss
134 | .getRange(CONFIG.RANGE_NAME.PRINT_MESSAGES_ONLY)
135 | .getValue();
136 | const merge = ss.getRange(CONFIG.RANGE_NAME.MERGE).getValue();
137 | let folder = ss.getRange(CONFIG.RANGE_NAME.FOLDER).getValue();
138 | if (folder) {
139 | folder = DriveApp.getFolderById(folder.split("/folders/")[1].split("/")[0]);
140 | } else {
141 | folder = DriveApp.getFolderById(ss.getId()).getParents().next();
142 | }
143 | const filename = ss.getRange(CONFIG.RANGE_NAME.FILENAME).getValue() ||
144 | `${CONFIG.NAME} Email Print ${new Date().toLocaleString()}`;
145 |
146 | const threads = tableStart
147 | .getDataRegion()
148 | .getValues()
149 | .filter((v) => {
150 | return v[0] === true && v[3];
151 | })
152 | .map((v) => {
153 | const threadId = v[3];
154 | return GmailApp.getThreadById(threadId);
155 | });
156 | if (threads.length <= 0) {
157 | return _updateStatus_(
158 | "Error",
159 | "No thread was selected in the thread table.",
160 | );
161 | }
162 | _updateStatus_("Printing ...", null);
163 |
164 | let message = folder.getUrl();
165 | if (merge) {
166 | const html = threads
167 | .map((thread) => createThreadContent_(thread, printMessagesOnly))
168 | .join(" ");
169 | const pdf = htmlToPdf_(html, filename);
170 | folder && pdf.moveTo(folder);
171 | message = pdf.getUrl();
172 | } else {
173 | threads.forEach((thread) => {
174 | const html = createThreadContent_(thread, printMessagesOnly);
175 | const subject = thread.getFirstMessageSubject() + ".pdf";
176 | const pdf = htmlToPdf_(html, subject);
177 | folder && pdf.moveTo(folder);
178 | });
179 | }
180 | tableStart
181 | .getSheet()
182 | .getRange(
183 | tableStart.getRow() + 1,
184 | tableStart.getColumn(),
185 | tableStart.getDataRegion().getNumRows(),
186 | 1,
187 | )
188 | .setValue(false);
189 | return _updateStatus_(CONFIG.STATUS.SUCCESS, message);
190 | };
191 |
192 | const search_ = () => {
193 | const ss = SpreadsheetApp.getActive();
194 | _updateStatus_("Searching ...", null);
195 | const query = ss.getRange(CONFIG.RANGE_NAME.QUERY).getValue() || "";
196 | const max = ss.getRange(CONFIG.RANGE_NAME.MAX).getValue() * 1 || 20;
197 | const threads = GmailApp.search(query, 0, max);
198 | const values = threads.map((thread) => {
199 | return [
200 | false,
201 | thread.getFirstMessageSubject(),
202 | thread.getMessages()[0].getPlainBody().slice(0, 30) + " ...",
203 | thread.getId(),
204 | thread.getMessages().length,
205 | thread.getLastMessageDate(),
206 | ];
207 | });
208 | values.unshift([
209 | "Select",
210 | "1st Message Subject",
211 | "1st Message Preview",
212 | "Thread ID",
213 | "Message Count",
214 | "Last Message Date",
215 | ]);
216 | const tableStart = ss.getRange(CONFIG.RANGE_NAME.TABLE_THREADS);
217 | tableStart.getDataRegion().clearContent();
218 |
219 | tableStart
220 | .getSheet()
221 | .getRange(
222 | tableStart.getRow(),
223 | tableStart.getColumn(),
224 | values.length,
225 | values[0].length,
226 | )
227 | .setValues(values);
228 | _updateStatus_("Success", `${threads.length} threads found.`);
229 | };
230 |
231 | const onPrint = () => _try_(print_);
232 |
233 | const onSearch = () => _try_(search_);
234 |
--------------------------------------------------------------------------------
/projects/GAS111/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS112/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"16il3BO-vQEGXeS13fGO69BlOqBPfMlNjcc6floNAr2lYLrx95ZFfKl-H","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/projects/GAS112"}
2 |
--------------------------------------------------------------------------------
/projects/GAS112/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "Asia/Shanghai",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS112/code.js:
--------------------------------------------------------------------------------
1 | const deleteTriggers_ = () => {
2 | ScriptApp.getProjectTriggers().forEach((t) => ScriptApp.deleteTrigger(t));
3 | };
4 |
5 | const installTrigger = () => {
6 | deleteTriggers_();
7 | const ss = SpreadsheetApp.getActive();
8 | ScriptApp.newTrigger("triggerOnFormSubmit")
9 | .forSpreadsheet(SpreadsheetApp.getActive())
10 | .onFormSubmit()
11 | .create();
12 | PropertiesService.getScriptProperties().setProperty("installed", "yes");
13 | onOpen();
14 | ss.toast("Trigger has been installed.", "GAS122", 5);
15 | };
16 |
17 | const uninstallTrigger = () => {
18 | deleteTriggers_();
19 | PropertiesService.getScriptProperties().deleteProperty("installed");
20 | onOpen();
21 | SpreadsheetApp.getActive().toast(
22 | "Trigger has been uninstalled.",
23 | "GAS122",
24 | 5,
25 | );
26 | };
27 |
28 | const onOpen = () => {
29 | const installed = PropertiesService.getScriptProperties().getProperty(
30 | "installed",
31 | );
32 | const menu = SpreadsheetApp.getUi().createMenu("GAS112");
33 | if (installed) {
34 | menu.addItem("Uninstall trigger", "uninstallTrigger");
35 | } else {
36 | menu.addItem("Install trigger", "installTrigger");
37 | }
38 | menu.addToUi();
39 | };
40 |
41 | /**
42 | * @param {GoogleAppsScript.Events.SheetsOnFormSubmit} e
43 | */
44 | const triggerOnFormSubmit = (e) => {
45 | const settings = getSettings_("Settings");
46 | const row = e.range.getRow();
47 | const sheet = e.range.getSheet();
48 | const { errorHeader, imageHeader, imageField, emailField, emailImage, size } =
49 | settings;
50 | const updates = {
51 | [errorHeader]: "Success",
52 | };
53 |
54 | try {
55 | const values = parseNamedValues_(e.namedValues);
56 | const imageUrl = values[imageField];
57 | const imageId = imageUrl.split("id=")[1];
58 | if (!imageId) {
59 | throw new Error(`No image uploaded at the field "${imageField}".`);
60 | }
61 | const imageFile = DriveApp.getFileById(imageId);
62 | const newImage = compressImageFile_(imageFile, size);
63 |
64 | if (settings.trashMe) {
65 | imageFile.setTrashed(true);
66 | }
67 |
68 | if (emailImage && values[emailField]) {
69 | sendImage_(values, newImage, settings);
70 | }
71 | updates[imageHeader] = newImage.getUrl();
72 | } catch (err) {
73 | updates[errorHeader] = err.message;
74 | } finally {
75 | console.log(updates);
76 | updateRowValues_(sheet, row)(updates, true);
77 | }
78 | };
79 |
--------------------------------------------------------------------------------
/projects/GAS112/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {GoogleAppsScript.Drive.File} file an image file
3 | * @param {number} size The width of the image to be created
4 | * @returns {GoogleAppsScript.Drive.File} a new image file with smaller size
5 | */
6 | const compressImageFile_ = (file, size = 800) => {
7 | const accessToken = ScriptApp.getOAuthToken();
8 | const request = {
9 | url: `https://drive.google.com/thumbnail?id=${file.getId()}&sz=${size}`,
10 | headers: {
11 | Authorization: `Bearer ${accessToken}`,
12 | },
13 | muteHttpExceptions: true,
14 | };
15 |
16 | const [response] = UrlFetchApp.fetchAll([request]);
17 |
18 | if (response.getResponseCode() != 200) {
19 | throw new Error(response.getContentText());
20 | }
21 | const blog = response.getBlob();
22 | const nameSplits = file.getName().split(".");
23 | const name = [
24 | ...nameSplits.slice(0, -1).join("."),
25 | ` SZ${size}.`,
26 | ...nameSplits.slice(-1),
27 | ].join("");
28 | blog.setName(name);
29 | return file.getParents().next().createFile(blog);
30 | };
31 |
32 | const getSettings_ = (sheetName = "Settings") => {
33 | const settings = {};
34 | const ss = SpreadsheetApp.getActive();
35 | const sheet = ss.getSheetByName(sheetName);
36 | if (!sheet) {
37 | throw new Error(`Sheet "${sheetName}" was not found in the Spreadsheet.`);
38 | }
39 | sheet
40 | .getDataRange()
41 | .getValues()
42 | .forEach(([key, value], index) => {
43 | if (index === 0) return;
44 | key = key.trim();
45 | if (!key) return;
46 | settings[key] = value;
47 | });
48 | return settings;
49 | };
50 |
51 | const parseNamedValues_ = (namedValues, delimeter = ", ") => {
52 | const values = {};
53 | Object.entries(namedValues).forEach(([key, value]) => {
54 | values[key] = value.join(delimeter);
55 | });
56 | return values;
57 | };
58 |
59 | /**
60 | * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet
61 | * @param {number} row The row position starts from 1
62 | * @param {object} updates An object with headers as keys and updates as values
63 | * @param {boolean} createMissingHeaders Create the missing headers if the
64 | * header is not found in the sheet
65 | * @returns void
66 | */
67 | const updateRowValues_ =
68 | (sheet, row) => (updates, createMissingHeader = true) => {
69 | const [headers] = sheet.getDataRange().getDisplayValues();
70 | Object.entries(updates).forEach(([key, value]) => {
71 | let index = headers.indexOf(key);
72 | if (index == -1) {
73 | if (!createMissingHeader) {
74 | return;
75 | }
76 | headers.push(key);
77 | index = headers.length - 1;
78 | sheet.getRange(1, index + 1).setValue(key);
79 | }
80 | sheet.getRange(row, index + 1).setValue(value);
81 | });
82 | };
83 |
84 | const sendImage_ = (values, image, settings) => {
85 | const recipient = values[settings.emailField];
86 | const subject = "Your Compressed Image: GAS122";
87 | const options = {
88 | htmlBody: "Here is the compressed image
",
89 | attachments: [image.getBlob()],
90 | };
91 | GmailApp.sendEmail(recipient, subject, "", options);
92 | };
93 |
--------------------------------------------------------------------------------
/projects/GAS113/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"14UUsie87BrheZ_XWsFT91G5Wsw_3SJuK0B__0SdQyK6Q7Us_pFS3Mx1f","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/projects/GAS113","parentId":["1qfYp6iyv-IQbCj5w1TBKtFeIkarFLsgNhxjbb32HEMM"]}
2 |
--------------------------------------------------------------------------------
/projects/GAS113/0.utils.js:
--------------------------------------------------------------------------------
1 | const _getUi_ = () => SpreadsheetApp.getUi();
2 |
3 | const _flush_ = () => SpreadsheetApp.flush();
4 |
5 | const _toast_ = (title = "Toast") => (msg, timeoutSeconds = 3) => {
6 | return SpreadsheetApp.getActive().toast(msg, title, timeoutSeconds);
7 | };
8 |
9 | const _createAlert_ = (title = "Alert") => (msg) => {
10 | const ui = _getUi_();
11 | return ui.alert(title, msg, ui.ButtonSet.OK);
12 | };
13 |
14 | const _error_ = _createAlert_("Error");
15 | const _warning_ = _createAlert_("Warning");
16 | const _success_ = _createAlert_("Success");
17 |
18 | const _createConfirm_ = (title = "Confirm") => (msg, buttons) => {
19 | const ui = _getUi_();
20 | return ui.alert(title, msg, buttons || ui.ButtonSet.YES_NO);
21 | };
22 |
23 | const _updateTextWithPlaceholders_ = (data) => (text) => {
24 | Object.entries(data).forEach(([key, value]) => {
25 | text = text.replace(new RegExp(`{{${key}}}`, "gi"), value);
26 | });
27 | return text;
28 | };
29 |
30 | const _getNamedValues_ =
31 | (ss = SpreadsheetApp.getActive()) => (filter = null) => {
32 | const values = {};
33 | ss.getNamedRanges().forEach((namedRange) => {
34 | const key = namedRange.getName();
35 | const value = namedRange.getRange().getDisplayValue();
36 | if (!filter) {
37 | return (values[key] = value);
38 | }
39 | if (!filter(key)) return;
40 | values[key] = value;
41 | });
42 | return values;
43 | };
44 |
45 | const _createQueryString_ = (params) => {
46 | return Object.entries(params)
47 | .map(([k, v]) => `${k}=${v}`)
48 | .join("&");
49 | };
50 |
51 | const _exportSpreadsheetAsPdf_ = (
52 | spreadsheetId,
53 | accessToken = ScriptApp.getOAuthToken(),
54 | options = {},
55 | ) => {
56 | const queryParams = {
57 | size: "Letter",
58 | format: "pdf",
59 | portrait: true,
60 | download: true,
61 | fitw: true,
62 | gridlines: false,
63 | top_margin: 0.25,
64 | right_margin: 0.25,
65 | bottom_margin: 0.25,
66 | left_margin: 0.25,
67 | ...options,
68 | };
69 | const queryString = _createQueryString_(queryParams);
70 | const url =
71 | `https://docs.google.com/spreadsheets/d/${spreadsheetId}/export?${queryString}`;
72 | return UrlFetchApp.fetch(url, {
73 | method: "GET",
74 | headers: { Authorization: `Bearer ${accessToken}` },
75 | }).getBlob();
76 | };
77 |
78 | const _try_ = (fn) => {
79 | try {
80 | return fn();
81 | } catch (err) {
82 | _error_(err.message);
83 | }
84 | };
85 |
--------------------------------------------------------------------------------
/projects/GAS113/1.code.js:
--------------------------------------------------------------------------------
1 | const CONFIG = {
2 | RANGE: {
3 | START: "start", // where the start time of the timer is saved
4 | TASK: "task", // where the row number of the selected task is saved
5 | REFRESH: "H1", // a cell used for refreshing the timer (the cell will be cleared by the script)
6 | REPORT_FROM: "reportFrom", // where the email of the active user will be entered
7 | REPORT_TO: "reportTo", // where the email of the report should be sent to
8 | REPORT_BY: "reportBy", // where the name of the sender saved
9 | },
10 | SHEET: {
11 | TASKS: "Tasks",
12 | RECORDS: "Records",
13 | REPORT: "Report",
14 | },
15 | EMAIL: {
16 | SUBJECT: "Time Tracking Report to {{reportClient}}", // placeholders (named ranges for report) can be used
17 | CC: "",
18 | BCC: "",
19 | },
20 | };
21 |
22 | const refreshTimer = () => {
23 | const start = SpreadsheetApp.getActive().getRange(CONFIG.RANGE.START);
24 | const range = start.getSheet().getRange(CONFIG.RANGE.REFRESH);
25 | range.setValue("refresh");
26 | _flush_();
27 | range.setValue(null);
28 | };
29 |
30 | const installTrigger_ = () => {
31 | const fn = "refreshTimer";
32 | uninstallTrigger_();
33 | ScriptApp.newTrigger(fn).timeBased().everyMinutes(1).create();
34 | };
35 |
36 | const uninstallTrigger_ = () => {
37 | const fn = "refreshTimer";
38 | ScriptApp.getProjectTriggers().forEach((t) => {
39 | if (t.getHandlerFunction() == fn) ScriptApp.deleteTrigger(t);
40 | });
41 | };
42 |
43 | const startTimer_ = () => {
44 | const ss = SpreadsheetApp.getActive();
45 | const start = ss.getRange(CONFIG.RANGE.START).getValue();
46 | if (start) {
47 | throw new Error("There is a running timer.");
48 | }
49 | const selectedRow = ss.getRange(CONFIG.RANGE.TASK).getValue();
50 | if (selectedRow) {
51 | throw new Error("There is a running timer.");
52 | }
53 |
54 | const activeCell = ss.getActiveCell();
55 | const row = activeCell.getRow();
56 | if (row <= 2) {
57 | throw new Error("Active row is not a task item.");
58 | }
59 |
60 | let [task, project] = activeCell
61 | .getSheet()
62 | .getRange(`${row}:${row}`)
63 | .getValues()[0];
64 |
65 | task = task.trim();
66 | project = project.trim();
67 |
68 | if (!task || !project) {
69 | throw new Error("No task or project in the active row.");
70 | }
71 |
72 | ss.getRange(CONFIG.RANGE.START).setValue(new Date());
73 | ss.getRange(CONFIG.RANGE.TASK).setValue(row);
74 | installTrigger_();
75 | _toast_("Timer")("Started");
76 | };
77 |
78 | const stopTimer_ = () => {
79 | const ss = SpreadsheetApp.getActive();
80 | const end = new Date();
81 | const start = ss.getRange(CONFIG.RANGE.START).getValue();
82 | const row = ss.getRange(CONFIG.RANGE.TASK).getValue();
83 |
84 | if (!start || !row) {
85 | throw new Error("No timer to be stopped.");
86 | }
87 |
88 | const ui = _getUi_();
89 | const confirm = _createConfirm_("Confirm")(
90 | `Are you sure to stop the timer?
91 |
92 | Yes - Stop timer and save it as a record
93 | No - Keep timer and do nothing
94 | Cancel - Cancel timer without saving a record`,
95 | ui.ButtonSet.YES_NO_CANCEL,
96 | );
97 | if (ui.Button.CANCEL == confirm) {
98 | ss.getRange(CONFIG.RANGE.START).setValue(null);
99 | ss.getRange(CONFIG.RANGE.TASK).setValue(null);
100 | return;
101 | }
102 |
103 | if (ui.Button.YES != confirm) {
104 | return;
105 | }
106 |
107 | const sheetTasks = ss.getSheetByName(CONFIG.SHEET.TASKS);
108 | const values = sheetTasks.getRange(`${row}:${row}`).getValues()[0];
109 | const [task, project, status, billable] = values;
110 | const hourlyRate = values[6];
111 |
112 | const sheetRecords = ss.getSheetByName(CONFIG.SHEET.RECORDS);
113 | const record = [task, project, status, billable, hourlyRate, start, end];
114 | sheetRecords
115 | .getRange(sheetRecords.getLastRow() + 1, 1, 1, record.length)
116 | .setValues([record]);
117 |
118 | uninstallTrigger_();
119 | ss.getRange(CONFIG.RANGE.TASK).setValue(null);
120 | ss.getRange(CONFIG.RANGE.START).setValue(null);
121 |
122 | _toast_("Timer")("Stopped");
123 | };
124 |
125 | const sendReport_ = () => {
126 | const ss = SpreadsheetApp.getActive();
127 | const sheet = ss.getSheetByName(CONFIG.SHEET.REPORT);
128 | if (!sheet) {
129 | throw new Error(`Sheet "Report" was missing.`);
130 | }
131 | sheet.activate();
132 | const ui = _getUi_();
133 | const confirm = _createConfirm_("Confirm")(
134 | "Are you sure to send this report?",
135 | );
136 | if (ui.Button.YES != confirm) {
137 | return;
138 | }
139 | const activeUser = Session.getActiveUser().getEmail();
140 | sheet.getRange(CONFIG.RANGE.REPORT_FROM).setValue(activeUser);
141 | SpreadsheetApp.flush();
142 |
143 | const filter = (v) => /report.+/.test(v);
144 | const data = _getNamedValues_(ss)(filter);
145 | textUpdater = _updateTextWithPlaceholders_(data);
146 | const subject = textUpdater(CONFIG.EMAIL.SUBJECT);
147 | const recipient = data[CONFIG.RANGE.REPORT_TO];
148 | const template = HtmlService.createTemplateFromFile("email.html");
149 | template.data = data;
150 |
151 | const report = _exportSpreadsheetAsPdf_(
152 | ss.getId(),
153 | ScriptApp.getOAuthToken(),
154 | {
155 | gid: sheet.getSheetId(),
156 | },
157 | ).setName("Time Tracking Reprot.pdf");
158 |
159 | const options = {
160 | htmlBody: template.evaluate().getContent(),
161 | name: data[CONFIG.RANGE.REPORT_BY],
162 | attachments: [report],
163 | cc: CONFIG.EMAIL.CC,
164 | bcc: CONFIG.EMAIL.BCC,
165 | };
166 | GmailApp.sendEmail(recipient, subject, "", options);
167 | _toast_("Report")("Sent!");
168 | };
169 |
170 | const onStartTimer = () => _try_(startTimer_);
171 |
172 | const onStopTimer = () => _try_(stopTimer_);
173 |
174 | const onSendReport = () => _try_(sendReport_);
175 |
176 | const onOpen = () => {
177 | const ui = _getUi_();
178 | const menu = ui.createMenu("GAS113");
179 | menu.addItem("Send report", "onSendReport");
180 | menu.addToUi();
181 | };
182 |
--------------------------------------------------------------------------------
/projects/GAS113/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS113/email.html:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
Hi != data.reportClient ?>,
10 |
11 |
12 | Here is the time tracking report for the tasks have been complted from !=
13 | data.reportStart ?> to != data.reportEnd ?>, check the details in the
14 | attachment.
15 |
16 |
17 |
Total hours: != data.reportHours ?>
18 |
Total costs: != data.reportCosts ?>
19 |
20 |
Thanks, != data.reportBy ?>
21 |
22 |
--------------------------------------------------------------------------------
/projects/GAS114/.clasp.json:
--------------------------------------------------------------------------------
1 | {
2 | "scriptId": "14aSuZN957Ee6ZplhAe8MBESz2MuiDr-zG9tUb4fdjFbrzYDk7mdyvjwp",
3 | "rootDir": "./"
4 | }
5 |
--------------------------------------------------------------------------------
/projects/GAS114/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {},
4 | "exceptionLogging": "STACKDRIVER",
5 | "runtimeVersion": "V8",
6 | "oauthScopes": [
7 | "https://mail.google.com/",
8 | "https://www.googleapis.com/auth/drive",
9 | "https://www.googleapis.com/auth/script.scriptapp",
10 | "https://www.googleapis.com/auth/spreadsheets.currentonly",
11 | "https://www.googleapis.com/auth/cloud-platform",
12 | "https://www.googleapis.com/auth/cloud-vision",
13 | "https://www.googleapis.com/auth/script.external_request"
14 | ]
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/projects/GAS114/forms.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {GoogleAppsScript.Events.SheetsOnFormSubmit} e
3 | */
4 | const triggerOnFormSubmit = (e) => {
5 | const options = {
6 | labels: 3,
7 | colors: 2,
8 | text: true,
9 | };
10 | const parseNamedValues_ = (values) => {
11 | const item = {};
12 | Object.entries(values).forEach(([key, value]) => {
13 | item[key] = value.join(", ");
14 | });
15 | return item;
16 | };
17 | const item = parseNamedValues_(e.namedValues);
18 |
19 | createImageUri_ = (id) => {
20 | DriveApp.getFileById(id).setSharing(
21 | DriveApp.Access.ANYONE_WITH_LINK,
22 | DriveApp.Permission.VIEW,
23 | );
24 | return `https://drive.google.com/thumbnail?id=${id}&sz=1080`;
25 | };
26 |
27 | const createImageContent_ = (id) => {
28 | return Utilities.base64Encode(
29 | DriveApp.getFileById(id).getBlob().getBytes(),
30 | );
31 | };
32 |
33 | const id = item["Image"].split("id=")[1];
34 | const imageUri = createImageUri_(id);
35 | const imageContent = createImageContent_(id);
36 | const preivew = `=IMAGE("${imageUri}")`;
37 | const annotation = imageAnnotate_(imageContent, options);
38 | const data = parseImageAnnotation_(annotation);
39 |
40 | const results = [
41 | preivew,
42 | data.labels ? data.labels.join(", ") : null,
43 | data.text || null,
44 | data.colors ? data.colors.slice(0, options.colors).join(", ") : null,
45 | "OK" || data.error,
46 | ];
47 |
48 | const sheet = e.range.getSheet();
49 | const row = e.range.rowStart;
50 | sheet.getRange(`D${row}:H${row}`).setValues([results]);
51 |
52 | const recipient = item["Email Address"];
53 | const subject = "Image Labels, Text, Colors for Your Upload: GAS114";
54 | const htmlBody = `
55 |
56 | Lables: ${results[1]}
57 | Text: ${results[2]}
58 | Colors: ${results[3]}
59 | Status: ${results[4]}
60 | `;
61 |
62 | GmailApp.sendEmail(recipient, subject, "", {
63 | htmlBody,
64 | });
65 | };
66 |
--------------------------------------------------------------------------------
/projects/GAS114/main.js:
--------------------------------------------------------------------------------
1 | const installTrigger = () => {
2 | const ss = SpreadsheetApp.getActive();
3 | ScriptApp.getProjectTriggers().forEach((t) => ScriptApp.deleteTrigger(t));
4 | ScriptApp.newTrigger("triggerOnFormSubmit")
5 | .forSpreadsheet(ss)
6 | .onFormSubmit()
7 | .create();
8 | PropertiesService.getScriptProperties().setProperty("installed", "true");
9 | onOpen();
10 | };
11 |
12 | const uninstallTrigger = () => {
13 | ScriptApp.getProjectTriggers().forEach((t) => ScriptApp.deleteTrigger(t));
14 | PropertiesService.getScriptProperties().deleteProperty("installed");
15 | onOpen();
16 | };
17 |
18 | const onOpen = () => {
19 | const ui = SpreadsheetApp.getUi();
20 | const menu = ui.createMenu("GAS114");
21 | const installed =
22 | PropertiesService.getScriptProperties().getProperty("installed");
23 | menu.addItem("Image annotate", "demoSheetsIntegration");
24 | if (installed) {
25 | menu.addItem("Uninstall trigger", "uninstallTrigger");
26 | } else {
27 | menu.addItem("Install trigger", "installTrigger");
28 | }
29 | menu.addToUi();
30 | };
31 |
--------------------------------------------------------------------------------
/projects/GAS114/sheets.js:
--------------------------------------------------------------------------------
1 | const demoSheetsIntegration = () => {
2 | const ok = "OK";
3 | const ss = SpreadsheetApp.getActive();
4 | const sheet = ss.getActiveSheet();
5 | const [_, ...values] = sheet.getDataRange().getValues();
6 | values.forEach(([imageUri, _, labels, text, colors, status], i) => {
7 | if (status == ok) return;
8 | if (!imageUri) return;
9 | if (!labels && !text && !colors) return;
10 | const row = i + 2;
11 | sheet.getRange(`F${row}:G${row}`).setValues([["Annotating ...", null]]);
12 | SpreadsheetApp.flush();
13 | if (imageUri.includes("drive.google.com")) {
14 | const id = imageUri.split("id=")[1].split(/[]+/)[0];
15 | imageUri = Utilities.base64Encode(
16 | DriveApp.getFileById(id).getBlob().getBytes(),
17 | );
18 | }
19 | const annotation = imageAnnotate_(imageUri, { labels, text, colors });
20 | const data = parseImageAnnotation_(annotation);
21 | console.log(imageUri);
22 | console.log(data);
23 | const results = [
24 | data.labels ? data.labels.join(", ") : labels,
25 | data.text ? data.text : text,
26 | data.colors ? data.colors.slice(0, colors).join(", ") : colors,
27 | data.error ? data.error : ok,
28 | new Date(),
29 | ];
30 | sheet.getRange(`C${row}:G${row}`).setValues([results]);
31 | });
32 | };
33 |
--------------------------------------------------------------------------------
/projects/GAS114/vision.js:
--------------------------------------------------------------------------------
1 | const imageAnnotate_ = (imageUri, { labels, text, colors }) => {
2 | const token = ScriptApp.getOAuthToken();
3 | const features = [];
4 | if (labels) {
5 | features.push({
6 | type: "LABEL_DETECTION",
7 | maxResults: labels,
8 | });
9 | }
10 | if (text) {
11 | features.push({
12 | type: "TEXT_DETECTION",
13 | });
14 | }
15 | if (colors) {
16 | features.push({
17 | type: "IMAGE_PROPERTIES",
18 | });
19 | }
20 |
21 | const image = {};
22 | if (imageUri.startsWith("http")) {
23 | image.source = { imageUri };
24 | } else {
25 | image.content = imageUri;
26 | }
27 |
28 | const payload = {
29 | requests: [
30 | {
31 | image,
32 | features,
33 | },
34 | ],
35 | };
36 | const request = {
37 | method: "POST",
38 | url: `https://vision.googleapis.com/v1/images:annotate`,
39 | headers: {
40 | Authorization: `Bearer ${token}`,
41 | },
42 | contentType: "application/json",
43 | payload: JSON.stringify(payload),
44 | muteHttpExceptions: true,
45 | };
46 | const [response] = UrlFetchApp.fetchAll([request]);
47 | return JSON.parse(response.getContentText());
48 | };
49 |
50 | const parseImageAnnotation_ = (data) => {
51 | const results = {};
52 | if (data.error) {
53 | results.error = data.error;
54 | return results;
55 | }
56 | const {
57 | error,
58 | labelAnnotations,
59 | textAnnotations,
60 | imagePropertiesAnnotation,
61 | } = data?.responses?.[0];
62 |
63 | console.log(data);
64 | console.log(error);
65 |
66 | if (error) {
67 | results.error = JSON.stringify(error, null, 4);
68 | return results;
69 | }
70 |
71 | const rgbToHex_ = ({ red, green, blue }) => {
72 | const toHex_ = (v) => v.toString(16).padStart(2, "0");
73 | return `#${toHex_(red)}${toHex_(green)}${toHex_(blue)}`.toUpperCase();
74 | };
75 | if (labelAnnotations) {
76 | results.labels = labelAnnotations.map((v) => v.description);
77 | }
78 | if (imagePropertiesAnnotation) {
79 | results.colors = imagePropertiesAnnotation.dominantColors.colors.map((v) =>
80 | rgbToHex_(v.color),
81 | );
82 | }
83 | if (textAnnotations) {
84 | results.text = textAnnotations[0].description;
85 | }
86 | return results;
87 | };
88 |
--------------------------------------------------------------------------------
/projects/GAS115/.clasp.json:
--------------------------------------------------------------------------------
1 | {"scriptId":"15_1mgojBA6FbhSWO5r32ebrX82wvUvnqeyte4LnTNOX3cFVA8CqTuClH","rootDir":"/Users/ashton/github/ashtonfei/google-apps-script-projects/projects/GAS115","parentId":["1UkQu-vE7UPGkzhduCXQ_MirUU5NrYnjs3z6iLBrbYR8"]}
2 |
--------------------------------------------------------------------------------
/projects/GAS115/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "timeZone": "America/New_York",
3 | "dependencies": {
4 | },
5 | "exceptionLogging": "STACKDRIVER",
6 | "runtimeVersion": "V8"
7 | }
--------------------------------------------------------------------------------
/projects/GAS115/main.js:
--------------------------------------------------------------------------------
1 | const parseOptions_ = (value) => {
2 | if (value === "") return [];
3 | if (/""/.test(value)) {
4 | value = value.replace(/""/g, '\\"');
5 | }
6 | try {
7 | return JSON.parse(`[${value}]`);
8 | } catch (err) {
9 | return value.split(/,\s/);
10 | }
11 | };
12 |
13 | const test = () => {
14 | const sheet = SpreadsheetApp.getActive().getSheetByName("orders");
15 | const values = sheet.getDataRange().getValues();
16 | const items = values.map((v) => {
17 | const value = v[1];
18 | const items = parseOptions_(value);
19 | const length = items.length;
20 | return {
21 | value,
22 | items,
23 | length,
24 | };
25 | });
26 | console.log(items);
27 | };
28 |
--------------------------------------------------------------------------------