├── src └── #README.js ├── .gitignore ├── package.json ├── README.md ├── google.script.run.js └── Gruntfile.js /src/#README.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MAIN Code is in GIT: 3 | * url://repo 4 | */ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /node_modules/ 3 | 4 | /build/src/ 5 | /build/config/dev_config.* 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GAS-shell", 3 | "version": "0.1.0", 4 | "description": "Google Apps Script shell project", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "" 9 | }, 10 | "dependencies": { 11 | "@scriptaddicts/gas-error-handler": "latest" 12 | }, 13 | "devDependencies": { 14 | "gas-lib": "latest", 15 | "grunt": "^1.0.1", 16 | "grunt-contrib-clean": "^1.0.0", 17 | "grunt-contrib-copy": "^1.0.0", 18 | "grunt-preprocess": "^5.1.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | ## How do I get set up? 4 | 5 | #### Summary of set up 6 | 7 | * We assume that a code Editor (Webstorm, Visual Studio Code -> free), Git, and npm are installed on you computer. 8 | * Clone the Git repository 9 | * Run npm install in project root to install **_node_modules_** necessary to run grunt and build the project 10 | * Create a _**/build/config/dev_config.json**_ file 11 | It will contain the built target, and must be of the following form: (see below < config file format >) 12 | ```json 13 | { 14 | "clasp": {"scriptId": "AppsScript file drive ID"}, 15 | "script_manifest": { 16 | "timeZone": "Europe/Paris", 17 | "exceptionLogging": "STACKDRIVER" 18 | } 19 | } 20 | 21 | ``` 22 | * We use the **@google/clasp** package to get/update apps script file in Google Drive. 23 | Find the setup for clasp on this page: https://www.npmjs.com/package/@google/clasp 24 | 25 | 26 | #### Build command 27 | 28 | 1. Renaming and copy files: Execute ``grunt build --target=$CONFiG_NAME$`` inside the main working directory ($CONFiG_NAME$ = dev, prod, whatever name you choose: it will use the config file named "/build/config/$CONFiG_NAME$_config.json") 29 | 2. Update the script: Execute ``grunt push --target=dev`` 30 | 3. Both can be executed with: ``grunt build_push --target=dev`` 31 | 32 | 33 | #### Changing target (DEV, PROD for example) 34 | 35 | You can define other target by using the "--target=targetName" grunt option. 36 | For each target, a valid config file named: "/build/config/targetName_config.json" must exist. 37 | 38 | 39 | #### Config file format: 40 | 41 | /build/config/$CONFiG_NAME$_config.json 42 | ```json 43 | { 44 | "clasp": { 45 | "scriptId": "$TARGET_SCRIPT_ID$" 46 | }, 47 | "script_manifest": { 48 | "timeZone": "Europe/Paris", 49 | "exceptionLogging": "STACKDRIVER" 50 | } 51 | } 52 | ``` 53 | NOTE: fill the "script_manifest" key with this format: https://developers.google.com/apps-script/concepts/manifests#manifest_structure 54 | 55 | NOTE: adjust the timezone to match yours, best is to copy the original from the script 56 | 57 | ## Best practices ### 58 | 59 | Git branches: 60 | 61 | * master: merge Pull requests on master 62 | 63 | * Set **tags** with increasing version on master's commit that are pushed in production (v1, v2, ...) 64 | 65 | **Always merge with no fast forward !** (For major feature and update. Quick fix can be merged by default): 66 | ``` 67 | git merge --no-ff branchToMergeHere 68 | ``` 69 | 70 | ## I want to publish as an Addon with my build process ## 71 | 72 | It's possible, please check the repository branch: 73 | [buildAddOn](https://github.com/JeanRemiDelteil/gas-starter-kit/tree/buildAddOn) 74 | 75 | ⚠ 76 | However be warned that using the command to publish as an addon will FORBID further manual publishing with the GAS interface. 77 | ⚠ 78 | 79 | ## Who do I talk to? ### 80 | 81 | * jeanremi.delteil@gmail.com -------------------------------------------------------------------------------- /google.script.run.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file exist to provide auto-completion and linking between Server side code and Client side code in Apps Script 3 | * 4 | * Add all functions callable from client side to the google.script.run prototype 5 | */ 6 | 7 | 8 | // 9 | 10 | GoogleScriptRun = function () {}; 11 | // NEVER DO THAT in normal code, this overwrite the prototype, here we do this of auto-completion only 12 | GoogleScriptRun.prototype = { 13 | // PLACE HERE the entryPoint functions (just for listing purpose) 14 | // onInstall: onInstall, 15 | onOpen: onOpen, 16 | 17 | // PLACE HERE functions called from Sidebar/Modal to keep the link and have auto-complete 18 | 19 | }; 20 | 21 | 22 | // 23 | 24 | /** 25 | * @param {function(serverResponse: *, userObject: *)} successHandler 26 | * @return {GoogleScriptRun} 27 | */ 28 | GoogleScriptRun.prototype.withSuccessHandler = function (successHandler) { 29 | successHandler('serverResponse', this._userObject); 30 | return this; 31 | }; 32 | /** 33 | * @param {function(error: Error, userObject: *)} failureHandler 34 | * @return {GoogleScriptRun} 35 | */ 36 | GoogleScriptRun.prototype.withFailureHandler = function (failureHandler) { 37 | failureHandler(new Error('errorMessage'), this._userObject); 38 | return this; 39 | }; 40 | /** 41 | * @param {*} userObject 42 | * @return {GoogleScriptRun} 43 | */ 44 | GoogleScriptRun.prototype.withUserObject = function (userObject) { 45 | this._userObject = userObject; 46 | return this; 47 | }; 48 | 49 | // noinspection ES6ConvertVarToLetConst 50 | var google = { 51 | script: { 52 | run: new GoogleScriptRun() 53 | } 54 | }; 55 | 56 | // 57 | 58 | // 59 | 60 | 61 | // 62 | 63 | 64 | /** 65 | * @function google.picker.PickerBuilder 66 | * @constructor 67 | * 68 | * NOTE: API is not completely described 69 | * see here for full list (https://developers.google.com/picker/docs/reference#PickerBuilder) 70 | * 71 | * @return PickerBuilder 72 | */ 73 | /** 74 | * @function PickerBuilder.prototype.setDeveloperKey 75 | * 76 | * @param {string} developerKey 77 | * @return PickerBuilder 78 | */ 79 | /** 80 | * @function PickerBuilder.prototype.setOAuthToken 81 | * 82 | * @param {string} token 83 | * @return PickerBuilder 84 | */ 85 | /** 86 | * @function PickerBuilder.prototype.setOrigin 87 | * 88 | * @param {string} origin 89 | * @return PickerBuilder 90 | */ 91 | /** 92 | * @function PickerBuilder.prototype.setCallback 93 | * 94 | * @param {function} callBack 95 | * @return PickerBuilder 96 | */ 97 | /** 98 | * @function PickerBuilder.prototype.setTitle 99 | * 100 | * @param {string} title 101 | * @return PickerBuilder 102 | */ 103 | /** 104 | * @function PickerBuilder.prototype.setSize 105 | * 106 | * @param {number} width 107 | * @param {number} height 108 | * @return PickerBuilder 109 | */ 110 | /** 111 | * @function PickerBuilder.prototype.addView 112 | * 113 | * @param {google.picker.View || google.picker.ViewId} view 114 | * @return PickerBuilder 115 | */ 116 | /** 117 | * @function PickerBuilder.prototype.enableFeature 118 | * 119 | * @param {google.picker.Feature} feature 120 | * @return PickerBuilder 121 | */ 122 | /** 123 | * @function PickerBuilder.prototype.hideTitleBar 124 | * 125 | * @return {PickerBuilder} 126 | */ 127 | /** 128 | * @function PickerBuilder.prototype.build 129 | * 130 | * @return {Picker} 131 | */ 132 | 133 | /** 134 | * @function google.picker.View 135 | * @constructor 136 | * 137 | * @param {google.picker.ViewId} viewId 138 | * 139 | * @return google.picker.View 140 | */ 141 | /** 142 | * @function google.picker.View.prototype.getId 143 | * Returns the ViewId for this view. 144 | * 145 | * @return {string} 146 | */ 147 | /** 148 | * @function google.picker.View.prototype.setMimeTypes 149 | * 150 | * @param {string} mimeTypes 151 | */ 152 | /** 153 | * @function google.picker.View.prototype.setQuery 154 | * 155 | * @param {string} query 156 | */ 157 | 158 | 159 | /** 160 | * @typedef {{}} Picker 161 | */ 162 | /** 163 | * @function Picker.prototype.setVisible 164 | * 165 | * @param {boolean} isVisible 166 | */ 167 | /** 168 | * @function Picker.prototype.dispose 169 | */ 170 | 171 | /** 172 | * @function google.picker.DocsView 173 | * @constructor 174 | * 175 | * @param {google.picker.ViewId} [viewId] 176 | * 177 | * @return DocsView 178 | */ 179 | /** 180 | * @function DocsView.prototype.setIncludeFolders 181 | * 182 | * @param {boolean} includeFolders 183 | * @return DocsView 184 | */ 185 | /** 186 | * @function DocsView.prototype.setEnableTeamDrives 187 | * 188 | * @param {boolean} enableTeamDrives 189 | * @return DocsView 190 | */ 191 | /** 192 | * @function DocsView.prototype.setSelectFolderEnabled 193 | * 194 | * @param {boolean} enableSelectFolder 195 | * @return DocsView 196 | */ 197 | /** 198 | * @function DocsView.prototype.setMode 199 | * 200 | * @param {google.picker.DocsViewMode} mode 201 | * @return DocsView 202 | */ 203 | /** 204 | * @function DocsView.prototype.setOwnedByMe 205 | * 206 | * @param {boolean} ownedByMe 207 | * @return DocsView 208 | */ 209 | /** 210 | * @function DocsView.prototype.setParent 211 | * 212 | * @param {string} parent 213 | * @return DocsView 214 | */ 215 | /** 216 | * @function DocsView.prototype.setStarred 217 | * 218 | * @param {boolean} starred 219 | * @return DocsView 220 | */ 221 | 222 | 223 | /** 224 | * google.picker 225 | * 226 | * @typedef {{ 227 | * Action: { 228 | * PICKED, 229 | * CANCEL, 230 | * LOADED, 231 | * }, 232 | * DocsViewMode: { 233 | * GRID, 234 | * LIST, 235 | * }, 236 | * ViewId: { 237 | * DOCS, 238 | * DOCS_IMAGES, 239 | * DOCS_IMAGES_AND_VIDEOS, 240 | * DOCS_VIDEOS, 241 | * DOCUMENTS, 242 | * DRAWINGS, 243 | * FOLDERS, 244 | * FORMS, 245 | * IMAGE_SEARCH, 246 | * PDFS, 247 | * PHOTO_ALBUMS, 248 | * PHOTO_UPLOAD, 249 | * PHOTOS, 250 | * PRESENTATIONS, 251 | * RECENTLY_PICKED, 252 | * SPREADSHEETS, 253 | * VIDEO_SEARCH, 254 | * WEBCAM, 255 | * YOUTUBE, 256 | * }, 257 | * Feature: { 258 | * NAV_HIDDEN, 259 | * MINE_ONLY, 260 | * MULTISELECT_ENABLED, 261 | * SIMPLE_UPLOAD_ENABLED, 262 | * SUPPORT_TEAM_DRIVES, 263 | * }, 264 | * Document: { 265 | * ADDRESS_LINES, 266 | * AUDIENCE, 267 | * DESCRIPTION, 268 | * DURATION, 269 | * EMBEDDABLE_URL, 270 | * ICON_URL, 271 | * ID, 272 | * IS_NEW, 273 | * LAST_EDITED_UTC, 274 | * LATITUDE, 275 | * LONGITUDE, 276 | * MIME_TYPE, 277 | * NAME, 278 | * NUM_CHILDREN, 279 | * PARENT_ID, 280 | * PHONE_NUMBERS, 281 | * SERVICE_ID, 282 | * THUMBNAILS, 283 | * TYPE, 284 | * URL, 285 | * }, 286 | * Response: { 287 | * ACTION, 288 | * DOCUMENTS, 289 | * PARENTS, 290 | * VIEW, 291 | * } 292 | * }} google.picker 293 | */ 294 | 295 | 296 | // 297 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | /** 3 | * @typedef {function} grunt.option 4 | * @typedef {function} grunt.initConfig 5 | */ 6 | 7 | /** possible build : 8 | * 9 | * > build files to prepare pushing on App Script 10 | * grunt build --target=dev 11 | * grunt build --target=prod 12 | * 13 | * grunt build_push --target=prod 14 | * 15 | *//**/ 16 | 17 | // define target and config to run the task with 18 | const TARGET = grunt.option('target') || 'dev'; 19 | 20 | /** 21 | * Load config file directly 22 | * 23 | * @type {{ 24 | * clasp: { 25 | * scriptId: string 26 | * }, 27 | * script_manifest: {}, 28 | * context: {} 29 | * }} 30 | */ 31 | let CONFIG; 32 | 33 | // 34 | { 35 | try { 36 | CONFIG = require(`./build/config/${TARGET}_config`); 37 | } 38 | catch (e) { 39 | grunt.fail.fatal(`\x1b[31mERROR: "\x1b[0m\x1b[41m\x1b[30m${TARGET}\x1b[31m\x1b[0m\x1b[31m" is not a valid target build\x1b[0m`); 40 | 41 | // not needed, grunt.fail.fatal already exits 42 | return false; 43 | } 44 | 45 | 46 | // Stringify non-string key in .context 47 | let context = JSON.parse(JSON.stringify(CONFIG.context)); 48 | 49 | for (let key in context) { 50 | if (typeof context[key] === 'string') continue; 51 | 52 | context[key] = JSON.stringify(context[key]); 53 | } 54 | 55 | CONFIG.context = context; 56 | 57 | } 58 | // 59 | 60 | // init config object 61 | grunt.initConfig({ 62 | 63 | // task 64 | clean: { 65 | 'build': { 66 | files: [ 67 | { 68 | expand: true, 69 | cwd: 'build/src/', // build source folder 70 | src: [ 71 | '**/*', // Wipe everything 72 | ], 73 | }, 74 | ], 75 | }, 76 | }, 77 | copy: { 78 | 'build': { 79 | files: [ 80 | { 81 | expand: true, 82 | cwd: 'src/', 83 | src: [ 84 | '**/*.gs', 85 | '**/*.js', 86 | '**/*.ts', 87 | '**/*.html', 88 | 'appsscript.json', 89 | ], 90 | dest: 'build/src/', 91 | flatten: false, 92 | filter: 'isFile', 93 | 'rename': (dest, src) => dest + src.replace(/\.gs\.js$/, '.js'), 94 | }, 95 | ], 96 | }, 97 | 'dependencies': { 98 | get files() { 99 | if (this._cachedFiles) return this._cachedFiles; 100 | 101 | // custom task to copy the package dependencies to build output 102 | let files = []; 103 | let {dependencies} = require('./package'); 104 | 105 | // Allows access to saved packages information 106 | this.options.process = this.options.process.bind(this.options); 107 | 108 | // for every dependency package, build src and dest rules 109 | for (let pkgName in dependencies) { 110 | // retrieve installed package version 111 | let {version} = require(`./node_modules/${pkgName}/package`); 112 | 113 | // save package info 114 | this.options._pkg[pkgName] = { 115 | name: pkgName, 116 | version: version, 117 | }; 118 | 119 | files.push({ 120 | expand: true, 121 | cwd: `node_modules/${pkgName}/src/`, 122 | src: [ 123 | '**/*.gs', 124 | '**/*.js', 125 | '**/*.ts', 126 | ], 127 | dest: `build/src/lib/${pkgName}/`, 128 | flatten: false, // set flatten to false once we use clasp folder name 129 | filter: 'isFile', 130 | 'rename': (dest, src) => dest + src.replace(/\.gs\.js$/, '.js'), 131 | }); 132 | } 133 | 134 | // Save files to only process them once (they should not change in between calls) 135 | this._cachedFiles = files; 136 | 137 | return files; 138 | }, 139 | 140 | options: { 141 | _pkg: {}, 142 | 143 | /** 144 | * Add header with library information 145 | * 146 | * @param {string} content 147 | * @param {string} srcPath 148 | */ 149 | process: function (content, srcPath) { 150 | // find pkg 151 | let [/*res*/, pkg] = /^node_modules\/(.+?)\/src\//.exec(srcPath) || []; 152 | if (!pkg) return content; 153 | 154 | let {name, version} = this._pkg[pkg]; 155 | 156 | let header = `/** 157 | * package: ${name} 158 | * version: ${version} 159 | */`; 160 | 161 | return `${header}\n\n${content}`; 162 | }, 163 | }, 164 | 165 | }, 166 | }, 167 | jsonPatch: { 168 | 'build': { 169 | srcFolder: 'build/src/', 170 | destFolder: 'build/src/', 171 | 172 | files: [ 173 | { 174 | src: '.clasp.json', 175 | dest: '.clasp.json', 176 | data: CONFIG.clasp, 177 | }, 178 | { 179 | src: 'appsscript.json', 180 | dest: 'appsscript.json', 181 | data: CONFIG.script_manifest || {}, 182 | }, 183 | ], 184 | }, 185 | }, 186 | preprocess: { 187 | 'js': { 188 | options: { 189 | context: CONFIG.context, 190 | type: 'js', 191 | }, 192 | files: [ 193 | { 194 | expand: true, 195 | cwd: 'build/src/', 196 | src: [ 197 | '**/*.js', 198 | '**/*.js.html', 199 | ], 200 | dest: 'build/src/', 201 | }, 202 | ], 203 | }, 204 | }, 205 | clasp: { 206 | 'push': { 207 | runDir: 'build/src', 208 | command: 'push', 209 | }, 210 | 'version': { 211 | runDir: 'build/src', 212 | command: 'version', 213 | }, 214 | }, 215 | }); 216 | 217 | 218 | // load tasks 219 | grunt.loadNpmTasks('grunt-contrib-clean'); 220 | grunt.loadNpmTasks('grunt-contrib-copy'); 221 | grunt.loadNpmTasks('grunt-preprocess'); 222 | 223 | /** 224 | * custom task to patch *.json or multiple *.json 225 | */ 226 | grunt.registerMultiTask('jsonPatch', 'Update properties in *.json file or multiple json files', function () { 227 | 228 | // Check if there are files to patch 229 | if (!this || !this.data || (!this.files && (!this.data.src || !this.data.dest))) return; 230 | 231 | let srcFolder = this.data['srcFolder'] || ''; 232 | let destFolder = this.data['destFolder'] || ''; 233 | 234 | /** 235 | * Load a JSON file, patch it with , save it to 236 | * 237 | * @param {string} src 238 | * @param {string} target 239 | * @param {Object} data 240 | */ 241 | function updateJsonFile(src, target, data) { 242 | 243 | let config = {}; 244 | 245 | // read config file 246 | try { config = grunt.file.readJSON(srcFolder + src); } 247 | catch (e) {} 248 | 249 | // update the provided parameters 250 | for (let key in data) { 251 | let path = key.split('/'), 252 | configDrillDown = config; 253 | 254 | for (let i = 0; i < path.length - 1; i++) { 255 | // in case the path doesn't exist, create it. ONLY create object 256 | if (configDrillDown[path[i]] === undefined) { 257 | 258 | // Add array element at the end 259 | if (Array.isArray(configDrillDown) && path[i] === '-1') { 260 | path[i] = configDrillDown.push({}) - 1; 261 | } 262 | // Create Array of (-1) is used and object is empty (newly created) 263 | else if (Object.keys(configDrillDown).length === 0 && path[i] === '-1') { 264 | configDrillDown = [{}]; 265 | path[i] = 0; 266 | } 267 | else { 268 | configDrillDown[path[i]] = {}; 269 | } 270 | } 271 | 272 | configDrillDown = configDrillDown[path[i]]; 273 | } 274 | 275 | let i = path.length - 1; 276 | if (configDrillDown[path[i]] === undefined) { 277 | 278 | // Add array element at the end 279 | if (Array.isArray(configDrillDown) && path[i] === '-1') { 280 | path[i] = configDrillDown.push({}) - 1; 281 | } 282 | // Create Array of (-1) is used and object is empty (newly created) 283 | else if (Object.keys(configDrillDown).length === 0 && path[i] === '-1') { 284 | configDrillDown = [{}]; 285 | path[i] = 0; 286 | } 287 | } 288 | 289 | configDrillDown[path[i]] = data[key]; 290 | } 291 | 292 | // write updated config 293 | grunt.file.write(destFolder + target, JSON.stringify(config, null, '\t')); 294 | } 295 | 296 | // Init files object 297 | let files = this.data.files || [ 298 | { 299 | src: this.files[0].src[0], 300 | dest: this.files[0].dest, 301 | data: this.data.data, 302 | }, 303 | ]; 304 | 305 | // Patch every JSON files 306 | files.forEach(({src, dest, data}) => updateJsonFile(src, dest, data)); 307 | }); 308 | 309 | /** 310 | * Use clasp 311 | */ 312 | grunt.registerMultiTask('clasp', 'push content in script, and create a version', function () { 313 | const child_process = require('child_process'); 314 | 315 | /** 316 | * @type {{ 317 | * command: string, 318 | * runDir: string 319 | * }} 320 | */ 321 | let param = this.data; 322 | 323 | function clasp(cmd) { 324 | let res = child_process.execSync(`clasp ${cmd}`, { 325 | cwd: __dirname + '/' + param.runDir, 326 | }); 327 | 328 | // Get string res 329 | return res.toString(); 330 | } 331 | 332 | switch (param.command) { 333 | case 'push': 334 | // Push 335 | console.log('Pushing files to the script'); 336 | let pushRes = clasp('push'); 337 | 338 | // Check result 339 | let resPush = /Pushed\s(\d+)\sfiles\./.exec(pushRes); 340 | if (!resPush) throw 'Error while pushing files to AppsScript'; 341 | console.log(`Pushed files: ${resPush[1]}`); 342 | 343 | break; 344 | 345 | case 'version': 346 | // create a new version 347 | console.log('Creating new script version'); 348 | let versionRes = clasp('version'); 349 | 350 | // Check result and get version num 351 | let resVers = /version\s(\d+)/.exec(versionRes); 352 | if (!resVers) throw 'Error while creating new version'; 353 | 354 | let versionNum = +resVers[1]; 355 | console.log('New version num: ' + versionNum); 356 | 357 | // Update version value: 358 | !CONFIG.publishing && (CONFIG['publishing'] = {}); 359 | CONFIG.publishing.version = versionNum; 360 | 361 | break; 362 | } 363 | }); 364 | 365 | 366 | // NEVER name the task as the configurator object 367 | // the default task can be run just by typing "grunt" on the command line 368 | // USE THIS when multiple tasks must be chained 369 | grunt.registerTask('build', [ 370 | 'clean:build', 371 | 'copy:build', 372 | 'preprocess:js', 373 | 'jsonPatch:build', 374 | 'copy:dependencies', 375 | ]); 376 | 377 | grunt.registerTask('push', [ 378 | 'clasp:push', 379 | ]); 380 | 381 | 382 | grunt.registerTask('build_push', [ 383 | 'build', 384 | 'push', 385 | ]); 386 | 387 | // define default task (for grunt alone) 388 | grunt.registerTask('default', ['build']); 389 | 390 | /** 391 | * NOTES: 392 | * 393 | * - Beware of comments in JSON files, no comments can exists in JSON processed by config task 394 | * 395 | */ 396 | }; 397 | --------------------------------------------------------------------------------