├── core ├── images │ ├── entry.png │ ├── delete_file.png │ ├── delete_folder.png │ ├── rename_file.png │ └── rename_folder.png ├── Drive.gs ├── appscript.json ├── fluid-config.yaml └── Common.gs ├── fluid-Dockerfile ├── .github └── workflows │ └── dev.yml ├── LICENSE └── README.md /core/images/entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ycui1984/DriveWorks/HEAD/core/images/entry.png -------------------------------------------------------------------------------- /core/images/delete_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ycui1984/DriveWorks/HEAD/core/images/delete_file.png -------------------------------------------------------------------------------- /core/images/delete_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ycui1984/DriveWorks/HEAD/core/images/delete_folder.png -------------------------------------------------------------------------------- /core/images/rename_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ycui1984/DriveWorks/HEAD/core/images/rename_file.png -------------------------------------------------------------------------------- /core/images/rename_folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ycui1984/DriveWorks/HEAD/core/images/rename_folder.png -------------------------------------------------------------------------------- /fluid-Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM nixos/nix:latest 3 | WORKDIR /usr/scan 4 | COPY . /usr/scan/ 5 | RUN mkdir results 6 | RUN nix-env -if https://github.com/fluidattacks/makes/archive/23.04.tar.gz 7 | -------------------------------------------------------------------------------- /core/Drive.gs: -------------------------------------------------------------------------------- 1 | /** 2 | * Callback for rendering the card for specific Drive items. 3 | * @param {Object} e The event object. 4 | * @return {CardService.Card} The card to show to the user. 5 | */ 6 | 7 | function onDriveItemsSelected(e) { 8 | console.log(e); 9 | return createHomeCard(e.drive.activeCursorItem); 10 | } 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/dev.yml 2 | name: Makes CI 3 | on: [push, pull_request] 4 | jobs: 5 | machineStandalone: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@f095bcc56b7c2baf48f3ac70d6d6782f4f553222 9 | - uses: docker://ghcr.io/fluidattacks/makes/amd64:23.06 10 | name: machineStandalone 11 | with: 12 | args: m gitlab:fluidattacks/universe@trunk /skims scan ./core/fluid-config.yaml 13 | -------------------------------------------------------------------------------- /core/appscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/Los_Angeles", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "oauthScopes": [ 6 | "https://www.googleapis.com/auth/drive.addons.metadata.readonly", 7 | "https://www.googleapis.com/auth/script.locale", 8 | "https://www.googleapis.com/auth/userinfo.email", 9 | "https://www.googleapis.com/auth/drive", 10 | "https://www.googleapis.com/auth/script.scriptapp", 11 | "https://www.googleapis.com/auth/spreadsheets", 12 | "https://www.googleapis.com/auth/script.send_mail" 13 | ], 14 | "runtimeVersion": "V8", 15 | "addOns": { 16 | "common": { 17 | "name": "DriveWorks", 18 | "logoUrl": "https://drive.google.com/uc?id=1YRuaxhrXiRlLu1SbTZopBKTDElfO3QIx", 19 | "useLocaleFromApp": true, 20 | "homepageTrigger": { 21 | "runFunction": "onHomepage", 22 | "enabled": true 23 | }, 24 | "universalActions": [] 25 | }, 26 | "drive": { 27 | "onItemsSelectedTrigger": { 28 | "runFunction": "onDriveItemsSelected" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ycui1984 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 | -------------------------------------------------------------------------------- /core/fluid-config.yaml: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Pick a name you like, normally the name of the repository. 3 | # Example: 4 | namespace: OWASP 5 | 6 | # Description: 7 | # Omit if you want pretty-printed results, 8 | # Set to a path if you want CSV results. 9 | # Optional: 10 | # Yes 11 | # Example: 12 | #output: 13 | # file_path: ./Fluid-Attacks-Results.csv 14 | # format: CSV 15 | 16 | # Description: 17 | # Working directory, normally used as the path to the repository. 18 | # Example: 19 | working_dir: . 20 | 21 | # Description: 22 | # SAST for source code. 23 | # Example: 24 | sast: 25 | # Description: 26 | # Target files used in the analysis. 27 | # Example: 28 | include: 29 | # Absolute path 30 | - . 31 | # Relative path to `working_dir` 32 | - . 33 | 34 | # Description: 35 | # Reversing checks for Android APKs. 36 | # apk: 37 | # Description: 38 | # Target files used in the analysis. 39 | # Example: 40 | # include: 41 | # Absolute paths 42 | - /app/app-arm-debug-Android5.apk 43 | - /app/app-arm-debug.apk 44 | - /app/app-x86-debug-Android5.apk 45 | - /app/app-x86-debug.apk 46 | 47 | 48 | # Description: 49 | # Findings to analyze. 50 | # The complete list of findings can be found here: 51 | # https://gitlab.com/fluidattacks/universe/-/blob/trunk/skims/manifests/findings.lst 52 | # Optional: 53 | # Yes, if not present all security findings will be analyzed. 54 | # Example: 55 | checks: 56 | - F001 57 | - F004 58 | - F008 59 | - F009 60 | - F010 61 | - F011 62 | - F012 63 | - F015 64 | - F016 65 | - F017 66 | - F020 67 | - F021 68 | - F022 69 | - F023 70 | - F031 71 | - F034 72 | - F035 73 | - F037 74 | - F042 75 | - F043 76 | - F052 77 | - F055 78 | - F056 79 | - F058 80 | - F073 81 | - F075 82 | - F079 83 | - F080 84 | - F082 85 | - F085 86 | - F086 87 | - F089 88 | - F091 89 | - F092 90 | - F094 91 | - F096 92 | - F098 93 | - F099 94 | - F100 95 | - F103 96 | - F107 97 | - F112 98 | - F120 99 | - F127 100 | - F128 101 | - F129 102 | - F130 103 | - F131 104 | - F132 105 | - F133 106 | - F134 107 | - F143 108 | - F160 109 | - F176 110 | - F177 111 | - F182 112 | - F200 113 | - F203 114 | - F206 115 | - F207 116 | - F211 117 | - F234 118 | - F239 119 | - F246 120 | - F247 121 | - F250 122 | - F252 123 | - F256 124 | - F257 125 | - F258 126 | - F259 127 | - F266 128 | - F267 129 | - F268 130 | - F277 131 | - F281 132 | - F300 133 | - F313 134 | - F320 135 | - F325 136 | - F333 137 | - F335 138 | - F338 139 | - F346 140 | - F363 141 | - F372 142 | - F380 143 | - F381 144 | - F393 145 | - F394 146 | - F396 147 | - F398 148 | - F400 149 | - F401 150 | - F402 151 | - F406 152 | - F407 153 | - F408 154 | - F409 155 | - F411 156 | - F412 157 | - F413 158 | - F414 159 | - F416 160 | - F418 161 | 162 | # Description: 163 | # Language to use, valid values are: EN, ES. 164 | # Optional: 165 | # Yes, defaults to: EN. 166 | language: EN 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DriveWorks 2 | 3 | Managing Google drive files could be time consuming and error prone. 4 | Imagine how boring it was to delete all documents titled as 'Untitled document' 5 | among thousands of files under hundreds of folders. DriveWorks is a workspace 6 | addon to make file or folder management easy. The addon supports operations like 7 | deletion, renaming, etc on files/folders with filters like name matching, file 8 | type matching etc. 9 | 10 | # Design Choice 11 | High level, the implementation of the addon used an async pattern, where user commits the job 12 | spec into property storage first, then later, in the logic of triggers, the user job is executed 13 | and status is reported. Note that executing user jobs immediately after user committed 14 | the job spec (inline execution) is only acceptable for small jobs and is going to be 15 | problematic (Google has limitation of each executation to be under 5 minutes) for jobs 16 | that lasts > 5 minutes or hours. 17 | 18 | To overcome the 5 minutes per executation limitation, the addon set up multiple triggers with 19 | 5 minutes interval. At the end of each executation, we use property service to store the snapshot 20 | of the executation. At the start of each executation, we use property service to resume the last 21 | executation state. Note that the trigger might not fire exactly at the configured time due to 22 | Google trigger limitations. However, setting up multiple triggers mitigate this problem to some degrees 23 | and is acceptable in practice. 24 | 25 | # How To Use This Addon 26 | ## Step1. Clone the repo locally 27 | `git clone https://github.com/ycui1984/DriveWorks.git` 28 | 29 | ## Step2. Create new projects for app scripts 30 | Open `https://script.google.com/home/start` 31 | Click `New Project` 32 | 33 | ## Step3. Move the local files into the created project 34 | 35 | ## Step4. After you have the project 36 | Click `Deploy` and `Test Deployments` 37 | 38 | ## Step5. Goto your google drive. The driveworks addon logo should show up at very right. 39 | 40 | # ScreenShot 41 | ## Main Menu 42 | ![alt text](https://github.com/ycui1984/DriveWorks/blob/main/core/images/entry.png?raw=true) 43 | 44 | ## Delete File 45 | ![alt text](https://github.com/ycui1984/DriveWorks/blob/main/core/images/delete_file.png?raw=true) 46 | 47 | ## Delete Folder 48 | ![alt text](https://github.com/ycui1984/DriveWorks/blob/main/core/images/delete_folder.png?raw=true) 49 | 50 | ## Rename File 51 | ![alt text](https://github.com/ycui1984/DriveWorks/blob/main/core/images/rename_file.png?raw=true) 52 | 53 | ## Rename Folder 54 | ![alt text](https://github.com/ycui1984/DriveWorks/blob/main/core/images/rename_folder.png?raw=true) 55 | 56 | # Demo Video 57 | See https://www.youtube.com/watch?v=MWWHMgO7vP8 as an example on how to manage Google files/folders in batch. 58 | 59 | # Donations 60 | If this addon make your life easier, please consider to donate to encourage development. 61 | Buymeacoffee https://www.buymeacoffee.com/smartgas 62 | Paypal https://www.paypal.com/paypalme/SmartGAS888 63 | -------------------------------------------------------------------------------- /core/Common.gs: -------------------------------------------------------------------------------- 1 | function onHomepage(e) { 2 | console.log('onHomePage: ' + JSON.stringify(e)); 3 | var statusCard = buildCardViaPropertiesIfExist(); 4 | if (statusCard) { 5 | console.log('onHomePage: return existing status card'); 6 | return statusCard.build(); 7 | } 8 | console.log('onHomePage: return brand new home card'); 9 | return createHomeCard(); 10 | } 11 | 12 | function applyDeleteOnFile(file, dryrun, delete_ops, include_subfolder, sheet) { 13 | var matchStr = getMatchStr(delete_ops); 14 | var fileName = file.getName(); 15 | if (matchStr === null || fileName.includes(matchStr)) { 16 | console.log('Deleting file ' + fileName); 17 | if (dryrun) { 18 | sheet.appendRow(["File", "Delete", "Yes", include_subfolder, fileName, "N/A"]); 19 | return; 20 | } 21 | 22 | sheet.appendRow(["File", "Delete", "No", include_subfolder, fileName, "N/A"]); 23 | file.setTrashed(true); 24 | } 25 | } 26 | 27 | function applyDeleteOnFolder(folder, dryrun, delete_ops, include_subfolder, sheet) { 28 | var matchStr = getMatchStr(delete_ops); 29 | var notEmpty = folder.getFiles().hasNext(); 30 | var folderName = folder.getName(); 31 | if (matchStr === null || folderName.includes(matchStr)) { 32 | if (delete_ops.delete_empty_folder) { 33 | if (notEmpty) return; 34 | } 35 | console.log('Deleting folder ' + folderName); 36 | if (dryrun) { 37 | sheet.appendRow(["Folder", "Delete", "Yes", include_subfolder, folderName, "N/A"]); 38 | return; 39 | } 40 | 41 | sheet.appendRow(["Folder", "Delete", "No", include_subfolder, folderName, "N/A"]); 42 | folder.setTrashed(true); 43 | } 44 | } 45 | 46 | 47 | function getNewName(name, rename_ops) { 48 | if (rename_ops.method === "rename_partial") { 49 | return name.replaceAll(rename_ops.search, rename_ops.replace); 50 | } 51 | 52 | if (rename_ops.method === "rename_full") { 53 | return rename_ops.fullname; 54 | } 55 | 56 | if (rename_ops.method === "rename_adding") { 57 | return ((rename_ops.before === null)? "" : rename_ops.before) + name + ((rename_ops.after === null)? "" : rename_ops.after); 58 | } 59 | 60 | throw "Unsupported rename ops = " + rename_ops.rename_method; 61 | } 62 | 63 | function applyRenameOnFile(file, dryrun, rename_ops, include_subfolder, sheet) { 64 | var matchStr = getMatchStr(rename_ops); 65 | var fileName = file.getName(); 66 | if (matchStr === null || fileName.includes(matchStr)) { 67 | var new_name = getNewName(fileName, rename_ops); 68 | console.log('Renaming file ' + fileName + ' into new name = ' + new_name); 69 | if (dryrun) { 70 | sheet.appendRow(["File", "Rename", "Yes", include_subfolder, fileName, new_name]); 71 | return; 72 | } 73 | 74 | sheet.appendRow(["File", "Rename", "No", include_subfolder, fileName, new_name]); 75 | file.setName(new_name); 76 | } 77 | } 78 | 79 | function applyRenameOnFolder(folder, dryrun, rename_ops, include_subfolder, sheet) { 80 | var matchStr = getMatchStr(rename_ops); 81 | var folderName = folder.getName(); 82 | if (matchStr === null || folderName.includes(matchStr)) { 83 | var new_name = getNewName(folderName, rename_ops); 84 | console.log('Renaming folder ' + folderName + ' into new name = ' + new_name); 85 | if (dryrun) { 86 | sheet.appendRow(["Folder", "Rename", "Yes", include_subfolder, folderName, new_name]); 87 | return; 88 | } 89 | 90 | sheet.appendRow(["Folder", "Rename", "No", include_subfolder, folderName, new_name]); 91 | folder.setName(new_name); 92 | } 93 | } 94 | 95 | function nextIteration(iterationState, operation, entity, include_subfolder, dryrun, delete_ops, rename_ops, sheet) { 96 | var currentIteration = iterationState[iterationState.length-1]; 97 | if (currentIteration.fileIteratorContinuationToken !== null) { 98 | var fileIterator = DriveApp.continueFileIterator(currentIteration.fileIteratorContinuationToken); 99 | if (fileIterator.hasNext()) { 100 | var file = fileIterator.next(); 101 | if (entity === "file") { 102 | if (operation === "delete") { 103 | applyDeleteOnFile(file, dryrun, delete_ops, include_subfolder, sheet); 104 | } else if (operation === "rename") { 105 | applyRenameOnFile(file, dryrun, rename_ops, include_subfolder, sheet); 106 | } else { 107 | throw "nextIteration: Unsupported operation type for file = " + operation; 108 | } 109 | } 110 | currentIteration.fileIteratorContinuationToken = fileIterator.getContinuationToken(); 111 | iterationState[iterationState.length-1] = currentIteration; 112 | return iterationState; 113 | } 114 | 115 | currentIteration.fileIteratorContinuationToken = null; 116 | iterationState[iterationState.length-1] = currentIteration; 117 | return iterationState; 118 | } 119 | 120 | if (currentIteration.folderIteratorContinuationToken !== null) { 121 | var folderIterator = DriveApp.continueFolderIterator(currentIteration.folderIteratorContinuationToken); 122 | if (folderIterator.hasNext()) { 123 | var folder = folderIterator.next(); 124 | if (entity === "folder") { 125 | if (operation === "delete") { 126 | applyDeleteOnFolder(folder, dryrun, delete_ops, include_subfolder, sheet); 127 | } else if (operation === "rename") { 128 | applyRenameOnFolder(folder, dryrun, rename_ops, include_subfolder, sheet); 129 | } else { 130 | throw "nextIteration: Unsupported operation type for folder = " + operation; 131 | } 132 | } 133 | iterationState[iterationState.length-1].folderIteratorContinuationToken = folderIterator.getContinuationToken(); 134 | if (include_subfolder) { 135 | iterationState.push(makeIterationFromFolder(folder, operation, entity, delete_ops, rename_ops)); 136 | } 137 | return iterationState; 138 | } 139 | 140 | iterationState.pop(); 141 | return iterationState; 142 | } 143 | } 144 | 145 | function getMatchStr(ops) { 146 | return ops.search; 147 | } 148 | 149 | function getMimeType(ops) { 150 | var type = ops.file_type; 151 | if (type === "spreadsheet") return MimeType.GOOGLE_SHEETS; 152 | if (type === "doc") return MimeType.GOOGLE_DOCS; 153 | if (type === "slide") return MimeType.GOOGLE_SLIDES; 154 | if (type === "form") return MimeType.GOOGLE_FORMS; 155 | if (type === "sites") return MimeType.GOOGLE_SITES; 156 | if (type === "drawing") return MimeType.GOOGLE_DRAWINGS; 157 | if (type === "appscript") return MimeType.GOOGLE_APPS_SCRIPT; 158 | if (type === "pdf") return MimeType.PDF; 159 | if (type === "bmp") return MimeType.BMP; 160 | if (type === "gif") return MimeType.GIF; 161 | if (type === "jpeg") return MimeType.JPEG; 162 | if (type === "png") return MimeType.PNG; 163 | if (type === "svg") return MimeType.SVG; 164 | if (type === "css") return MimeType.CSS; 165 | if (type === "csv") return MimeType.CSV; 166 | if (type === "html") return MimeType.HTML; 167 | if (type === "js") return MimeType.JAVASCRIPT; 168 | if (type === "txt") return MimeType.PLAIN_TEXT; 169 | if (type === "rtf") return MimeType.RTF; 170 | if (type === "zip") return MimeType.ZIP; 171 | if (type === "word1") return MimeType.MICROSOFT_WORD_LEGACY; 172 | if (type === "word2") return MimeType.MICROSOFT_WORD; 173 | if (type === "excel1") return MimeType.MICROSOFT_EXCEL_LEGACY; 174 | if (type === "excel2") return MimeType.MICROSOFT_EXCEL; 175 | if (type === "ppt1") return MimeType.MICROSOFT_POWERPOINT_LEGACY; 176 | if (type === "ppt2") return MimeType.MICROSOFT_POWERPOINT; 177 | if (type === "odt") return MimeType.OPENDOCUMENT_TEXT; 178 | if (type === "ods") return MimeType.OPENDOCUMENT_SPREADSHEET; 179 | if (type === "odp") return MimeType.OPENDOCUMENT_PRESENTATION; 180 | if (type === "odg") return MimeType.OPENDOCUMENT_GRAPHICS; 181 | return null; 182 | } 183 | 184 | function getSearchToken(folder, entity, mimeType) { 185 | var searchStr = mimeType === null? null : "mimeType='" + mimeType + "'"; 186 | if (searchStr === null) { 187 | return null; 188 | } 189 | console.log('Get token via search string = ' + searchStr); 190 | return (entity === "file")? folder.searchFiles(searchStr).getContinuationToken() : folder.searchFolders(searchStr).getContinuationToken(); 191 | } 192 | 193 | function makeIterationFromFolder(folder, operation, entity, delete_ops, rename_ops) { 194 | var iteration = { 195 | folderName: folder.getName(), 196 | fileIteratorContinuationToken: null, 197 | folderIteratorContinuationToken: null 198 | }; 199 | 200 | if (operation === "delete") { 201 | if (entity === "file") { 202 | console.log('mime type = ' + getMimeType(delete_ops) + ', match str = ' + getMatchStr(delete_ops)); 203 | var token = getSearchToken(folder, entity, getMimeType(delete_ops)); 204 | if (token !== null) { 205 | console.log('search file token is not null = ' + token); 206 | iteration.fileIteratorContinuationToken = token; 207 | } 208 | } else { 209 | var token = getSearchToken(folder, entity, null); 210 | if (token !== null) { 211 | iteration.folderIteratorContinuationToken = token; 212 | } 213 | } 214 | } else if (operation === "rename") { 215 | if (entity === "file") { 216 | var token = getSearchToken(folder, entity, getMimeType(rename_ops)); 217 | if (token !== null) { 218 | iteration.fileIteratorContinuationToken = token; 219 | } 220 | } else { 221 | var token = getSearchToken(folder, entity, null); 222 | if (token !== null) { 223 | iteration.folderIteratorContinuationToken = token; 224 | } 225 | } 226 | } 227 | 228 | if (iteration.fileIteratorContinuationToken === null) { 229 | iteration.fileIteratorContinuationToken = folder.getFiles().getContinuationToken(); 230 | } 231 | 232 | if (iteration.folderIteratorContinuationToken === null) { 233 | iteration.folderIteratorContinuationToken = folder.getFolders().getContinuationToken(); 234 | } 235 | 236 | /// now iteration has both file token and folder token 237 | /// when entity is folder only do not set file token because it is useless 238 | if (entity === "folder") { 239 | iteration.fileIteratorContinuationToken = null; 240 | } 241 | 242 | return iteration; 243 | } 244 | 245 | function getJobStatusKey(email) { 246 | return email + ":job"; 247 | } 248 | 249 | function getJobMetadataKey(email) { 250 | return email + ":metadata"; 251 | } 252 | 253 | function deleteAllTriggersForUser() { 254 | // NOTE: getProjectTrigger() only list out triggers for CURRENT USER 255 | var triggers = ScriptApp.getProjectTriggers(); 256 | for (var i = 0; i < triggers.length; i++) { 257 | ScriptApp.deleteTrigger(triggers[i]); 258 | } 259 | } 260 | 261 | function getLockKey(email) { 262 | return email + "lock"; 263 | } 264 | 265 | function onScheduledRun(e) { 266 | var email = Session.getEffectiveUser().getEmail(); 267 | var jobMetadataKey = getJobMetadataKey(email); 268 | var properties = PropertiesService.getUserProperties(); 269 | var metadata = properties.getProperty(jobMetadataKey); 270 | if (metadata === null) { 271 | console.log('onScheduleRun: Cannot find job metadata for key ' + jobMetadataKey + '. Skip since no work to do'); 272 | return; 273 | } 274 | 275 | var lockVal = properties.getProperty(getLockKey(email)); 276 | if (lockVal !== null) { 277 | console.log('There is another callback to make progress. Skip'); 278 | return; 279 | } 280 | 281 | var timestamp = (new Date()).getTime().toString(); 282 | properties.setProperty(getLockKey(email), timestamp); 283 | var json = JSON.parse(metadata); 284 | var folder = DriveApp.getFolderById(json.folder.id); 285 | var spreadsheet = SpreadsheetApp.openById(json.report_spreadsheet.id); 286 | var sheet = spreadsheet.getSheetByName(getOperationLogSheetName()); 287 | var progress = spreadsheet.getSheetByName(getProgressSheetName()); 288 | try { 289 | iterateFolder(folder, json.operation, json.entity, json.include_subfolder, json.dry_run, json.delete_ops, json.rename_ops, sheet, progress, timestamp); 290 | } catch(e) { 291 | console.log('error in onScheduledRun:' + e); 292 | } 293 | properties.deleteProperty(getLockKey(email)); 294 | } 295 | 296 | function iterateFolder(folder, operation, entity, include_subfolder, dryrun, delete_ops, rename_ops, sheet, progress, timestamp) { 297 | var email = Session.getEffectiveUser().getEmail(); 298 | console.log('Iterate entity in folder: email = ' + email + ', folder = ' + folder + ', operation = ' + operation + ', entity = ' + entity + ', include_subfolder = ' + include_subfolder + ', dryrun = ' + dryrun + ', delete_ops = ' + JSON.stringify(delete_ops) + ', rename_ops = ' + JSON.stringify(rename_ops) + ', lock was hold at = ' + timestamp); 299 | var MAX_RUNNING_TIME_MS = 4.5 * 60 * 1000; 300 | var startTime = (new Date()).getTime(); 301 | var jobKey = getJobStatusKey(email); 302 | var jobMetadataKey = getJobMetadataKey(email); 303 | var lockKey = getLockKey(email); 304 | var properties = PropertiesService.getUserProperties(); 305 | var jobValue = properties.getProperty(jobKey); 306 | var iterationState = JSON.parse(jobValue); 307 | if (iterationState !== null) { 308 | if (folder.getName() !== iterationState[0].folderName) { 309 | console.error("Iterating a new folder: " + folder.getName() + ". End early since existing operation is not done."); 310 | return; 311 | } 312 | console.info("Resuming iteration for folder: " + folder.getName()); 313 | } 314 | if (iterationState === null) { 315 | console.info("Starting new iteration for folder: " + folder.getName()); 316 | progress.appendRow([new Date(), "STARTED"]); 317 | iterationState = []; 318 | iterationState.push(makeIterationFromFolder(folder, operation, entity, delete_ops, rename_ops)); 319 | } 320 | 321 | progress.appendRow([new Date(), "STARTED NEW ITERATION"]); 322 | while (iterationState.length > 0) { 323 | iterationState = nextIteration(iterationState, operation, entity, include_subfolder, dryrun, delete_ops, rename_ops, sheet); 324 | var currTime = (new Date()).getTime(); 325 | var elapsedTimeInMS = currTime - startTime; 326 | var timeLimitExceeded = elapsedTimeInMS >= MAX_RUNNING_TIME_MS; 327 | if (timeLimitExceeded) { 328 | if (properties.getProperty(lockKey) !== timestamp || properties.getProperty(jobKey) !== jobValue) { 329 | console.info('Lock has been overwritten or jobValue has been overwritten, which means others committed at race condition time. Abort without committing.'); 330 | progress.appendRow([new Date(), "ENDED NEW ITERATION WITH OUT COMMITTING"]); 331 | } else { 332 | properties.setProperty(jobKey, JSON.stringify(iterationState)); 333 | progress.appendRow([new Date(), "ENDED NEW ITERATION"]); 334 | } 335 | console.info("Stopping loop after '%d' milliseconds.", elapsedTimeInMS); 336 | return; 337 | } 338 | } 339 | 340 | console.info("Done iterating. Deleting iterating state ... "); 341 | progress.appendRow([new Date(), "ENDED NEW ITERATION"]); 342 | progress.appendRow([new Date(), "DONE"]); 343 | var url = sheet.getParent().getUrl(); 344 | sendCompletionEmail(email, url); 345 | properties.deleteProperty(jobKey); 346 | properties.deleteProperty(jobMetadataKey); 347 | deleteAllTriggersForUser(); 348 | } 349 | 350 | function sendCompletionEmail(email, url) { 351 | var message = 'Dear DriveWorks User,\n\n The job has been completed, please check the progress spreadsheet for the details. Here is the spreadsheet URL ' + url + '.\n\n\nThanks!'; 352 | console.log('sendUsageExpireEmail:' + email + ', message = ' + message); 353 | MailApp.sendEmail(email, 'Job completed by DriveWorks', message); 354 | } 355 | 356 | 357 | function parseFolderFromEvent(e) { 358 | var folderId = null; 359 | if (('selectedItems' in e.drive) && (e.drive.activeCursorItem.mimeType === 'application/vnd.google-apps.folder')) { 360 | folderId = e.drive.activeCursorItem.id; 361 | } 362 | console.log('folderId = ' + folderId); 363 | if (folderId === null) { 364 | var folder = DriveApp.getRootFolder(); 365 | } else { 366 | var folder = DriveApp.getFolderById(folderId); 367 | } 368 | return folder; 369 | } 370 | 371 | function getOperationLogSheetName() { 372 | return "Operation Logs"; 373 | } 374 | 375 | function getProgressSheetName() { 376 | return "Progress"; 377 | } 378 | 379 | function setMetadata(folder, operation, entity, dryrun, include_subfolder, delete_ops, rename_ops) { 380 | var email = Session.getEffectiveUser().getEmail(); 381 | var properties = PropertiesService.getUserProperties(); 382 | var jobMetadataKey = getJobMetadataKey(email); 383 | var spreadsheet = SpreadsheetApp.create("DriveWorks_progress_report_" + Date.now()); 384 | var sheet = spreadsheet.insertSheet(getOperationLogSheetName()); 385 | sheet.appendRow(["Entity", "Operation", "Dry run", "Include subfolder", "Entity name", "Updated name"]); 386 | var progress = spreadsheet.insertSheet(getProgressSheetName()); 387 | progress.appendRow(["Time", "Progress"]); 388 | spreadsheet.deleteSheet(spreadsheet.getSheetByName('Sheet1')); 389 | var metadata = { 390 | folder: { 391 | id: folder.getId(), 392 | name: folder.getName() 393 | }, 394 | operation : operation, 395 | entity : entity, 396 | include_subfolder : include_subfolder, 397 | dry_run : dryrun, 398 | delete_ops : delete_ops, 399 | rename_ops : rename_ops, 400 | report_spreadsheet : { 401 | id : spreadsheet.getId(), 402 | name: spreadsheet.getName() 403 | } 404 | }; 405 | if (properties.getProperty(jobMetadataKey) === null) { 406 | properties.setProperty(jobMetadataKey, JSON.stringify(metadata)); 407 | var jobKey = getJobStatusKey(email); 408 | properties.deleteProperty(jobKey); 409 | } else { 410 | console.log('job metadata key exists, but it should not, the key =' + jobMetadataKey); 411 | } 412 | } 413 | 414 | function onScheduledRunNear0(e) { 415 | var email = Session.getEffectiveUser().getEmail(); 416 | console.log('onScheduledRunNear0 for email = ' + email); 417 | return onScheduledRun(e); 418 | } 419 | 420 | function onScheduledRunNear5(e) { 421 | var email = Session.getEffectiveUser().getEmail(); 422 | console.log('onScheduledRunNear5 for email = ' + email); 423 | return onScheduledRun(e); 424 | } 425 | 426 | function onScheduledRunNear10(e) { 427 | var email = Session.getEffectiveUser().getEmail(); 428 | console.log('onScheduledRunNear10 for email = ' + email); 429 | return onScheduledRun(e); 430 | } 431 | 432 | function onScheduledRunNear15(e) { 433 | var email = Session.getEffectiveUser().getEmail(); 434 | console.log('onScheduledRunNear15 for email = ' + email); 435 | return onScheduledRun(e); 436 | } 437 | 438 | function onScheduledRunNear20(e) { 439 | var email = Session.getEffectiveUser().getEmail(); 440 | console.log('onScheduledRunNear20 for email = ' + email); 441 | return onScheduledRun(e); 442 | } 443 | 444 | function onScheduledRunNear25(e) { 445 | var email = Session.getEffectiveUser().getEmail(); 446 | console.log('onScheduledRunNear25 for email = ' + email); 447 | return onScheduledRun(e); 448 | } 449 | 450 | function onScheduledRunNear30(e) { 451 | var email = Session.getEffectiveUser().getEmail(); 452 | console.log('onScheduledRunNear30 for email = ' + email); 453 | return onScheduledRun(e); 454 | } 455 | 456 | function onScheduledRunNear35(e) { 457 | var email = Session.getEffectiveUser().getEmail(); 458 | console.log('onScheduledRunNear35 for email = ' + email); 459 | return onScheduledRun(e); 460 | } 461 | 462 | function onScheduledRunNear40(e) { 463 | var email = Session.getEffectiveUser().getEmail(); 464 | console.log('onScheduledRunNear40 for email = ' + email); 465 | return onScheduledRun(e); 466 | } 467 | 468 | function onScheduledRunNear45(e) { 469 | var email = Session.getEffectiveUser().getEmail(); 470 | console.log('onScheduledRunNear45 for email = ' + email); 471 | return onScheduledRun(e); 472 | } 473 | 474 | function onScheduledRunNear50(e) { 475 | var email = Session.getEffectiveUser().getEmail(); 476 | console.log('onScheduledRunNear50 for email = ' + email); 477 | return onScheduledRun(e); 478 | } 479 | 480 | function onScheduledRunNear55(e) { 481 | var email = Session.getEffectiveUser().getEmail(); 482 | console.log('onScheduledRunNear55 for email = ' + email); 483 | return onScheduledRun(e); 484 | } 485 | 486 | function installTriggersForUsers() { 487 | var email = Session.getEffectiveUser().getEmail(); 488 | deleteAllTriggersForUser(); 489 | // for (var i = 0; i < 60; i+= 5) { 490 | // console.log('installTriggersForUsers: email = ' + email + ', near min = ' + i); 491 | // ScriptApp.newTrigger("onScheduledRunNear" + i.toString()).timeBased().everyHours(1).nearMinute(i).create(); 492 | // } 493 | console.log('installTriggersForUsers: email = ' + email + ', near min = 0'); 494 | ScriptApp.newTrigger("onScheduledRunNear0").timeBased().everyHours(1).nearMinute(0).create(); 495 | } 496 | 497 | function deleteFileHandler(e) { 498 | console.log('deleteFileHandler = ' + JSON.stringify(e)); 499 | var folder = parseFolderFromEvent(e); 500 | var filename_match = ('file_name_field' in e.formInput)? e.formInput.file_name_field : null; 501 | var delete_ops = { 502 | file_type: e.formInput.file_type_field, 503 | search: filename_match, 504 | delete_empty_folder: null, 505 | } 506 | var dryrun = JSON.parse(e.parameters.dryrun); 507 | var include_subfolder = JSON.parse(e.parameters.include_subfolder); 508 | setMetadata(folder, "delete", "file", dryrun, include_subfolder, delete_ops, null); 509 | installTriggersForUsers(); 510 | var card = createDeleteFileCard(include_subfolder, dryrun); 511 | var navigation = CardService.newNavigation().updateCard(card); 512 | var actionResponse = CardService.newActionResponseBuilder() 513 | .setNavigation(navigation); 514 | return actionResponse.build(); 515 | } 516 | 517 | function deleteJob() { 518 | var email = Session.getEffectiveUser().getEmail(); 519 | var jobMetadataKey = getJobMetadataKey(email); 520 | var properties = PropertiesService.getUserProperties(); 521 | properties.deleteProperty(jobMetadataKey); 522 | return createHomeCard(); 523 | } 524 | 525 | 526 | function buildCardViaPropertiesIfExist() { 527 | var properties = PropertiesService.getUserProperties(); 528 | var email = Session.getEffectiveUser().getEmail(); 529 | var jobMetadataKey = getJobMetadataKey(email); 530 | var metadataStr = properties.getProperty(jobMetadataKey); 531 | if (metadataStr === null) { 532 | return null; 533 | } 534 | var metadata = JSON.parse(metadataStr); 535 | var cardHeader = CardService.newCardHeader() 536 | .setTitle("DriveWorks") 537 | .setSubtitle("Job Settings and Status") 538 | .setImageUrl(getLogoURL()); 539 | var paymentSection = getPaymentSection(); 540 | var mainSection = CardService.newCardSection(); 541 | var operationType = CardService.newDecoratedText().setText("Drive operation type: " + metadata.operation + "").setWrapText(true).setTopLabel("Primary Job Settings"); 542 | var entityType = CardService.newDecoratedText().setText("Entity type: " + metadata.entity + "").setWrapText(true); 543 | var folderName = CardService.newDecoratedText().setText("Target folder: " + metadata.folder.name + ""); 544 | var dryrun = CardService.newDecoratedText().setText("Is dryrun: " + metadata.dry_run + ""); 545 | var include_subfolder = CardService.newDecoratedText().setText("Include subfolder: " + metadata.include_subfolder + ""); 546 | mainSection 547 | .addWidget(operationType) 548 | .addWidget(entityType) 549 | .addWidget(folderName) 550 | .addWidget(dryrun) 551 | .addWidget(include_subfolder); 552 | var spreadsheet = SpreadsheetApp.openById(metadata.report_spreadsheet.id); 553 | var link = CardService.newOpenLink() 554 | .setUrl(spreadsheet.getUrl()) 555 | .setOpenAs(CardService.OpenAs.FULL_SIZE) 556 | .setOnClose(CardService.OnClose.RELOAD_ADD_ON) 557 | var status = CardService.newDecoratedText().setText("CLICK HERE to monitor job progress. You will be notified via email once the job completes. You are not allowed to start a new job while a previous job is running unless the previous is deleted.").setWrapText(true).setTopLabel("Existing Job Status").setOpenLink(link); 558 | mainSection.addWidget(status); 559 | var action = CardService.newAction() 560 | .setFunctionName('deleteJob') 561 | var button = CardService.newTextButton() 562 | .setText('Delete Job') 563 | .setOnClickAction(action) 564 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 565 | var buttonSet = CardService.newButtonSet() 566 | .addButton(button); 567 | mainSection.addWidget(buttonSet); 568 | var card = CardService.newCardBuilder() 569 | .setHeader(cardHeader) 570 | .addSection(paymentSection) 571 | .addSection(mainSection); 572 | 573 | return card; 574 | } 575 | 576 | function deleteFolderHandler(e) { 577 | console.log('deleteFolderHandler = ' + JSON.stringify(e)); 578 | var folder = parseFolderFromEvent(e); 579 | var foldername_match = ('folder_name_field' in e.formInput)? e.formInput.folder_name_field : null; 580 | var delete_empty_folder = ('delete_empty_folders_field' in e.formInput)? true : false; 581 | var delete_ops = { 582 | file_type: null, 583 | search: foldername_match, 584 | delete_empty_folder: delete_empty_folder, 585 | } 586 | var dryrun = JSON.parse(e.parameters.dryrun); 587 | var include_subfolder = JSON.parse(e.parameters.include_subfolder); 588 | setMetadata(folder, "delete", "folder", dryrun, include_subfolder, delete_ops, null); 589 | installTriggersForUsers(); 590 | var card = createDeleteFolderCard(include_subfolder, dryrun); 591 | var navigation = CardService.newNavigation().updateCard(card); 592 | var actionResponse = CardService.newActionResponseBuilder() 593 | .setNavigation(navigation); 594 | return actionResponse.build(); 595 | } 596 | 597 | function renameFileHandler(e) { 598 | console.log('renameFileHandler = ' + JSON.stringify(e)); 599 | var folder = parseFolderFromEvent(e); 600 | var rename_ops = { 601 | method: e.formInput.rename_method_field, 602 | file_type: e.formInput.file_type_field, 603 | search: ("file_name_search_field" in e.formInput)? e.formInput.file_name_search_field : null, 604 | replace: ("file_name_replace_field" in e.formInput)? e.formInput.file_name_replace_field : null, 605 | fullname: ("new_file_name_field" in e.formInput)? e.formInput.new_file_name_field : null, 606 | before: ("file_name_before_field" in e.formInput)? e.formInput.file_name_before_field: null, 607 | after: ("file_name_after_field" in e.formInput)? e.formInput.file_name_after_field : null, 608 | } 609 | 610 | var dryrun = JSON.parse(e.parameters.dryrun); 611 | var include_subfolder = JSON.parse(e.parameters.include_subfolder); 612 | setMetadata(folder, "rename", "file", dryrun, include_subfolder, null, rename_ops); 613 | installTriggersForUsers(); 614 | var card = createRenameFileCard(e.formInput.rename_method_field, include_subfolder, dryrun); 615 | var navigation = CardService.newNavigation().updateCard(card); 616 | var actionResponse = CardService.newActionResponseBuilder() 617 | .setNavigation(navigation); 618 | return actionResponse.build(); 619 | } 620 | 621 | function renameFolderHandler(e) { 622 | console.log('renameFolderHandler = ' + JSON.stringify(e)); 623 | var folder = parseFolderFromEvent(e); 624 | var rename_ops = { 625 | method: e.formInput.rename_method_field, 626 | file_type: null, 627 | search: ("folder_name_search_field" in e.formInput)? e.formInput.folder_name_search_field : null, 628 | replace: ("folder_name_replace_field" in e.formInput)? e.formInput.folder_name_replace_field : null, 629 | fullname: ("new_folder_name_field" in e.formInput)? e.formInput.new_folder_name_field : null, 630 | before: ("folder_name_before_field" in e.formInput)? e.formInput.folder_name_before_field: null, 631 | after: ("folder_name_after_field" in e.formInput)? e.formInput.folder_name_after_field : null, 632 | } 633 | var dryrun = JSON.parse(e.parameters.dryrun); 634 | var include_subfolder = JSON.parse(e.parameters.include_subfolder); 635 | setMetadata(folder, "rename", "folder", dryrun, include_subfolder, null, rename_ops); 636 | installTriggersForUsers(); 637 | var card = createRenameFolderCard(e.formInput.rename_method_field, include_subfolder, dryrun); 638 | var navigation = CardService.newNavigation().updateCard(card); 639 | var actionResponse = CardService.newActionResponseBuilder() 640 | .setNavigation(navigation); 641 | return actionResponse.build(); 642 | } 643 | 644 | function getFileTypeWidget() { 645 | return CardService.newSelectionInput() 646 | .setType(CardService.SelectionInputType.DROPDOWN) 647 | .setTitle("Delete when file type matches") 648 | .setFieldName("file_type_field") 649 | .addItem("Any file type", "all", true) 650 | .addItem("Google Sheets", "spreadsheet", false) 651 | .addItem("Google Docs", "doc", false) 652 | .addItem("Google Slides", "slide", false) 653 | .addItem("Google Forms", "form", false) 654 | .addItem("Google Sites", "sites", false) 655 | .addItem("Google Drawings", "drawing", false) 656 | .addItem("Google App Script", "appscript", false) 657 | .addItem("Adobe PDF (.pdf)", "pdf", false) 658 | .addItem("BMP file (.bmp)", "bmp", false) 659 | .addItem("GIF file (.gif)", "gif", false) 660 | .addItem("JPEG file (.jpeg)", "jpeg", false) 661 | .addItem("PNG file (.png)", "png", false) 662 | .addItem("SVG file (.svg)", "svg", false) 663 | .addItem("CSS file (.css)", "css", false) 664 | .addItem("CSV file (.csv)", "csv", false) 665 | .addItem("Html file (.html)", "html", false) 666 | .addItem("Javascript file (.js)", "js", false) 667 | .addItem("Plain Text (.txt)", "txt", false) 668 | .addItem("Rich Text file (.rtf)", "rtf", false) 669 | .addItem("ZIP file (.zip)", "zip", false) 670 | .addItem("Microsoft Word (.doc)", "word1", false) 671 | .addItem("Microsoft Word (.docx)", "word2", false) 672 | .addItem("Microsoft Excel (.xls)", "excel1", false) 673 | .addItem("Microsoft Excel (.xlsx)", "excel2", false) 674 | .addItem("Microsoft Powerpoint (.ppt)", "ppt1", false) 675 | .addItem("Microsoft Powerpoint (.pptx)", "ppt2", false) 676 | .addItem("OpenDocument Text (.odt)", "odt", false) 677 | .addItem("OpenDocument Spreadsheet (.ods)", "ods", false) 678 | .addItem("OpenDocument Presentation (.odp)", "odp", false) 679 | .addItem("OpenDocument Graphics (.odg)", "odg", false); 680 | } 681 | 682 | function createDeleteFileCard(include_subfolder, dryrun) { 683 | var statusCard = buildCardViaPropertiesIfExist(); 684 | if (statusCard) { 685 | return statusCard.build(); 686 | } 687 | var cardHeader = CardService.newCardHeader() 688 | .setTitle("DriveWorks") 689 | .setSubtitle("Delete files") 690 | .setImageUrl(getLogoURL()); 691 | var paymentSection = getPaymentSection(); 692 | var mainSection = CardService.newCardSection().setHeader("Set filters for files to delete"); 693 | 694 | var filenameMatch = CardService.newTextInput() 695 | .setFieldName("file_name_field") 696 | .setTitle("Delete when file name contains input"); 697 | mainSection.addWidget(filenameMatch); 698 | 699 | var fileType = getFileTypeWidget(); 700 | mainSection.addWidget(fileType); 701 | 702 | var action = CardService.newAction() 703 | .setFunctionName('deleteFileHandler') 704 | .setParameters({include_subfolder: JSON.stringify(include_subfolder), dryrun : JSON.stringify(dryrun)}); 705 | var button = CardService.newTextButton() 706 | .setText('Delete Files') 707 | .setOnClickAction(action) 708 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 709 | var buttonSet = CardService.newButtonSet() 710 | .addButton(button); 711 | mainSection.addWidget(buttonSet); 712 | var status = CardService.newDecoratedText().setText("File deletion progress is going to be shown in a spreadsheet once button is clicked. You will be notified via email once the job completes.").setWrapText(true); 713 | mainSection.addWidget(status); 714 | 715 | var card = CardService.newCardBuilder() 716 | .setHeader(cardHeader) 717 | .addSection(paymentSection) 718 | .addSection(mainSection); 719 | 720 | return card.build(); 721 | } 722 | 723 | function createDeleteFolderCard(include_subfolder, dryrun) { 724 | var statusCard = buildCardViaPropertiesIfExist(); 725 | if (statusCard) { 726 | return statusCard.build(); 727 | } 728 | var cardHeader = CardService.newCardHeader() 729 | .setTitle("DriveWorks") 730 | .setSubtitle("Delete folders") 731 | .setImageUrl(getLogoURL()); 732 | var paymentSection = getPaymentSection(); 733 | var mainSection = CardService.newCardSection().setHeader("Set filters for folders to delete"); 734 | 735 | var deleteEmpty = CardService.newSelectionInput() 736 | .setType(CardService.SelectionInputType.CHECK_BOX) 737 | .setFieldName("delete_empty_folders_field") 738 | .addItem("Delete empty folders only", "delete_empty_folder", false); 739 | mainSection.addWidget(deleteEmpty); 740 | 741 | var foldernameMatch = CardService.newTextInput() 742 | .setFieldName("folder_name_field") 743 | .setTitle("Delete when folder name contains input"); 744 | mainSection.addWidget(foldernameMatch); 745 | 746 | var action = CardService.newAction() 747 | .setFunctionName('deleteFolderHandler') 748 | .setParameters({include_subfolder: JSON.stringify(include_subfolder), dryrun : JSON.stringify(dryrun)}); 749 | 750 | var button = CardService.newTextButton() 751 | .setText('Delete Folders') 752 | .setOnClickAction(action) 753 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 754 | var buttonSet = CardService.newButtonSet() 755 | .addButton(button); 756 | mainSection.addWidget(buttonSet); 757 | 758 | var status = CardService.newDecoratedText().setText("Folder deletion progress is going to be shown in a spreadsheet once button is clicked. You will be notified via email once the job completes.").setWrapText(true).setTopLabel("Folder deletion status is shown below"); 759 | mainSection.addWidget(status); 760 | 761 | var card = CardService.newCardBuilder() 762 | .setHeader(cardHeader) 763 | .addSection(paymentSection) 764 | .addSection(mainSection); 765 | 766 | return card.build(); 767 | } 768 | 769 | function changeFileRenameHandler(e) { 770 | console.log('changeFileRenameHandler = ' + JSON.stringify(e)); 771 | var card = createRenameFileCard(e.formInput.rename_method_field, JSON.parse(e.parameters.include_subfolder), JSON.parse(e.parameters.dryrun)); 772 | var navigation = CardService.newNavigation().updateCard(card); 773 | var actionResponse = CardService.newActionResponseBuilder() 774 | .setNavigation(navigation); 775 | return actionResponse.build(); 776 | } 777 | 778 | function createRenameFileCard(rename_method="rename_partial", include_subfolder, dryrun) { 779 | var statusCard = buildCardViaPropertiesIfExist(); 780 | if (statusCard) { 781 | return statusCard.build(); 782 | } 783 | var cardHeader = CardService.newCardHeader() 784 | .setTitle("DriveWorks") 785 | .setSubtitle("Rename file") 786 | .setImageUrl(getLogoURL()); 787 | var paymentSection = getPaymentSection(); 788 | var mainSection = CardService.newCardSection().setHeader("Set filters to rename files"); 789 | var action = CardService.newAction() 790 | .setFunctionName('changeFileRenameHandler') 791 | .setParameters({include_subfolder: JSON.stringify(include_subfolder), dryrun : JSON.stringify(dryrun)}); 792 | var renameMethod = CardService.newSelectionInput() 793 | .setType(CardService.SelectionInputType.RADIO_BUTTON) 794 | .setFieldName("rename_method_field") 795 | .setOnChangeAction(action); 796 | if (rename_method === "rename_partial") { 797 | renameMethod 798 | .addItem("Replace matched file name", "rename_partial", true) 799 | .addItem("Rename full name", "rename_full", false) 800 | .addItem("Add string before or after file name", "rename_adding", false); 801 | } else if (rename_method === "rename_full") { 802 | renameMethod 803 | .addItem("Replace matched file name", "rename_partial", false) 804 | .addItem("Rename full name", "rename_full", true) 805 | .addItem("Add string before or after file name", "rename_adding", false); 806 | } else if (rename_method === "rename_adding") { 807 | renameMethod 808 | .addItem("Replace matched file name", "rename_partial", false) 809 | .addItem("Rename full name", "rename_full", false) 810 | .addItem("Add string before or after file name", "rename_adding", true); 811 | } else { 812 | throw "Unsupported rename method = " + rename_method; 813 | } 814 | 815 | mainSection.addWidget(renameMethod); 816 | if (rename_method === "rename_partial") { 817 | var search = CardService.newTextInput() 818 | .setFieldName("file_name_search_field") 819 | .setTitle("String to match file name"); 820 | var replace = CardService.newTextInput() 821 | .setFieldName("file_name_replace_field") 822 | .setTitle("String to replace the matches"); 823 | mainSection.addWidget(search).addWidget(replace); 824 | } else if (rename_method === "rename_full") { 825 | var newname = CardService.newTextInput() 826 | .setFieldName("new_file_name_field") 827 | .setTitle("New file name"); 828 | mainSection.addWidget(newname); 829 | } else if (rename_method === "rename_adding") { 830 | var before = CardService.newTextInput() 831 | .setFieldName("file_name_before_field") 832 | .setTitle("Add string before file name"); 833 | var after = CardService.newTextInput() 834 | .setFieldName("file_name_after_field") 835 | .setTitle("Add string after file name"); 836 | mainSection.addWidget(before).addWidget(after); 837 | } else { 838 | throw "Unsupported rename method X = " + rename_method; 839 | } 840 | 841 | var fileType = getFileTypeWidget(); 842 | mainSection.addWidget(fileType); 843 | 844 | var action = CardService.newAction() 845 | .setFunctionName('renameFileHandler') 846 | .setParameters({include_subfolder: JSON.stringify(include_subfolder), dryrun : JSON.stringify(dryrun)}); 847 | var button = CardService.newTextButton() 848 | .setText('Rename Files') 849 | .setOnClickAction(action) 850 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 851 | var buttonSet = CardService.newButtonSet() 852 | .addButton(button); 853 | mainSection.addWidget(buttonSet); 854 | var status = CardService.newDecoratedText().setText("File renaming progress is going to be shown in a spreadsheet once button is clicked. You will be notified via email once the job completes.").setWrapText(true).setTopLabel("File renaming status is shown below"); 855 | mainSection.addWidget(status); 856 | var card = CardService.newCardBuilder() 857 | .setHeader(cardHeader) 858 | .addSection(paymentSection) 859 | .addSection(mainSection); 860 | 861 | return card.build(); 862 | } 863 | 864 | function changeFolderRenameHandler(e) { 865 | console.log('changeFolderRenameHandler = ' + JSON.stringify(e)); 866 | var card = createRenameFolderCard(e.formInput.rename_method_field, JSON.parse(e.parameters.include_subfolder), JSON.parse(e.parameters.dryrun)); 867 | var navigation = CardService.newNavigation().updateCard(card); 868 | var actionResponse = CardService.newActionResponseBuilder() 869 | .setNavigation(navigation); 870 | return actionResponse.build(); 871 | } 872 | 873 | function createRenameFolderCard(rename_method="rename_partial", include_subfolder, dryrun) { 874 | var statusCard = buildCardViaPropertiesIfExist(); 875 | if (statusCard) { 876 | return statusCard.build(); 877 | } 878 | var cardHeader = CardService.newCardHeader() 879 | .setTitle("DriveWorks") 880 | .setSubtitle("Rename folder") 881 | .setImageUrl(getLogoURL()); 882 | var paymentSection = getPaymentSection(); 883 | var mainSection = CardService.newCardSection().setHeader("Set filters to rename folders"); 884 | var action = CardService.newAction() 885 | .setFunctionName('changeFolderRenameHandler') 886 | .setParameters({include_subfolder: JSON.stringify(include_subfolder), dryrun: JSON.stringify(dryrun)}); 887 | var renameMethod = CardService.newSelectionInput() 888 | .setType(CardService.SelectionInputType.RADIO_BUTTON) 889 | .setFieldName("rename_method_field") 890 | .setOnChangeAction(action); 891 | if (rename_method === "rename_partial") { 892 | renameMethod 893 | .addItem("Replace matched folder name", "rename_partial", true) 894 | .addItem("Rename full name", "rename_full", false) 895 | .addItem("Add string before or after folder name", "rename_adding", false); 896 | } else if (rename_method === "rename_full") { 897 | renameMethod 898 | .addItem("Replace matched folder name", "rename_partial", false) 899 | .addItem("Rename full name", "rename_full", true) 900 | .addItem("Add string before or after folder name", "rename_adding", false); 901 | } else if (rename_method === "rename_adding") { 902 | renameMethod 903 | .addItem("Replace matched folder name", "rename_partial", false) 904 | .addItem("Rename full name", "rename_full", false) 905 | .addItem("Add string before or after folder name", "rename_adding", true); 906 | } else { 907 | throw "Unsupported rename method = " + rename_method; 908 | } 909 | 910 | mainSection.addWidget(renameMethod); 911 | if (rename_method === "rename_partial") { 912 | var search = CardService.newTextInput() 913 | .setFieldName("folder_name_search_field") 914 | .setTitle("String to match folder name"); 915 | var replace = CardService.newTextInput() 916 | .setFieldName("folder_name_replace_field") 917 | .setTitle("String to replace the matches"); 918 | mainSection.addWidget(search).addWidget(replace); 919 | } else if (rename_method === "rename_full") { 920 | var newname = CardService.newTextInput() 921 | .setFieldName("new_folder_name_field") 922 | .setTitle("New folder name"); 923 | mainSection.addWidget(newname); 924 | } else if (rename_method === "rename_adding") { 925 | var before = CardService.newTextInput() 926 | .setFieldName("folder_name_before_field") 927 | .setTitle("Add string before folder name"); 928 | var after = CardService.newTextInput() 929 | .setFieldName("folder_name_after_field") 930 | .setTitle("Add string after folder name"); 931 | mainSection.addWidget(before).addWidget(after); 932 | } else { 933 | throw "Unsupported rename method X = " + rename_method; 934 | } 935 | 936 | var action = CardService.newAction() 937 | .setFunctionName('renameFolderHandler') 938 | .setParameters({include_subfolder: JSON.stringify(include_subfolder), dryrun : JSON.stringify(dryrun)}); 939 | 940 | var button = CardService.newTextButton() 941 | .setText('Rename Folders') 942 | .setOnClickAction(action) 943 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 944 | var buttonSet = CardService.newButtonSet() 945 | .addButton(button); 946 | mainSection.addWidget(buttonSet); 947 | var status = CardService.newDecoratedText().setText("Folder renaming progress is going to be shown in a spreadsheet once button is clicked. You will be notified via email once the job completes.").setWrapText(true).setTopLabel("Folder renaming status is shown below"); 948 | mainSection.addWidget(status); 949 | var card = CardService.newCardBuilder() 950 | .setHeader(cardHeader) 951 | .addSection(paymentSection) 952 | .addSection(mainSection); 953 | 954 | return card.build(); 955 | } 956 | 957 | function configureMore(e) { 958 | console.log('configureMore = ' + JSON.stringify(e)); 959 | var expire_time = getExpireTime(); 960 | if (expire_time!== -1 && expire_time <= Date.now()/1000) { 961 | var card = getBuymoreCard(); 962 | var navigation = CardService.newNavigation() 963 | .pushCard(card); 964 | var actionResponse = CardService.newActionResponseBuilder() 965 | .setNavigation(navigation); 966 | return actionResponse.build(); 967 | } 968 | 969 | var operation_type = e.formInput.drive_operation_type_field; 970 | var entity_type = e.formInput.entity_type_field; 971 | var include_subfolder = e.formInput.include_subfolders_field === "include_subfolder"; 972 | var dryrun = e.formInput.dryrun_field === "dryrun"; 973 | var card = null; 974 | if (operation_type === "delete") { 975 | var card = (entity_type === "file")? createDeleteFileCard(include_subfolder, dryrun) : createDeleteFolderCard(include_subfolder, dryrun); 976 | } 977 | 978 | if (operation_type === "rename") { 979 | var card = (entity_type === "file")? createRenameFileCard("rename_partial", include_subfolder, dryrun) : createRenameFolderCard("rename_partial", include_subfolder, dryrun); 980 | } 981 | 982 | if (card === null) { 983 | throw "Unsupported operation type = " + operation_type; 984 | } 985 | 986 | var navigation = CardService.newNavigation() 987 | .pushCard(card); 988 | var actionResponse = CardService.newActionResponseBuilder() 989 | .setNavigation(navigation); 990 | return actionResponse.build(); 991 | } 992 | 993 | function getExpireTime(version=1) { 994 | try { 995 | var initial_days = 7; 996 | var email = Session.getEffectiveUser().getEmail(); 997 | var key = email + version.toString(); 998 | var properties = PropertiesService.getScriptProperties(); 999 | var expire_time = properties.getProperty(key); 1000 | if (expire_time === null) { 1001 | var now = Date.now()/1000; 1002 | var future = now + initial_days*24*3600; 1003 | expire_time = properties.setProperty(key, future.toString()).getProperty(key); 1004 | } 1005 | console.log('expire_time = ' + expire_time); 1006 | var ret = parseInt(expire_time); 1007 | console.log('getExpireTime:' + key + ', initial_days = ' + initial_days + ', expire_time = ' + expire_time); 1008 | return ret; 1009 | } catch (e) { 1010 | console.log('getExpireTime error:' + e); 1011 | throw e; 1012 | } 1013 | } 1014 | 1015 | function getRemainingDays() { 1016 | try { 1017 | var email = Session.getEffectiveUser().getEmail(); 1018 | var expire_time = getExpireTime(); 1019 | if (expire_time === -1) { 1020 | return -1; 1021 | } 1022 | var now = Date.now()/1000; 1023 | if (expire_time <= now) { 1024 | return 0; 1025 | } 1026 | 1027 | var days = Math.ceil((expire_time - now) / (24*3600)); 1028 | console.log('Email:' + email + ',remaining days = ' + days); 1029 | return days; 1030 | } catch (e) { 1031 | console.log('getRemainingDays error:' + e); 1032 | throw e; 1033 | } 1034 | } 1035 | 1036 | function getBuymoreCard() { 1037 | var email = Session.getEffectiveUser().getEmail(); 1038 | var cardHeader = CardService.newCardHeader() 1039 | .setTitle("DriveWorks") 1040 | .setSubtitle("Buy More") 1041 | .setImageUrl(getLogoURL()); 1042 | var mainSection = CardService.newCardSection().setHeader("Buy more days for app access"); 1043 | var description = CardService.newDecoratedText().setText("To support users and add features, we are kindly asking to pay for its use. We hope you find the subscription price reasonable.").setWrapText(true); 1044 | mainSection.addWidget(description); 1045 | 1046 | var options = CardService.newSelectionInput() 1047 | .setType(CardService.SelectionInputType.RADIO_BUTTON) 1048 | .setTitle("Select payment option") 1049 | .setFieldName("payment_option_field") 1050 | .addItem("$5 per month (billed per month)", "month", false) 1051 | .addItem("$50 per year (billed per year)", "year", true); 1052 | mainSection.addWidget(options); 1053 | 1054 | var paypalLink = CardService.newOpenLink() 1055 | .setUrl("https://www.paypal.com/paypalme/SmartGAS888") 1056 | .setOpenAs(CardService.OpenAs.FULL_SIZE) 1057 | .setOnClose(CardService.OnClose.RELOAD_ADD_ON); 1058 | var paypal = CardService.newDecoratedText().setText("Option1 (PayPal): Please use PayPal and send an email to james.cui.code@gmail.com after the payment to update your access.").setWrapText(true).setOpenLink(paypalLink); 1059 | mainSection.addWidget(paypal); 1060 | 1061 | var buycoffeelink = CardService.newOpenLink() 1062 | .setUrl("https://www.buymeacoffee.com/smartgas") 1063 | .setOpenAs(CardService.OpenAs.FULL_SIZE) 1064 | .setOnClose(CardService.OnClose.RELOAD_ADD_ON) 1065 | var buycoffee = CardService.newDecoratedText().setText("Option2 (Buy Me A Coffee): Please use Buy me a coffee and send an email to james.cui.code@gmail.com after the payment to update your access.").setWrapText(true).setOpenLink(buycoffeelink); 1066 | mainSection.addWidget(buycoffee); 1067 | 1068 | if (email === "james.cui.code@gmail.com") { 1069 | var email_account = CardService.newTextInput() 1070 | .setFieldName("email_field") 1071 | .setTitle("Email account"); 1072 | var added_days = CardService.newTextInput() 1073 | .setFieldName("added_days") 1074 | .setTitle("Days"); 1075 | mainSection.addWidget(email_account).addWidget(added_days); 1076 | var setDaysAction = CardService.newAction().setMethodName("setDays") 1077 | var button = CardService.newTextButton() 1078 | .setText('Set Days') 1079 | .setOnClickAction(setDaysAction) 1080 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 1081 | var buttonSet = CardService.newButtonSet() 1082 | .addButton(button); 1083 | mainSection.addWidget(buttonSet); 1084 | } 1085 | 1086 | var card = CardService.newCardBuilder() 1087 | .setHeader(cardHeader) 1088 | .addSection(mainSection); 1089 | 1090 | return card.build(); 1091 | } 1092 | 1093 | function setDays(e) { 1094 | var email = e.formInput.email_field; 1095 | var days = parseInt(e.formInput.added_days); 1096 | setExpiredTime(email, 1, days); 1097 | } 1098 | 1099 | function setExpiredTime(user, version, days) { 1100 | try { 1101 | var key = user + version.toString(); 1102 | var properties = PropertiesService.getScriptProperties(); 1103 | if (days === -1) { 1104 | properties.setProperty(key, "-1"); 1105 | return -1; 1106 | } 1107 | var expire_time = properties.getProperty(key); 1108 | var now = Date.now()/1000; 1109 | var future = now + days*24*3600; 1110 | expire_time = properties.setProperty(key, future.toString()).getProperty(key); 1111 | var ret = parseInt(expire_time); 1112 | console.log('setExpiredTime: key = ' + key + ', expire_time = ' + expire_time); 1113 | return ret; 1114 | } catch (e) { 1115 | console.log('setExpiredTime error:' + e); 1116 | return -1; 1117 | } 1118 | } 1119 | 1120 | function buymore() { 1121 | var card = getBuymoreCard(); 1122 | var navigation = CardService.newNavigation() 1123 | .pushCard(card); 1124 | var actionResponse = CardService.newActionResponseBuilder() 1125 | .setNavigation(navigation); 1126 | return actionResponse.build(); 1127 | } 1128 | 1129 | function getPaymentSection() { 1130 | var endIcon = CardService.newIconImage().setIcon(CardService.Icon.VIDEO_PLAY); 1131 | var buymoreIcon = CardService.newIconImage().setIcon(CardService.Icon.DOLLAR); 1132 | var remainDays = getRemainingDays(); 1133 | var action = CardService.newAction().setFunctionName('buymore'); 1134 | var buymore = CardService.newDecoratedText().setText("Remaining " + remainDays + " days for trial").setStartIcon(buymoreIcon).setEndIcon(endIcon).setWrapText(true).setOnClickAction(action); 1135 | return CardService.newCardSection().addWidget(buymore); 1136 | } 1137 | 1138 | function getLogoURL() { 1139 | return "https://drive.google.com/uc?id=1YRuaxhrXiRlLu1SbTZopBKTDElfO3QIx"; 1140 | } 1141 | 1142 | function createHomeCard(item={}) { 1143 | var entityType = CardService.newSelectionInput() 1144 | .setType(CardService.SelectionInputType.RADIO_BUTTON) 1145 | .setTitle("Select drive entity type") 1146 | .setFieldName("entity_type_field") 1147 | .addItem("File", "file", true) 1148 | .addItem("Folder", "folder", false); 1149 | var operationType = CardService.newSelectionInput() 1150 | .setType(CardService.SelectionInputType.RADIO_BUTTON) 1151 | .setTitle("Select drive operation type") 1152 | .setFieldName("drive_operation_type_field") 1153 | .addItem("Delete", "delete", true) 1154 | .addItem("Rename", "rename", false); 1155 | var textStart = "Selected operation folder
"; 1156 | var textEnd = ""; 1157 | if ('title' in item) { 1158 | var text = textStart + item.title + textEnd; 1159 | } else { 1160 | var text = textStart + "My Drive" + textEnd; 1161 | } 1162 | var selectedFolder = CardService.newDecoratedText().setText(text).setWrapText(true).setBottomLabel("Change by selecting a different folder"); 1163 | 1164 | var dryrun = CardService.newSelectionInput() 1165 | .setType(CardService.SelectionInputType.CHECK_BOX) 1166 | .setFieldName("dryrun_field") 1167 | .addItem("Preview operations only without executing (dryrun)", "dryrun", false); 1168 | 1169 | var includeSubfolder = CardService.newSelectionInput() 1170 | .setType(CardService.SelectionInputType.CHECK_BOX) 1171 | .setFieldName("include_subfolders_field") 1172 | .addItem("Apply to subfolders", "include_subfolder", true); 1173 | 1174 | var configureMoreAction = CardService.newAction() 1175 | .setFunctionName('configureMore'); 1176 | var button = CardService.newTextButton() 1177 | .setText('Configure Details') 1178 | .setOnClickAction(configureMoreAction) 1179 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED); 1180 | var buttonSet = CardService.newButtonSet() 1181 | .addButton(button); 1182 | var mainSection = CardService.newCardSection() 1183 | .addWidget(operationType) 1184 | .addWidget(entityType) 1185 | .addWidget(selectedFolder) 1186 | .addWidget(dryrun) 1187 | .addWidget(includeSubfolder) 1188 | .addWidget(buttonSet); 1189 | 1190 | var paymentSection = getPaymentSection(); 1191 | var cardHeader = CardService.newCardHeader() 1192 | .setTitle("DriveWorks") 1193 | .setSubtitle("Drive operations made easy") 1194 | .setImageUrl(getLogoURL()); 1195 | var card = CardService.newCardBuilder() 1196 | .setHeader(cardHeader) 1197 | .addSection(paymentSection) 1198 | .addSection(mainSection); 1199 | 1200 | return card.build(); 1201 | } 1202 | 1203 | --------------------------------------------------------------------------------