Google Sheets for Kodular (Web component) + Template + Test project

Hi everyone :partying_face:,

Today I’d like to share two projects to better understand how :raising_hands:Tim:raising_hands:’s script for Google Sheets works.
Since several users have contacted me privately to integrate it into their own projects, so i decided to create a guide in the hope that it can be helpful.

I divided it into sections to make everything a bit more organized :nerd_face:


Add script to a Google Sheet

Mine is a slightly modified version of :raising_hands:Tim:raising_hands:’s guide, with the addition of a “readAllSheets” function by :raising_hands:Ibrahim:raising_hands:

script.txt (6.2 KB)

function doGet(e) {

  var ss = SpreadsheetApp.openById(e.parameter.ID);
  var fn = e.parameter.FN;
  var sh = null;
  var rg = null;

  if (e.parameter.SH) {
    sh = ss.getSheetByName(e.parameter.SH);
    if (sh) {
      rg = sh.getDataRange().getValues();
    }
  }
  
  // enter row number and column number to return A1 Notation Cell reference
  if ( fn == 'getCellRef' ) {
    var ref = sh.getRange(e.parameter.ROW,e.parameter.COL).getA1Notation();
    return ContentService.createTextOutput(ref);
  } 
  
  // enter start and end row numbers and column numbers to return A1 Notation Range reference
  else if ( fn == 'getRangeRef' ) {
    var ref = sh.getRange(e.parameter.ROW,e.parameter.COL,e.parameter.ROW1,e.parameter.COL1).getA1Notation();
    return ContentService.createTextOutput(ref);
  }
  
  // enter A1 notation reference to return cell value
  else if ( fn == 'readCell' ) {
    var ref = sh.getRange(e.parameter.REF).getValue();
    return ContentService.createTextOutput(ref);
  }
  
  // enter row number to return all values in row as a list
  else if ( fn == 'readRow' ) {
    var ref = sh.getRange(parseInt(e.parameter.ROW),1,1,rg[0].length).getValues().flat();
    return ContentService.createTextOutput(JSON.stringify(ref));
  }
  
  // enter column number (A=1/B=2/etc.) to return all values in column as a list
  else if ( fn == 'readCol' ) {
    var ref = sh.getRange(1, parseInt(e.parameter.COL),rg.length,1).getValues().flat();
    return ContentService.createTextOutput(JSON.stringify(ref));
  }
  
  // enter A1 notation reference to return range values as a list
  else if ( fn == 'readRange' ) {
    var ref = sh.getRange(e.parameter.REF).getValues();
    return ContentService.createTextOutput(JSON.stringify(ref));
  }
  
  // returns all values on sheet, including headers
  else if ( fn == 'readSheet' ) {
    return ContentService.createTextOutput(JSON.stringify(rg));     
  }

  // returns all sheets, including headers
  else if ( fn == 'readAllSheets' ) {
    const sheets = ss.getSheets();
    let result = {};
    sheets.forEach(sheet => {
     result[sheet.getName()] = sheet.getDataRange().getValues();
    });
    return ContentService.createTextOutput(JSON.stringify(result));
  }
 
  // outputs results for SQL query of all data  
  else if ( fn == 'querySheet' ) {
    var sql = e.parameter.SQL;
    var hdr = e.parameter.HDR;
    if ( hdr == 0 ) {
    var rgq = sh.getName() + "!" + sh.getDataRange().getA1Notation().replace('A1','A2');
    var qry = '=query(' + rgq + ';\"' + sql + '\";0)';
    } else if ( hdr == 1 ) {
    var rgq = sh.getName() + "!" + sh.getDataRange().getA1Notation();
    var qry = '=query(' + rgq + ';\"' + sql + '\";1)';
    }
    var ts = ss.insertSheet();
    var setQuery = ts.getRange(1,1).setFormula(qry);
    var getResult = ts.getDataRange().getValues();
    ss.deleteSheet(ts); 
    return ContentService.createTextOutput(JSON.stringify(getResult));
  }
  
  // enter row number to delete that row
  else if ( fn == 'deleteRow' ) {
    sh.deleteRow(parseInt(e.parameter.ROW));
    return ContentService.createTextOutput('Row ' + e.parameter.ROW + ' has been deleted');
  }
  
  // enter row number to delete that row
  else if ( fn == 'deleteCol' ) {
    sh.deleteColumn(parseInt(e.parameter.COL));
    return ContentService.createTextOutput('Column ' + e.parameter.COL + ' has been deleted');
  }
  
  // enter A1 notation reference to write cell value
  else if ( fn == 'writeCell' ) {
    sh.getRange(e.parameter.REF).setValue(e.parameter.DATA);
    return ContentService.createTextOutput('Value: ' + e.parameter.DATA + ' written to cell ' + e.parameter.REF);
  }
  
  // enter row number to write row values
  else if ( fn == 'writeRow' ) {
    var data = JSON.parse('[' + e.parameter.DATA + ']');
    sh.getRange(e.parameter.ROW,1,1,data[0].length).setValues(data);
    return ContentService.createTextOutput('Values: ' + e.parameter.DATA + ' written to row ' + e.parameter.ROW);

  }
  
  // enter row number to write row values
  else if ( fn == 'writeCol' ) {
    var data = JSON.parse(e.parameter.DATA)
    .map(function(x) {
      return [x];
    });
    sh.getRange(1,e.parameter.COL,data.length,1).setValues(data);
    return ContentService.createTextOutput('Values: ' + e.parameter.DATA + ' written to column ' + e.parameter.COL);
  }
  
  // enter A1 notation reference to write cell values
  else if ( fn == 'writeRange' ) {
    var data = JSON.parse(e.parameter.DATA);
    var ref = sh.getRange(e.parameter.REF).setValues(data);
    return ContentService.createTextOutput('Values: ' + e.parameter.DATA + ' written to cells ' + e.parameter.REF);
  }
  
  // append row to end write row values
  else if ( fn == 'appendRow' ) {
    var data = JSON.parse('[' + e.parameter.DATA + ']');
    var lr = sh.getLastRow();
    sh.insertRowAfter(lr);
    rowNum = lr + 1;
    sh.getRange(rowNum,1,1,data[0].length).setValues(data);
    return ContentService.createTextOutput('Values: ' + e.parameter.DATA + ' appended');
  }

  // append row with id to end write row values
  else if (fn == 'appendRowWithId') {
    var data = JSON.parse('[' + e.parameter.DATA + ']');
    var lr = sh.getLastRow();
    sh.insertRowAfter(lr);
    rowNum = lr + 1;
    data[0].unshift(rowNum - 1);
    sh.getRange(rowNum, 1, 1, data[0].length).setValues(data);
    return ContentService.createTextOutput('Values: ' + e.parameter.DATA + ' appended');
  }


  // append column to end and write column values
  else if ( fn == 'appendCol' ) {
    var data = JSON.parse(e.parameter.DATA)
    .map(function(x) {
      return [x];
    });
    var lc = sh.getLastColumn();
    sh.insertColumnAfter(lc);
    colNum = lc + 1;
    sh.getRange(1,colNum,data.length,1).setValues(data);
    return ContentService.createTextOutput('Values: ' + e.parameter.DATA + ' appended');
  }
  
  // enter A1 notation reference and formula to set formula to cell
  else if ( fn == 'insertFormula' ) {
    var ref = sh.getRange(e.parameter.REF).setValue(e.parameter.DATA);
    return ContentService.createTextOutput('Formula: ' + e.parameter.DATA + ' inserted to  ' + e.parameter.REF);
  }
}

Important note:
When you update the code, never create a New deployment, because it will change the Script ID.
To keep the same one, always use:
Deploy > Manage deployments

Edit (pencil) >> New version >> Deploy


Integration on Kodular

To call a function of the script, you just need to create a URL in the following format

Script URL?ID=SHEETID&FN=FUNCTION&PARAMETERS=VALUES


Script URL

https://script.google.com/macros/s/AKfycbxV0p6lAyg3bwSNxbCjDtCEm4f0m0YDwaAJwdcd3k2k7WqOT3PXu2vffGnVHUD3RWyj/exec


?ID=SHEETID

immagine

?ID=1ABqZ9y3dQq-IcLNBuhpqSgETkT_UJhFKeA4HX-g81FE


&FN=FUNCTION

&FN=getCellRef


&PARAMETER=VALUE

&ROW=1&COL=2


Final URL

https://script.google.com/macros/s/AKfycbxV0p6lAyg3bwSNxbCjDtCEm4f0m0YDwaAJwdcd3k2k7WqOT3PXu2vffGnVHUD3RWyj/exec?ID=1ABqZ9y3dQq-IcLNBuhpqSgETkT_UJhFKeA4HX-g81FE&FN=getCellRef&ROW=1&COL=2


Now you just need to perform a GET request with this URL using the Web component, and that’s it.
immagine
And we’ll receive a response with the requested value, in this case “B1”.


Different types of responses

Not all functions return simple text, in fact, I prefer to divide them into three categories.

Text


In this section, we’ll receive responses like

“A1”

“item1”

“Row 4 has been deleted”

List response


In this section, we’ll receive responses like ["item1","item2","item3"], which we’ll convert into proper lists/list of lists using Web.JSONTextDecode.

Dictionary response

immagine
In this section, we’ll receive responses in JSON format, such as readAllSheets, which returns the sheets as dictionaries in the form "SheetName":[SheetData].

{
"Sheet1":
  [
    ["Column A","Column B","Column C","Column D","Column E"],
    ["ItemA1","ItemB1","ItemC1","ItemD1","ItemE1"]
  ],
"Sheet2":
  [
    ["Column A","Column B","Column C","Column D","Column E"],
    ["ItemA1","ItemB1","ItemC1","ItemD1","ItemE1"]
  ],
"Sheet3":
  [
    ["Column A","Column B","Column C","Column D","Column E"],
    ["ItemA1","ItemB1","ItemC1","ItemD1","ItemE1"]
  ]
}

Anyway, in the script Tim kindly commented all the functions so you can easily consult them.
immagine


Functions template
Template

I created a very simple template to have a procedure for each function.


GoogleSheetsForKodular.aia (16.7 KB)
GoogleSheetsForKodular.xlsx (29.8 KB)

Each procedure
E.g. “getCellRef”


calls appScriptFunction

which receives the function name and a dictionary of parameters.

  • It shows a progress dialog
  • Sets the variable function with the function name (this will be useful in Web.GotText to know which function the response refers to)
  • Generates the URL
  • Performs the GET request

In Web.GotText I routed all the responses by setting responseContent to the formatted value so that only one notification is created with the formatted response.
From there, it’s up to you to customize and separate each function according to your needs.

readAllSheets is the only function where, based on the sheet names, I execute different procedures.

CSVTableToDictionary

If the sheet is called “dictionary”

The procedure CSVTableToDictionary, which is a modified version of CSV to dictionary allows creating a dictionary with the first column values as keys and, as values, dictionaries with "HeaderName":"CellValue".

immagine

{
   "item1":{
      "id":"item1",
      "name":"name1",
      "value":"value1",
      "info":"info1"
   }
}
firstIndexEachItem

This function was useful for the “list” sheet

which returned a list of lists.
The function simply takes the first index of each element.

from

[
 ["Items"],
 ["item1"],
 ["item2"],
 ["item3"],
 ...
]

to

[
 "Items",
 "item1",
 "item2",
 "item3",
 ...
]
Other sheets

The remaining sheets, like “listOfLists”


are simply saved as lists of lists.

[
 ["ValueA1","ValueB1","ValueC1","ValueD1"],
 ["ValueA2","ValueB2","ValueC2","ValueD2"],
 ["ValueA3","ValueB3","ValueC3","ValueD3"],
 ...
]

sheetName (SH) as global variable

With a small change to Web.URL and the procedures, we can avoid specifying sheetName on every function call by creating a global variable.
blocks (11)


blocks (9)

GSForKodularSHVariable.aia (15.2 KB)


Error handling

Thanks to Still-learning’s input,
Google Sheets for Kodular (Web component) + Template + Test project - #2 by Still-learning

I noticed that with this script error handling can become tricky, since it used to return HTML text.
So I modified the script a bit to get something more consistent.
Additionally, every time we receive a response, it will always include the parameters used in the request

so I removed the function variable, which is no longer needed

script.txt (9.1 KB)

function doGet(e) {
  try {
    if (!e.parameter.ID) throw {
      error: "MissingParameter", 
      message: "Parameter 'ID' is required"
    };
    if (!e.parameter.FN) throw {
      error: "MissingParameter",
      message: "Parameter 'FN' is required"
    };

    var ss = SpreadsheetApp.openById(e.parameter.ID);
    var fn = e.parameter.FN;
    var sh = null;
    var rg = null;

    if (e.parameter.SH) {
      sh = ss.getSheetByName(e.parameter.SH);
      if (!sh) throw {
        error: "SheetNotFound",
        message: "Sheet '" + e.parameter.SH + "' not found"
      };
      rg = sh.getDataRange().getValues();
    }

    function validateParams(params) {
      let missing = [];
      params.forEach(p => {
        if (e.parameter[p] === undefined) missing.push(p);
      });
      if (missing.length > 0) throw {
        error: "MissingParameter",
        message: "Missing parameters: " + missing.join(", ")
      };
    }

    switch (fn) {
      case 'getCellRef':
        validateParams(['ROW','COL','SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: sh.getRange(e.parameter.ROW,e.parameter.COL).getA1Notation(),
          SH: e.parameter.SH,
          ROW: e.parameter.ROW,
          COL: e.parameter.COL,
          FN: e.parameter.FN
        }));

      case 'getRangeRef':
        validateParams(['ROW','COL','ROW1','COL1','SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: sh.getRange(e.parameter.ROW,e.parameter.COL,e.parameter.ROW1,e.parameter.COL1).getA1Notation(),
          SH: e.parameter.SH,
          ROW: e.parameter.ROW,
          COL: e.parameter.COL,
          ROW1: e.parameter.ROW,
          COL1: e.parameter.COL,
          FN: e.parameter.FN
        }));

      case 'readCell':
        validateParams(['REF','SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: sh.getRange(e.parameter.REF).getValue(),
          SH: e.parameter.SH,
          REF: e.parameter.REF,
          FN: e.parameter.FN
        }));

      case 'readRow':
        validateParams(['ROW','SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: sh.getRange(parseInt(e.parameter.ROW),1,1,rg[0].length).getValues().flat(),
          SH: e.parameter.SH,
          ROW: e.parameter.ROW,
          FN: e.parameter.FN
        }));

      case 'readCol':
        validateParams(['COL','SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: sh.getRange(1, parseInt(e.parameter.COL),rg.length,1).getValues().flat(),
          SH: e.parameter.SH,
          COL: e.parameter.COL,
          FN: e.parameter.FN
        }));

      case 'readRange':
        validateParams(['REF','SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: sh.getRange(e.parameter.REF).getValues(),
          SH: e.parameter.SH,
          REF: e.parameter.REF,
          FN: e.parameter.FN
        }));

      case 'readSheet':
        validateParams(['SH']);
        return ContentService.createTextOutput(JSON.stringify({
          result: rg,
          SH: e.parameter.SH,
          FN: e.parameter.FN
        }));

      case 'readAllSheets':
        let result = {};
        ss.getSheets().forEach(sheet => {
          result[sheet.getName()] = sheet.getDataRange().getValues();
        });
        return ContentService.createTextOutput(JSON.stringify({
          result: result,
          FN: e.parameter.FN
        }));

      case 'querySheet':
        validateParams(['SQL','HDR','SH']);
        var sql = e.parameter.SQL;
        var hdr = parseInt(e.parameter.HDR);
        var rgq = sh.getName() + "!" + sh.getDataRange().getA1Notation();
        if (hdr === 0) rgq = rgq.replace('A1','A2');
        var qry = '=query(' + rgq + ';\"' + sql + '\";' + hdr + ')';
        var ts = ss.insertSheet();
        ts.getRange(1,1).setFormula(qry);
        var getResult = ts.getDataRange().getValues();
        ss.deleteSheet(ts); 
        return ContentService.createTextOutput(JSON.stringify({
          result: getResult,
          SH: e.parameter.SH,
          SQL: e.parameter.SQL,
          HDR: e.parameter.HDR,
          FN: e.parameter.FN
        }));

      case 'deleteRow':
        validateParams(['ROW','SH']);
        sh.deleteRow(parseInt(e.parameter.ROW));
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Row ' + e.parameter.ROW + ' deleted',
          SH: e.parameter.SH,
          ROW: e.parameter.ROW,
          FN: e.parameter.FN
        }));

      case 'deleteCol':
        validateParams(['COL','SH']);
        sh.deleteColumn(parseInt(e.parameter.COL));
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Column ' + e.parameter.COL + ' deleted',
          SH: e.parameter.SH,
          COL: e.parameter.COL,
          FN: e.parameter.FN
        }));

      case 'writeCell':
        validateParams(['REF','DATA','SH']);
        sh.getRange(e.parameter.REF).setValue(e.parameter.DATA);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Value written', value: e.parameter.DATA, cell: e.parameter.REF,
          SH: e.parameter.SH,
          REF: e.parameter.REF,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'writeRow':
        validateParams(['ROW','DATA','SH']);
        var data = JSON.parse('[' + e.parameter.DATA + ']');
        sh.getRange(e.parameter.ROW,1,1,data[0].length).setValues(data);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Row written', values: data, row: e.parameter.ROW,
          SH: e.parameter.SH,
          ROW: e.parameter.ROW,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'writeCol':
        validateParams(['COL','DATA','SH']);
        var data = JSON.parse(e.parameter.DATA).map(x => [x]);
        sh.getRange(1,e.parameter.COL,data.length,1).setValues(data);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Column written', values: data, col: e.parameter.COL,
          SH: e.parameter.SH,
          COL: e.parameter.COL,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'writeRange':
        validateParams(['REF','DATA','SH']);
        var data = JSON.parse(e.parameter.DATA);
        sh.getRange(e.parameter.REF).setValues(data);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Range written', values: data, ref: e.parameter.REF,
          SH: e.parameter.SH,
          REF: e.parameter.REF,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'appendRow':
        validateParams(['DATA','SH']);
        var data = JSON.parse('[' + e.parameter.DATA + ']');
        var lr = sh.getLastRow();
        sh.insertRowAfter(lr);
        sh.getRange(lr+1,1,1,data[0].length).setValues(data);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Row appended', values: data,
          SH: e.parameter.SH,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'appendRowWithId':
        validateParams(['DATA','SH']);
        var data = JSON.parse('[' + e.parameter.DATA + ']');
        var lr = sh.getLastRow();
        sh.insertRowAfter(lr);
        data[0].unshift(lr);
        sh.getRange(lr+1,1,1,data[0].length).setValues(data);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Row appended with ID', values: data,
          SH: e.parameter.SH,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'appendCol':
        validateParams(['DATA','SH']);
        var data = JSON.parse(e.parameter.DATA).map(x => [x]);
        var lc = sh.getLastColumn();
        sh.insertColumnAfter(lc);
        sh.getRange(1,lc+1,data.length,1).setValues(data);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Column appended', values: data,
          SH: e.parameter.SH,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      case 'insertFormula':
        validateParams(['REF','DATA','SH']);
        sh.getRange(e.parameter.REF).setFormula(e.parameter.DATA);
        return ContentService.createTextOutput(JSON.stringify({
          result: 'Formula inserted', formula: e.parameter.DATA, ref: e.parameter.REF,
          SH: e.parameter.SH,
          REF: e.parameter.REF,
          DATA: e.parameter.DATA,
          FN: e.parameter.FN
        }));

      default:
        throw {
          error: "UnknownFunction",
          message: "Function '" + fn + "' not recognized"
        };
    }

  } catch(err) {
    return ContentService.createTextOutput(JSON.stringify({
      error: err.error || "ExecutionError",
      message: err.message || err.toString(),
      ID: e.parameter.ID,
      SH: e.parameter.SH,
      FN: e.parameter.FN
    }));
  }
}

Now, for each request, two possible dictionaries are returned

  • An error dictionary, always containing an error key
    immagine
{
 "error":"SheetNotFound",
 "message":"Sheet 'Sheet2' not found",
 "ID":"1ABqZ9y3dQq-IcLNBuhpqSgETkT_UJhFKeA4HX-g81FE",
 "SH":"Sheet2",
 "FN":"getCellRef"
}
  • A result dictionary in case of success, always containing a result key
    immagine
{
 "result":"B1",
 "SH":"Sheet1",
 "ROW":"1",
 "COL":"2",
 "FN":"getCellRef"
}

I also updated the Web.GotText to make it compatible with the new script, and this is the result.


GSForKodularUpdatedScript.aia (19.9 KB)


Test project

What better way to learn than seeing in real time how the URL changes based on what we set?!

Don’t use this project as a base to create an app :grin:!
It’s unnecessarily complex, entirely managed through Dynamic Components (which makes it even harder to understand) and it was designed to be used in both APK and Companion only to study how the URL is generated.


GSForKodularTestProject.aia (77.3 KB)
GoogleSheetsForKodular.xlsx (29.8 KB)

Here, ScriptURL, SpreadsheetID, SheetName and Function are set in the urlParts dictionary


I hope I haven’t missed anything, and as always, if you need anything, don’t hesitate to ask.

Happy :kodular:oding!

7 Likes

Good one. Also users can send the data to gsheet of maximum 200 row at a time. It it exceeds, they will get 404 error and if they want to send data of row more than 200 then they have to use pagination which will work without an error. Happy coding.

1 Like

Thank you so much :raising_hands:Still-learning:raising_hands:, you always manage to find something useful to add :heart_hands:

I added a new section :partying_face:

Functions template >> Error handling

With an updated script that improves error readability.
However, to bypass the error 400 when there are more than 200 rows, the app itself must handle it by paginating the data or preventing the insertion exactly as you said.

1 Like