├── README.md ├── _config.gs ├── _log.js ├── class Drive.js ├── class Records.js └── web.js /README.md: -------------------------------------------------------------------------------- 1 | # google-apps-script-sheets-to-front-end-crud 2 | 3 | The Google Sheets (GS) are confortable, powerful and free tool to keep data. 4 | The Google Apps Scripts can handle GET and POST requests and is natively connected to google sheets as data storage. 5 | netlify.com allows to host html+js pages from git. 6 | 7 | joined together it may be a solution for numerous business cases. 8 | and it's free, serverles and robust. 9 | 10 | here will be the fishbone scrips that I use for GS to Web UI integrations 11 | 12 | # Getting started 13 | Separate GS with backend scripts from GS with data. It's safe, scaleable and don't cofuse users. 14 | As scripts works as API and are nested in GS i name those files as GAPI {project name} 15 | here is a template 16 | https://docs.google.com/spreadsheets/d/1toEbphpNpyR8v6mXPvZFpQ6cNyCpSR8YGydC7iOQMqI/edit?usp=sharing 17 | 18 | # GAPI file sheets 19 | a. "config" - it's good idea to keep as much as you can out from script. that makes changes faster and makes script reusable. 20 | 21 | b. "web log" - to debug and log web requests and responses i log them here 22 | 23 | c. "console" - sometimes results and error detaila need to be printed somewhere. 24 | 25 | # Common scripts 26 | # initConfig() 27 | most of scripts starts from these rows to get config values 28 | ``` 29 | function doIt(config){ 30 | if(config===undefined){ 31 | config= initConfig() 32 | } 33 | } 34 | ``` 35 | 36 | # Log(data, [destination]) 37 | just put whatever you want to log here and get it as row added with timestamp in the destination ("web log" by default) 38 | it's recommended to make new file for log destination if there will be 10K+ log rows to keep GAPI file small and fast. 39 | 40 | # web.js 41 | have files that get GET POST requests, route them to handler, prepare and return responses. 42 | 43 | # Drive() 44 | class that handles sheets access operations and returns responses with structureda data or handled and documented errors. 45 | 46 | -------------------------------------------------------------------------------- /_config.gs: -------------------------------------------------------------------------------- 1 | /** 2 | goes to "config" sheet and converts rows to object 3 | the object have 4 | */ 5 | 6 | function initConfig() { 7 | var ws =SpreadsheetApp.getActive().getSheetByName("config") 8 | var da =ws.getDataRange().getValues() 9 | var config = {} 10 | da.forEach(function(row){ 11 | if(row[0]!=""&&row[1]!=""){ 12 | if(row[2]!=""){ 13 | if(config[row[2]]==undefined){ 14 | config[row[2]]={} 15 | } 16 | config[row[2]][row[0]]=row[1] 17 | }else{ 18 | config[row[0]]=row[1] 19 | } 20 | } 21 | }) 22 | return config 23 | } 24 | -------------------------------------------------------------------------------- /_log.js: -------------------------------------------------------------------------------- 1 | // run it to get the idea 2 | function testLog(){ 3 | var mes = "bla, bla" //string 4 | var num = 1 //number 5 | var obj = {foo:"boo"} //object 6 | var row = ["1","2"] //object 7 | var nul = null 8 | 9 | Log(mes) 10 | Log(num) 11 | Log(obj) 12 | Log(row) 13 | Log() 14 | Log(nul) 15 | } 16 | 17 | /** 18 | * the script will: 19 | * get data type of input, process it accordingly, 20 | * will add timestamp 21 | * and add as a new row to google file and sheet associated with destination. it's recommended to make new file for log destination if there will be 10K+ log rows. 22 | * @param {whatever} data - put any values, row range values and functions responses. objects will be stringlified 23 | * [ @param {string} destination ] - some clear for you name of destination. by default print to "web log". 24 | 25 | */ 26 | 27 | function Log(data,destination){ 28 | 29 | var getDataType = function(data){ 30 | var type = typeof data 31 | if(type=="object"){ 32 | if(data===null){ 33 | return "mes" 34 | }else if(data.length===undefined){ 35 | return "obj" 36 | }else{ 37 | return "row" 38 | } 39 | }else if(type===undefined){ 40 | return "undef" 41 | }else{ 42 | return "mes" 43 | } 44 | } 45 | 46 | var getRow = function(data,type){ 47 | // adds timestamp and prepare row to paste to destination 48 | var date = (new Date()).toISOString() 49 | 50 | if(type=="row"){ 51 | data.unshift(date) 52 | return data 53 | }else if(type=="obj"){ 54 | return [date,JSON.stringify(data,null,3)] 55 | }else{ 56 | return [date,data] 57 | } 58 | } 59 | 60 | // ---- main ---- 61 | 62 | // associate name of destination with sheet here 63 | if(destination===undefined){ 64 | var ws = SpreadsheetApp.getActive().getSheetByName("web Log") 65 | }else{ 66 | var ws = SpreadsheetApp.getActive().getSheetByName("web Log") 67 | } 68 | 69 | 70 | var type = getDataType(data) 71 | var row = getRow(data,type) 72 | ws.appendRow(row) 73 | 74 | } 75 | -------------------------------------------------------------------------------- /class Drive.js: -------------------------------------------------------------------------------- 1 | // TODO test and comments 2 | 3 | function testDrive(){ 4 | 5 | var ssId = "1Dt9Xs7gZKrhxAl9iVMhL3l-E6xuHW4B3lga9WxWVXy0" //gapi 6 | var wsName = "Business Profile" 7 | 8 | var config= initConfig() 9 | var colmap = config['colmap Business Profile'] 10 | //var driveResp = Drive().getDaFromWsInSsParsed(ssId,wsName,colmap) 11 | 12 | var search = { 13 | "HH Space ID":"hh2", 14 | "Business E-mail":"e2" 15 | } 16 | var filteredResp = Drive().getDaFromWsInSsParsedAndFiltered(ssId,wsName,colmap,search) 17 | return 18 | } 19 | 20 | 21 | function Drive(config) { 22 | if(config===undefined){ 23 | config= initConfig() 24 | } 25 | 26 | var Errors = function(){ 27 | var set = function(status,mes){ 28 | return { 29 | status:status, 30 | mes:mes 31 | } 32 | } 33 | return set 34 | } 35 | 36 | var getSsById = function(ssId){ 37 | try{ 38 | return{ 39 | status:"OK", 40 | ss:SpreadsheetApp.openById(ssId) 41 | } 42 | }catch(e){ 43 | return Errors().set("errow in file with id "+ ssId, e) 44 | } 45 | } 46 | 47 | var getJsonById = function(fileId){ 48 | try{ 49 | var file = DriveApp.getFileById(fileId) 50 | var text = file.getBlob().getDataAsString() 51 | var json = JSON.parse(text) 52 | return{ 53 | status:"OK", 54 | json:json 55 | } 56 | }catch(e){ 57 | return Errors().set("errow with getting json from file with id "+ fileId, e) 58 | } 59 | } 60 | 61 | var getWsByName = function(ss,wsName){ 62 | try{ 63 | return{ 64 | status:"OK", 65 | ws:ss.getSheetByName(wsName) 66 | } 67 | }catch(e){ 68 | Errors().set("errow with sheet named "+ wsName, e) 69 | } 70 | } 71 | 72 | var getWsInSs = function(ssId,wsName){ 73 | var ssResp = getSsById(ssId) 74 | if(ssResp.status!="OK"){return ssResp} 75 | return getWsByName(ssResp.ss,wsName) 76 | } 77 | 78 | 79 | var getDaFromWsInSs = function(ssId,wsName){ 80 | var ssResp = getSsById(ssId) 81 | if(ssResp.status!="OK"){return ssResp} 82 | var wsResp = getWsByName(ssResp.ss,wsName) 83 | if(wsResp.status!="OK"){return wsResp} 84 | try{ 85 | return{ 86 | status:"OK", 87 | da:wsResp.ws.getDataRange().getValues() 88 | } 89 | }catch(e){ 90 | Errors().set("errow on da get with sheet named "+ wsName, e) 91 | } 92 | } 93 | 94 | var getDaFromWsHere = function(wsName){ 95 | var ss = SpreadsheetApp.getActive() 96 | var wsResp = getWsByName(ss,wsName) 97 | if(wsResp.status!="OK"){return wsResp} 98 | try{ 99 | return{ 100 | status:"OK", 101 | da:wsResp.ws.getDataRange().getValues() 102 | } 103 | }catch(e){ 104 | Errors().set("errow on da get with sheet named "+ wsName, e) 105 | } 106 | } 107 | 108 | var getDaFromWsInSsParsed = function(ssId,wsName,colmap,mode){ 109 | 110 | var modes = { 111 | rowNumberIsKey:"rowNumberIsKey", 112 | idColumnIsKey:"idColumnIsKey", 113 | columnTitlesAreKeys:"columnTitlesAreKeys" 114 | } 115 | 116 | var configKeysForbiddenToParse = [ 117 | "_firstRow", 118 | "_emptyCheck", 119 | "_idColumn", 120 | "_mode", 121 | "_keysRow" 122 | ] 123 | 124 | var getTitles = function(da){ 125 | if(colmap._titlesRow){ 126 | return da[Number(colmap._titlesRow)-1] 127 | }else{ 128 | return [] 129 | } 130 | } 131 | 132 | var checkIfProcessRow = function(rowId){ 133 | if(Number(rowId)+1>=colmap._firstRow){ 134 | return true 135 | }else{ 136 | return false 137 | } 138 | } 139 | 140 | var checkIfNotEmply = function(row){ 141 | if(row[Number(colmap._emptyCheck)-1]!=""){ 142 | return true 143 | }else{ 144 | return false 145 | } 146 | } 147 | 148 | var getRow = function(row){ 149 | var ret = {} 150 | for(var key in colmap){ 151 | if(checkIfProcessKey(key)){ 152 | ret[key] = row[Number(colmap[key])-1] 153 | }else{ 154 | continue 155 | } 156 | } 157 | return ret 158 | } 159 | 160 | var getRowTitled = function(row,titles){ 161 | var ret = {} 162 | for(var columnId in titles){ 163 | if(columnId!=""){ 164 | ret[titles[columnId]] = row[columnId] 165 | }else{ 166 | continue 167 | } 168 | } 169 | return ret 170 | } 171 | 172 | 173 | var checkIfProcessKey = function(key){ 174 | if(configKeysForbiddenToParse.indexOf(key)==-1){ 175 | return true 176 | }else{ 177 | return false 178 | } 179 | } 180 | 181 | var daFromWsInSsResp = getDaFromWsInSs(ssId,wsName) 182 | if(daFromWsInSsResp.status!="OK"){return daFromWsInSsResp} 183 | 184 | var rows = {} 185 | 186 | var titles = getTitles(daFromWsInSsResp.da) 187 | 188 | for(var rowId in daFromWsInSsResp.da){ 189 | if(checkIfProcessRow(rowId)){ 190 | var row = daFromWsInSsResp.da[rowId] 191 | if(checkIfNotEmply(row)){ 192 | if(colmap._mode==modes.idColumnIsKey){ 193 | var id = row[Number(colmap._idColumn)-1] 194 | var rowObj = getRow(row) 195 | rows[id] = rowObj 196 | }else if(colmap._mode==modes.rowNumberIsKey){ 197 | var id = rowId 198 | var rowObj = getRow(row) 199 | rows[id] = rowObj 200 | }else if(colmap._mode==modes.columnTitlesAreKeys){ 201 | var id = rowId 202 | var rowObj = getRowTitled(row,titles) 203 | rows[id] = rowObj 204 | } 205 | 206 | }else{ 207 | continue 208 | } 209 | }else{ 210 | continue 211 | } 212 | } 213 | 214 | return { 215 | status:"OK", 216 | rows:rows 217 | } 218 | } 219 | 220 | 221 | var getDaFromWsInSsParsedAndFiltered = function(ssId,wsName,colmap,search){ 222 | var isMatch = function(row,search){ 223 | for(var title in search){ 224 | var valueToBe = search[title] 225 | var valueIs = row[title] 226 | if(valueToBe==valueIs){ 227 | //dn 228 | }else{ 229 | return false 230 | } 231 | } 232 | return true 233 | } 234 | 235 | var voidDeleteIfNotMatch = function(parsed,search){ 236 | for(var rowId in parsed.rows){ 237 | var isMatchResp = isMatch(parsed.rows[rowId],search) 238 | if(isMatchResp){ 239 | //dn 240 | }else{ 241 | delete parsed.rows[rowId] 242 | } 243 | } 244 | } 245 | 246 | var parsedResp = getDaFromWsInSsParsed(ssId,wsName,colmap,"columnTitlesAreKeys") 247 | if(parsedResp.status!="OK"){return parsedResp} 248 | voidDeleteIfNotMatch(parsedResp,search) 249 | parsedResp.rowsFound = (Object.keys(parsedResp.rows)).length 250 | return parsedResp 251 | 252 | } 253 | 254 | 255 | return { 256 | getSsById:getSsById, 257 | getJsonById:getJsonById, 258 | getWsByName:getWsByName, 259 | getWsInSs:getWsInSs, 260 | getDaFromWsInSs:getDaFromWsInSs, 261 | getDaFromWsInSsParsed:getDaFromWsInSsParsed, 262 | getDaFromWsInSsParsedAndFiltered:getDaFromWsInSsParsedAndFiltered, 263 | getDaFromWsHere:getDaFromWsHere 264 | } 265 | 266 | } 267 | -------------------------------------------------------------------------------- /class Records.js: -------------------------------------------------------------------------------- 1 | function tetsRecords(){ 2 | 3 | } 4 | 5 | function Records(){ 6 | 7 | var getRowsForSheet = function(records){ 8 | var ret = {} 9 | var rows = [] 10 | var columns = getColumns(records) 11 | rows.push(columns) 12 | 13 | for(var recordId in records){ 14 | var record = records[recordId] 15 | var row = [] 16 | for(var columnId in columns){ 17 | var column = columns[columnId] 18 | var value = (record[column]===undefined)?"":record[column]; 19 | row.push(value) 20 | } 21 | rows.push(row) 22 | } 23 | 24 | return rows 25 | } 26 | 27 | var getColumns = function(records){ 28 | var columns = {} 29 | for(var recordId in records){ 30 | var record = records[recordId] 31 | for(column in record){ 32 | columns[column] = "" 33 | } 34 | } 35 | 36 | return Object.keys(columns) 37 | } 38 | 39 | var getRowsForSheetValidated = function(records){ 40 | var ret = {} 41 | try{ 42 | ret.arr = getRowsForSheet(records) 43 | ret.status = "OK" 44 | }catch(e){ 45 | ret.status = "error" 46 | ret.mes = e 47 | } 48 | return ret 49 | } 50 | 51 | 52 | 53 | return { 54 | getRowsForSheet:getRowsForSheet, 55 | getRowsForSheetValidated:getRowsForSheetValidated 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GET POST will be handled according to mode and request source 3 | * the GAPI url looks like this 4 | * https://script.google.com/macros/s/{script Id}/exec?mode=foo 5 | */ 6 | 7 | /** 8 | * strings are declared once 9 | */ 10 | var GAPI_MODES = { 11 | gsToJson:"gs-to-json",// unsafe 12 | gsToJsonSafe:"gs-to-json-safe",// unsafe 13 | } 14 | 15 | //doGet is google apps script reserved function name. it runs on GET requests. 16 | function doGet(e) { 17 | Log(e) 18 | 19 | /** 20 | * https://script.google.com/macros/s/{script Id}/exec?mode=foo 21 | * is equal to 22 | * var e = {parameter:{ 23 | * "mode": "foo" 24 | * } 25 | * } 26 | */ 27 | 28 | var paramsObj = e.parameter // ?mode=foo turns into e.parameter = {mode:foo} 29 | 30 | if(paramsObj.mode==""){ 31 | var resp = {} // here to be some function that returns object 32 | 33 | // the object is wrapped by output() according to request source and is sent to response 34 | 35 | return output().web(e,resp) 36 | }else{ 37 | return output().fromDefault(e) 38 | } 39 | } 40 | 41 | 42 | 43 | //doPost is google apps script reserved function name. it runs on POST requests. 44 | 45 | function doPost(e) { 46 | Log(e) 47 | var mode = e.parameter.mode; Log(mode); 48 | var data = e.postData.contents; Log(data) 49 | 50 | // for post request all selection of handlers according to modes and data is taken into separate function. 51 | // as some posted values may influence on handler selection 52 | // and to keep the function small and clear 53 | 54 | var responseObj = router(mode,data); 55 | Log(responseObj) 56 | return output().web(e,responseObj) 57 | } 58 | 59 | 60 | 61 | 62 | function output(){ 63 | /* 64 | * returns functions: 65 | * fromDefault - for cases when provided mode don't have handler yet 66 | * web - for other cases 67 | */ 68 | 69 | var fromDefault= function(e){ 70 | //prepares error details and retnrns function web() 71 | Log("there is no handler for "+e.pathInfo) 72 | var obj = {status:"there is no handler for "+e.pathInfo + " e: " +JSON.stringify(e,false,2)} 73 | return (ret.web(e,obj)) 74 | } 75 | 76 | /** 77 | *@param {Object} obj that will be returned as json as GAPI response 78 | */ 79 | var web = function(e,obj){ 80 | Log(["web output obj status "+obj.status]) 81 | if(e.parameter.callback===undefined){ 82 | return ContentService.createTextOutput(JSON.stringify(obj)) 83 | }else{ // for AJAX 84 | // if GAPI is called by $.ajax it shall be wrapped 85 | return ContentService.createTextOutput(e.parameter.callback + "(" + JSON.stringify(obj) + ")") 86 | .setMimeType(ContentService.MimeType.JAVASCRIPT); 87 | } 88 | 89 | } 90 | 91 | return { 92 | fromDefault:fromDefault, 93 | web:web 94 | } 95 | 96 | } 97 | 98 | // TODO comments 99 | function router(mode,data){ 100 | var ret = {} 101 | // convert data string into object 102 | try{ 103 | var dataObj = JSON.parse(data) 104 | }catch(e){ 105 | // we expect json. so if it's not - something went wrong 106 | return { 107 | status: "error in json "+ data 108 | } 109 | } 110 | 111 | var config = initConfig() 112 | 113 | if(mode==GAPI_MODES.gsToJson){ 114 | var ssId = dataObj.fileId 115 | var wsName = dataObj.sheetName 116 | var colmap = dataObj.colmap 117 | var search = dataObj.search 118 | var resp = Drive().getDaFromWsInSsParsedAndFiltered(ssId,wsName,colmap,search) 119 | return resp 120 | }else if(mode==GAPI_MODES.gsToJsonSafe){ 121 | var projectName = dataObj.projectName 122 | var ssId = config[projectName].fileId 123 | var wsName = config[projectName].sheetName 124 | var colmap = { 125 | _mode:config[projectName]._mode, 126 | _firstRow:config[projectName]._firstRow, 127 | _emptyCheck:config[projectName]._emptyCheck, 128 | _titlesRow:config[projectName]._titlesRow 129 | } 130 | var search = dataObj.search 131 | var resp = Drive().getDaFromWsInSsParsedAndFiltered(ssId,wsName,colmap,search) 132 | return resp 133 | } 134 | else{ 135 | ret = {status:"there is no handler for "+mode} 136 | } 137 | return ret 138 | } 139 | 140 | 141 | 142 | 143 | 144 | 145 | // some emulates to play with data 146 | 147 | 148 | function emulatePostGsToJsonUnsafe(){ 149 | var fileId = "1Dt9Xs7gZKrhxAl9iVMhL3l-E6xuHW4B3lga9WxWVXy0" 150 | var sheetName = "Business Profile" 151 | var search = { 152 | "HH Space ID":"hh2", 153 | // "Business E-mail":"e2" 154 | } 155 | var colmap = { 156 | _mode:"columnTitlesAreKeys", 157 | _firstRow:2, 158 | _emptyCheck:1, 159 | _titlesRow:1 160 | } 161 | 162 | var payload = { 163 | fileId: fileId, // from the source config 164 | sheetName: sheetName, // from the source config 165 | search:search, 166 | colmap:colmap 167 | } 168 | 169 | var e = { 170 | parameter: 171 | { 172 | "mode": GAPI_MODES.gsToJson 173 | }, 174 | postData:{ 175 | contents:JSON.stringify(payload) 176 | } 177 | } 178 | 179 | doPost(e) 180 | } 181 | 182 | function emulateGet(){ 183 | // https://script.google.com/macros/s/{script Id}/exec?mode=foo 184 | // is equal to 185 | var e = {parameter:{ 186 | "mode": "foo" 187 | }} 188 | 189 | doGet(e) 190 | } 191 | 192 | 193 | function emulatePostGsToJsonSafe(){ 194 | var projectName = "projectOnboarding-BP" 195 | var search = { 196 | "HH Space ID":"hh2", 197 | // "Business E-mail":"e2" 198 | } 199 | 200 | var payload = { 201 | projectName: projectName, 202 | search:search, 203 | } 204 | 205 | var e = { 206 | parameter: 207 | { 208 | "mode": GAPI_MODES.gsToJsonSafe 209 | }, 210 | postData:{ 211 | contents:JSON.stringify(payload) 212 | } 213 | } 214 | 215 | doPost(e) 216 | } 217 | 218 | --------------------------------------------------------------------------------