Code.gs (347 lines of code) (raw):
/**
* @OnlyCurrentDoc Limits the script to only accessing the current spreadsheet.
*/
/**
* Adds a custom menu with items to show the sidebar and dialog.
*
* @param {Object} e The event parameter for a simple onOpen trigger.
*/
function onOpen(e) {
SpreadsheetApp.getUi()
.createAddonMenu()
.addItem('Send To Cluster...', 'showPushDataSidebar')
.addToUi();
}
/**
* Runs when the add-on is installed; calls onOpen() to ensure menu creation and
* any other initializion work is done immediately.
*
* @param {Object} e The event parameter for a simple onInstall trigger.
*/
function onInstall(e) {
onOpen(e);
}
/**
* Opens a sidebar. The sidebar structure is described in the Sidebar.html
* project file.
*/
function showPushDataSidebar() {
var ui = HtmlService.createTemplateFromFile('ConnectionDetailsSidebar')
.evaluate()
.setTitle('Send Data To Cluster');
SpreadsheetApp.getUi().showSidebar(ui);
}
/**
* Checks to see if the cluster is accessible by calling /_status
* Throws an error if the cluster does not return a 200
*
* @param {Object} host The set of parameters needed to connect to a cluster.
*/
function checkClusterConnection(host) {
isValidHost(host);
var url = [(host.use_ssl) ? 'https://' : 'http://',
host.host,':',host.port,'/'].join('');
var options = getDefaultOptions(host.username,host.password);
options['muteHttpExceptions'] = true;
try {
var resp = UrlFetchApp.fetch(url, options);
if(resp.getResponseCode() != 200) {
var jsonData = JSON.parse(resp.getContentText());
if(jsonData.message == 'forbidden') {
throw "The username and/or password is incorrect."
}
throw jsonData.message;
}
} catch(e) {
throw 'There was a problem connecting to your cluster. Please the connection details and try again.'
}
}
function clearData() {
var userProperties = PropertiesService.getUserProperties();
userProperties.deleteAllProperties();
}
function saveHostData(host) {
isValidHost(host);
var userProperties = PropertiesService.getUserProperties();
userProperties.setProperties(host);
}
function getHostData() {
var userProperties = PropertiesService.getUserProperties();
var data = userProperties.getProperties();
return {
host: data['host'],
port: data['port'],
use_ssl: (typeof data['use_ssl'] == 'string') ? (data['use_ssl'] == 'true') : data['use_ssl'],
username: data['username'],
password: data['password'],
was_checked: data['was_checked']
};
}
/**
* Returns a clean name to use as an index based on the sheet name
*
*/
function getSheetName() {
try {
var name = SpreadsheetApp.getActiveSheet().getName();
return name.replace(/[^0-9a-zA-Z]/g,'_').toLowerCase();
} catch(e) {
return "";
}
}
/**
* Highlights the cells in the A1 range
* @param {String} a1_range A1 notation for the cells to highlight - required.
*/
function highlightData(a1_range) {
var sheet = SpreadsheetApp.getActiveSheet();
try {
var range = sheet.getRange(a1_range);
if(a1_range.length == 3) {
sheet.setActiveSelection(sheet.getRange(range.getRow(),range.getColumn(),range.getHeight()));
} else {
sheet.setActiveSelection(range);
}
} catch(e) {
throw "The range entered was invalid. Please verify the range entered.";
}
}
/**
* Gets the default locations for headers and data, namely the first row
* and all other rows.
*/
function getDefaultRange() {
try {
var sheet = SpreadsheetApp.getActiveSheet();
var data_range = sheet.getRange(1, 1, sheet.getLastRow(), sheet.getLastColumn());
return data_range.getA1Notation();
} catch(e) {
throw "There is no data in the sheet.";
}
}
function getSelectedRange() {
try {
var sheet = SpreadsheetApp.getActiveSheet();
return sheet.getActiveRange().getA1Notation();
} catch(e) {
throw "No range selected.";
}
}
function getDocIdOptions() {
var data_width = SpreadsheetApp.getActiveSheet().getDataRange().getWidth();
var options = [];
for(var i = 1; i <= data_width; i++) {
options.push({ value: String.fromCharCode(64+i)+":"+String.fromCharCode(64+i),
text: String.fromCharCode(64+i)});
}
return options;
}
/**
* Attempts to validate that the data in each column is the same format.
* If something isn't the same, it adds a note to the sheet and throws an
* exception.
*/
function validateData(new_value) {
var sheet = SpreadsheetApp.getActiveSheet();
var range = null;
try {
range = sheet.getRange(new_value);
} catch(e) {
throw 'There is no data in the sheet.';
}
clearNotes();
var start_row = parseInt(range.getRow())+1;
var start_col = parseInt(range.getColumn());
var formats = range.getNumberFormats();
formats.shift();
var header_formats = formats.shift();
for(var r in formats) {
for(var c in formats[r]) {
if(formats[r][c] != header_formats[c]) {
var note_row = start_row+1+parseInt(r);
var note_col = start_col+parseInt(c);
var cell = sheet.getRange(note_row,note_col)
cell.setNote('Not the same format as first row. This may cause data to not be inserted into your cluster. ~SpreadsheetToES');
throw "Not all data formats are the same. See the note in the sheet.";
}
}
}
}
/**
* Attempts to clear only the notes that we've made
*/
function clearNotes() {
var sheet = SpreadsheetApp.getActiveSheet();
var notes_range = sheet.getRange(1, 1, sheet.getMaxRows(), sheet.getMaxColumns()).getNotes();
for(var r in notes_range) {
for(var c in notes_range[r]) {
if(notes_range[r][c] && notes_range[r][c].indexOf('~SpreadsheetToES') >= 0) {
sheet.getRange(1+parseInt(r),1+parseInt(c)).clearNote()
}
}
}
}
/**
* Pushes data from the spreadsheet to the cluster.
*
* @param {Object} host The set of parameters needed to connect to a cluster - required.
* @param {String} index The index name - required.
* @param {String} index_type The index type - required.
* @param {String} template The name of the index template to use.
* @param {String} header_range The A1 notion of the header row.
* @param {String} data_range The A1 notion of the data rows.
*/
function pushDataToCluster(index,index_type,template,data_range_a1,doc_id_range_a1) {
var host = getHostData();
isValidHost(host);
if(!index) { throw "Index name cannot be empty." }
if(index.indexOf(' ')>=0) { throw "Index should not have spaces." }
if(!index_type) { throw "Index type cannot be empty." }
if(index_type.indexOf(' ')>=0) { throw "Index type should not have spaces." }
if(template && template.indexOf(' ')>=0) { throw "Template name should not have spaces." }
if(!data_range_a1) { throw "Document data range cannot be empty." }
var data_range = null;
try {
data_range = SpreadsheetApp.getActiveSheet().getRange(data_range_a1);
} catch(e) {
throw "The document data range entered was invalid. Please verify the range entered.";
}
var data = data_range.getValues();
if(data.length <= 0) {
throw "No data to push."
}
var headers = data.shift();
for(var i in headers) {
if(!headers[i]) {
throw 'Document key name cannot be empty. Please make sure each cell in the document key names range has a value.';
}
headers[i] = headers[i].replace(/[^0-9a-zA-Z]/g,'_'); // clean up the column names for index keys
headers[i] = headers[i].toLowerCase();
if(!headers[i]) {
throw 'Document key name cannot be empty. Please make sure each cell in the document key names range has a value.';
}
}
var doc_id_data = null;
if(doc_id_range_a1) {
var doc_id_range = null;
try {
doc_id_range = SpreadsheetApp.getActiveSheet().getRange(doc_id_range_a1);
} catch(e) {
throw "The document id column entered was invalid. Please verify the id column entered."
}
doc_id_range = doc_id_range.offset(data_range.getRow(), 0,data_range.getHeight()-1);
doc_id_data = doc_id_range.getValues();
}
var bulkList = [];
if(template) { createTemplate(host,index,template); }
var did_send_some_data = false;
for(var r=0;r<data.length;r++) {
var row = data[r];
var toInsert = {};
for(var c=0;c<row.length;c++) {
if(row[c]) {
toInsert[headers[c]] = row[c];
}
}
if(Object.keys(toInsert).length > 0) {
if(doc_id_data) {
if(!doc_id_data[r][0]) {
throw "Missing document id for data row: "+(r+1);
}
bulkList.push(JSON.stringify({ "update" : { "_index" : index, "_type" : index_type, "_id" : doc_id_data[r][0], "_retry_on_conflict" : 3 } }));
bulkList.push(JSON.stringify({ doc: toInsert, detect_noop: true, doc_as_upsert: true }));
} else {
bulkList.push(JSON.stringify({ "index" : { "_index" : index, "_type" : index_type } }));
bulkList.push(JSON.stringify(toInsert));
}
did_send_some_data = true;
// Don't hit the UrlFetchApp limits of 10MB for POST calls.
if(bulkList.length >= 2000) {
postDataToES(host,bulkList.join("\n")+"\n");
bulkList = [];
}
}
}
if(bulkList.length > 0) {
postDataToES(host,bulkList.join("\n")+"\n");
did_send_some_data = true;
}
if(!did_send_some_data) {
throw "No data was sent to the cluster. Make sure your document key name and value ranges are valid.";
}
return [(host.use_ssl) ? 'https://' : 'http://', host.host,':',host.port,'/',index,'/',index_type,'/_search'].join('');
}
/**
* Creates a index template if required. If template already exists, it
* does not update. If not, it uses default_template and the template name
* to create a new one.
*
* @param {Object} host The set of parameters needed to connect to a cluster - required.
* @param {String} index The index name - required.
* @param {String} template_name The name of the index template to use - required.
*/
function createTemplate(host,index,template_name) {
Logger.log(typeof host.use_ssl);
var url = [(host.use_ssl) ? 'https://' : 'http://',
host.host,':',host.port,
'/_template/',template_name].join('')
Logger.log(url);
var options = getDefaultOptions(host.username,host.password);
options['muteHttpExceptions'] = true;
var resp = null
try {
var resp = UrlFetchApp.fetch(url, options);
} catch(e) {
throw "There was an issue creating the template. Please check the names of the template or index and try again."
}
if(resp.getResponseCode() == 404) {
options = getDefaultOptions(host.username,host.password);
options.method = 'POST';
default_template.template = index;
options['payload'] = JSON.stringify(default_template);
options.headers["Content-Type"] = "application/json";
options['muteHttpExceptions'] = true;
resp = null;
try {
resp = UrlFetchApp.fetch(url, options);
} catch(e) {
throw "There was an issue creating the template. Please check the names of the template or index and try again."
}
if(resp.getResponseCode() != 200) {
var jsonData = JSON.parse(resp.getContentText());
throw jsonData.message;
}
} else if(resp.getResponseCode() == 200) {
var jsonResp = JSON.parse(resp.getContentText());
if(jsonResp[template_name].template) {
var re = new RegExp(jsonResp[template_name].template);
if(!re.test(index)) {
throw "The template specified will only be applied to indices matching the following naming pattern: '"+jsonResp[template_name].template+
"' Please update the template or choose a new name.";
}
}
}
}
/**
* Posts data to the ES cluster using the /_bulk endpoint
*
* @param {Object} host The set of parameters needed to connect to a cluster - required.
* @param {Array} data The data to push in an array of JSON strings - required.
*/
function postDataToES(host,data) {
var url = [(host.use_ssl) ? 'https://' : 'http://',
host.host,':',host.port,'/_bulk'].join('');
var options = getDefaultOptions(host.username,host.password);
options.method = 'POST';
options['payload'] = data;
options.headers["Content-Type"] = "application/json";
options['muteHttpExceptions'] = true;
var resp = null;
try {
resp = UrlFetchApp.fetch(url, options);
} catch(e) {
throw "There was an error sending data to the cluster. Please check your connection details and data."
}
if(resp.getResponseCode() != 200) {
var jsonData = JSON.parse(resp.getContentText());
if(jsonData.error) {
if(jsonData.error.indexOf('AuthenticationException')>=0) {
throw "The username and/or password is incorrect."
}
throw jsonData.error;
}
throw "Your cluster returned an unknown error. Please check your connection details and data."
}
}
/**
* Helper function to get the default UrlFetchApp parameters
*
* @param {String} username The username for basic auth.
* @param {String} password The password for basic auth.
*/
function getDefaultOptions(username,password) {
var options = {
method : 'GET',
headers : { },
}
if(username) {
options.headers["Authorization"] = "Basic " + Utilities.base64Encode(username + ":" + password);
}
return options;
}
/**
* Helper function to validate the host object
*
* @param {Object} host The set of parameters needed to connect to a cluster - required.
*/
function isValidHost(host) {
if(!host) {
throw 'Cluster details cannot be empty.';
}
if(!host.host || !host.port) {
throw 'Please enter your cluster host and port.';
}
if(host.host == 'localhost' || host.host == '0.0.0.0') {
throw 'Your cluster must be externally accessible to use this tool.';
}
}
/**
* This is the default template to use. The template ke will
* be relaced with the index name if required.
*
*/
var default_template = {
"order": 0,
"template": "", // will be replaced with index name
"settings": {
"index.refresh_interval": "5s",
"index.analysis.analyzer.default.type": "standard",
"index.number_of_replicas": "1",
"index.number_of_shards": "1",
"index.analysis.analyzer.default.stopwords": "_none_"
},
"mappings": {
"_default_": {
"dynamic_templates": [
{
"string_fields": {
"mapping": {
"fields": {
"{name}": {
"index": "analyzed",
"omit_norms": true,
"type": "string"
},
"raw": {
"search_analyzer": "keyword",
"ignore_above": 256,
"index": "not_analyzed",
"type": "string"
}
},
"type": "multi_field"
},
"match_mapping_type": "string",
"match": "*"
}
}
],
"_all": { "enabled": true }
}
},
"aliases": {}
};