├── .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 | // for placeholder name in the HTML 11 | template.name = "Ashton Fei"; 12 | // 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 ,

3 |

This is a test message from

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 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
Ashton Fei © {{new Date().getFullYear()}}
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /projects/GAS086/utils.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/GAS086/vue/formdata.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /projects/GAS086/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 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 | 6 | 9 | 12 | 13 | 41 | 42 |
-------------------------------------------------------------------------------- /projects/GAS086/vue/view/received.html: -------------------------------------------------------------------------------- 1 |
2 | 11 | 12 | 15 | 18 | 21 | 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 | 6 | 9 | 12 | 13 | 41 | 42 |
-------------------------------------------------------------------------------- /projects/GAS086/vue/view/users.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | mdi-plusCreate 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 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 | 60 | 61 | 62 | 63 | 64 |
65 | 66 | 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 :

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
TimestampSubjectStatusLinks
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 | [![GAS097 Multiple Select in Google Sheets](https://user-images.githubusercontent.com/16481229/183232190-d2f10de6-e4d8-469a-a057-f3488c230aa2.jpeg) 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 | [![GAS098 Maze Generator](https://user-images.githubusercontent.com/16481229/184839314-11c166ea-f9e0-4b1c-ad95-c2bd0496a5ef.jpeg)](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 | [View on YouTube](https://youtube.com/ashtonfei) 4 | [Follow on Twitter](https://twitter.com/ashton_fei) 5 | [Follow on Twitter](https://upwork.com/workwith/ashtonfei) 6 | ![YouTube Cover](https://github.com/ashtonfei/google-apps-script-projects/assets/16481229/ff75f0e6-74a9-497b-bccf-c69216c2e48b) 7 | 8 | ## Links 9 | 10 | [Make a copy](https://docs.google.com/spreadsheets/d/17yFFhxs6Wbt2FBCAaSqtzmSB5IX_iHbzzyCEJDsO6U0/copy) 11 | [Booking form](https://forms.gle/Cx5aRK8QgsZ3neEQA) 12 | [Cancellation form](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 | [View on YouTube](https://youtube.com/ashtonfei) 4 | [Follow on Twitter](https://twitter.com/ashton_fei) 5 | [Follow on Twitter](https://upwork.com/workwith/ashtonfei) 6 | [![YouTube Cover](https://github.com/ashtonfei/google-apps-script-projects/assets/16481229/857d914e-dbc3-4717-9330-0d17255113cf)](https://youtu.be/iRqvvS0F9Bg) 7 | 8 | ## Links 9 | 10 | [Make a copy](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 ,

10 | 11 |

12 | Here is the time tracking report for the tasks have been complted from to , check the details in the 14 | attachment. 15 |

16 | 17 |
Total hours:
18 |
Total costs:
19 | 20 |

Thanks,

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 | --------------------------------------------------------------------------------