├── LICENSE.md ├── README.md ├── images └── metabase-add-on.gif └── index.gs /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2020 Scott Chacon and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Metabase Google Sheets Add-on 2 | 3 | This Google Sheets add-on imports the result of a Metabase question to a Google Sheet by using the [Metabase API](https://github.com/metabase/metabase/blob/master/docs/api-documentation.md). 4 | 5 | It has two main functions that an user can access: 6 | 7 | - `importQuestion`: import a single question from Metabase using it's question id number (you can find this at the end of a question URL) into the current Google Sheet tab 8 | - `importAllQuestions`: imports all questions in a Google Sheet using the following name convention: `(metabase/123)` -> imports question number 123 9 | 10 | ## What Users See 11 | 12 | ![This gif shows what users see in the add-on interface](images/metabase-add-on.gif) 13 | 14 | ## Metabase API Access 15 | 16 | Currently, the add-on authenticates with Metabase by using a user account. You can use your own user account or create a dedicated one for the add-on. It sends the username and password and [gets a token](https://github.com/metabase/metabase/wiki/Using-the-REST-API#authorizing) in response that it uses to make requests. If the token is expired (after a certain period of time it will expire) then the script requests a new token. 17 | 18 | When deploying for the first time, remember to set following ENV vars in your Google Script Project file, by going to File -> Project Properties: 19 | - `BASE_URL` (the url to your metabase instance with a trailing slash, e.g. `https://my-company.metabase.com/`) 20 | - `USERNAME` (a Metabase username) 21 | - `PASSWORD` (a Metabase user password) 22 | - `TOKEN` (do not set it; it will be set automatically) 23 | 24 | ## Publishing the Add-on 25 | 26 | Publishing a Google Apps Add-on is a tricky process. For help on how to publish a Google Sheets Add-on: https://developers.google.com/gsuite/add-ons/how-tos/publishing-editor-addons. 27 | 28 | Otherwise, you can just use the code as a simple Google Apps Script. Remember, to access the Project Properties, you might have to [switch to the old (legacy) Google Apps Script editor](https://stackoverflow.com/questions/65342439/upgrade-to-apps-script-new-ide-after-downgrading). 29 | -------------------------------------------------------------------------------- /images/metabase-add-on.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bplmp/metabase-google-sheets-add-on/51ca613574cd55e990f856a48715c30c39dd45b5/images/metabase-add-on.gif -------------------------------------------------------------------------------- /index.gs: -------------------------------------------------------------------------------- 1 | function onInstall() { 2 | onOpen(); 3 | } 4 | 5 | function onOpen() { 6 | var ui = SpreadsheetApp.getUi(); 7 | ui.createMenu('Metabase') 8 | .addItem('Import Question', 'importQuestion') 9 | .addItem('Import All Questions in Sheets', 'importAllQuestions') 10 | .addToUi(); 11 | } 12 | 13 | function importQuestion() { 14 | var metabaseQuestionNum = Browser.inputBox('Metabase question number (This will replace all data in the current tab with the result)', Browser.Buttons.OK_CANCEL); 15 | if (metabaseQuestionNum != 'cancel' && !isNaN(metabaseQuestionNum)) { 16 | var status = getQuestionAsCSV(metabaseQuestionNum, false); 17 | 18 | var log = { 19 | 'user': Session.getActiveUser().getEmail(), 20 | 'function': 'importQuestion', 21 | 'questionNumber': metabaseQuestionNum, 22 | 'status': status 23 | }; 24 | if (log.status === true) { 25 | console.log(log); 26 | } else { 27 | console.error(log); 28 | } 29 | 30 | if (status.success === true) { 31 | SpreadsheetApp.getUi().alert('Question ' + metabaseQuestionNum + ' successfully imported.'); 32 | } else { 33 | SpreadsheetApp.getUi().alert('Question ' + metabaseQuestionNum + ' failed to import. ' + status.error); 34 | } 35 | } else if (metabaseQuestionNum == 'cancel') { 36 | SpreadsheetApp.getUi().alert('You have canceled.'); 37 | } else { 38 | SpreadsheetApp.getUi().alert('You did not enter a number.'); 39 | } 40 | } 41 | 42 | function importAllQuestions() { 43 | var ui = SpreadsheetApp.getUi(); 44 | 45 | var result = ui.alert( 46 | 'Please confirm', 47 | 'This will import all data for sheets containing "(metabase/QUESTION_NUMBER)". After the slash should be a Metabase question number (example: "(metabase/152)" will import question 152).', 48 | ui.ButtonSet.YES_NO); 49 | 50 | if (result == ui.Button.YES) { 51 | var questions = getSheetNumbers(); 52 | for (var i = 0; i < questions.length; i++) { 53 | questions[i].done = false; 54 | } 55 | 56 | if (questions.length === 0) { 57 | ui.alert("Couldn't find any question numbers to import."); 58 | return; 59 | } 60 | 61 | var questionNumbers = []; 62 | for (var i = 0; i < questions.length; i++) { 63 | questionNumbers.push(questions[i].questionNumber); 64 | } 65 | 66 | var go = ui.alert( 67 | 'Please confirm', 68 | 'This will import ' + questions.length + ' question(s): ' + questionNumbers.join(', ') + '. Continue?', 69 | ui.ButtonSet.YES_NO); 70 | 71 | if (go == ui.Button.YES) { 72 | var startDate = new Date().toLocaleTimeString(); 73 | var htmlOutput = HtmlService.createHtmlOutput('

Started running at ' + startDate + '...

'); 74 | ui.showModalDialog(htmlOutput, 'Importing questions'); 75 | var questionsSuccess = []; 76 | var questionsError = []; 77 | for (var i = 0; i < questions.length; i++) { 78 | var questionNumber = questions[i].questionNumber; 79 | var sheetName = questions[i].sheetName; 80 | var status = getQuestionAsCSV(questionNumber, sheetName); 81 | if (status.success === true) { 82 | questionsSuccess.push(questionNumber); 83 | } else if (status.success === false) { 84 | questionsError.push({ 85 | 'number': questionNumber, 86 | 'errorMessage': status.error 87 | }); 88 | } 89 | } 90 | 91 | var endDate = new Date().toLocaleTimeString(); 92 | htmlOutput.append('

Finished at ' + endDate + '.

'); 93 | if (questionsSuccess.length > 0) { 94 | htmlOutput.append('

Successfully imported:

'); 95 | for (var i = 0; i < questionsSuccess.length; i++) { 96 | htmlOutput.append('
  • ' + questionsSuccess[i] + '
  • '); 97 | } 98 | } 99 | if (questionsError.length > 0) { 100 | htmlOutput.append('

    Failed to import:

    '); 101 | for (var i = 0; i < questionsError.length; i++) { 102 | htmlOutput.append('
  • ' + questionsError[i].number + '
    (' + questionsError[i].errorMessage + ')
  • '); 103 | } 104 | } 105 | ui.showModalDialog(htmlOutput, 'Importing questions'); 106 | 107 | var finalStatus; 108 | if (questionsError.length === 0) { 109 | finalStatus = true; 110 | } else { 111 | finalStatus = false; 112 | } 113 | var log = { 114 | 'user': Session.getActiveUser().getEmail(), 115 | 'function': 'importAllQuestions', 116 | 'questionNumber': questionNumbers, 117 | 'status': { 118 | 'success': finalStatus, 119 | 'questionsSuccess': questionsSuccess, 120 | 'questionsError': questionsError 121 | } 122 | }; 123 | if (log.status === true) { 124 | console.log(log); 125 | } else { 126 | console.error(log); 127 | } 128 | 129 | } else { 130 | ui.alert('You have canceled.'); 131 | } 132 | } else { 133 | ui.alert('You have canceled.'); 134 | } 135 | } 136 | 137 | function getSheetNumbers() { 138 | var ss = SpreadsheetApp.getActiveSpreadsheet(); 139 | var sheets = ss.getSheets(); 140 | var questionNumbers = []; 141 | for (var i in sheets) { 142 | var sheetName = sheets[i].getName(); 143 | if (sheetName.indexOf('(metabase/') > -1) { 144 | var questionMatch = sheetName.match('\(metabase\/[0-9]+\)'); 145 | if (questionMatch !== null) { 146 | var questionNumber = questionMatch[0].match('[0-9]+')[0]; 147 | if (!isNaN(questionNumber) && questionNumber !== '') { 148 | questionNumbers.push({ 149 | 'questionNumber': questionNumber, 150 | 'sheetName': sheetName 151 | }); 152 | } 153 | } 154 | } 155 | } 156 | return questionNumbers; 157 | } 158 | 159 | function getToken(baseUrl, username, password) { 160 | var sessionUrl = baseUrl + "api/session"; 161 | var options = { 162 | "method": "post", 163 | "headers": { 164 | "Content-Type": "application/json" 165 | }, 166 | "payload": JSON.stringify({ 167 | username: username, 168 | password: password 169 | }) 170 | }; 171 | var response; 172 | try { 173 | response = UrlFetchApp.fetch(sessionUrl, options); 174 | } catch (e) { 175 | throw (e); 176 | } 177 | var token = JSON.parse(response).id; 178 | return token; 179 | } 180 | 181 | function getQuestionAndFillSheet(baseUrl, token, metabaseQuestionNum, sheetName) { 182 | var questionUrl = baseUrl + "api/card/" + metabaseQuestionNum + "/query/csv"; 183 | 184 | var options = { 185 | "method": "post", 186 | "headers": { 187 | "Content-Type": "application/json", 188 | "X-Metabase-Session": token 189 | }, 190 | "muteHttpExceptions": true 191 | }; 192 | 193 | var response; 194 | try { 195 | response = UrlFetchApp.fetch(questionUrl, options); 196 | } catch (e) { 197 | return { 198 | 'success': false, 199 | 'error': e 200 | }; 201 | } 202 | var statusCode = response.getResponseCode(); 203 | 204 | if (statusCode == 200 || statusCode == 202) { 205 | var values = Utilities.parseCsv(response.getContentText()); 206 | try { 207 | fillSheet(values, sheetName); 208 | return { 209 | 'success': true 210 | }; 211 | } catch (e) { 212 | return { 213 | 'success': false, 214 | 'error': e 215 | }; 216 | } 217 | } else if (statusCode == 401) { 218 | var scriptProp = PropertiesService.getScriptProperties(); 219 | var username = scriptProp.getProperty('USERNAME'); 220 | var password = scriptProp.getProperty('PASSWORD'); 221 | 222 | var token = getToken(baseUrl, username, password); 223 | scriptProp.setProperty('TOKEN', token); 224 | var e = "Error: Could not retrieve question. Metabase says: '" + response.getContentText() + "'. Please try again in a few minutes."; 225 | return { 226 | 'success': false, 227 | 'error': e 228 | }; 229 | } else { 230 | var e = "Error: Could not retrieve question. Metabase says: '" + response.getContentText() + "'. Please try again later."; 231 | return { 232 | 'success': false, 233 | 'error': e 234 | }; 235 | } 236 | } 237 | 238 | function fillSheet(values, sheetName) { 239 | var colLetters = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "AM", "AN", "AO", "AP", "AQ", "AR", "AS", "AT", "AU", "AV", "AW", "AX", "AY", "AZ", "BA", "BB", "BC", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BK", "BL", "BM", "BN", "BO", "BP", "BQ", "BR", "BS", "BT", "BU", "BV", "BW", "BX", "BY", "BZ", "CA", "CB", "CC", "CD", "CE", "CF", "CG", "CH", "CI", "CJ", "CK", "CL", "CM", "CN", "CO", "CP", "CQ", "CR", "CS", "CT", "CU", "CV", "CW", "CX", "CY", "CZ", "DA", "DB", "DC", "DD", "DE", "DF", "DG", "DH", "DI", "DJ", "DK", "DL", "DM", "DN", "DO", "DP", "DQ", "DR", "DS", "DT", "DU", "DV", "DW", "DX", "DY", "DZ", "EA", "EB", "EC", "ED", "EE", "EF", "EG", "EH", "EI", "EJ", "EK", "EL", "EM", "EN", "EO", "EP", "EQ", "ER", "ES", "ET", "EU", "EV", "EW", "EX", "EY", "EZ", "FA", "FB", "FC", "FD", "FE", "FF", "FG", "FH", "FI", "FJ", "FK", "FL", "FM", "FN", "FO", "FP", "FQ", "FR", "FS", "FT", "FU", "FV", "FW", "FX", "FY", "FZ", "GA", "GB", "GC", "GD", "GE", "GF", "GG", "GH", "GI", "GJ", "GK", "GL", "GM", "GN", "GO", "GP", "GQ", "GR", "GS", "GT", "GU", "GV", "GW", "GX", "GY", "GZ", "HA", "HB", "HC", "HD", "HE", "HF", "HG", "HH", "HI", "HJ", "HK", "HL", "HM", "HN", "HO", "HP", "HQ", "HR", "HS", "HT", "HU", "HV", "HW", "HX", "HY", "HZ", "IA", "IB", "IC", "ID", "IE", "IF", "IG", "IH", "II", "IJ", "IK", "IL", "IM", "IN", "IO", "IP", "IQ", "IR", "IS", "IT", "IU", "IV", "IW", "IX", "IY", "IZ", "JA", "JB", "JC", "JD", "JE", "JF", "JG", "JH", "JI", "JJ", "JK", "JL", "JM", "JN", "JO", "JP", "JQ", "JR", "JS", "JT", "JU", "JV", "JW", "JX", "JY", "JZ"]; 240 | 241 | var sheet; 242 | if (sheetName == false) { 243 | sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); 244 | } else { 245 | sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName); 246 | } 247 | 248 | sheet.clear({ 249 | contentsOnly: true 250 | }); 251 | 252 | var rows = values; 253 | var header = rows[0]; 254 | var minCol = colLetters[0]; 255 | var maxCol = colLetters[header.length - 1]; 256 | var minRow = 1; 257 | var maxRow = rows.length; 258 | var range = sheet.getRange(minCol + minRow + ":" + maxCol + maxRow); 259 | range.setValues(rows); 260 | } 261 | 262 | function getQuestionAsCSV(metabaseQuestionNum, sheetName) { 263 | var scriptProp = PropertiesService.getScriptProperties(); 264 | var baseUrl = scriptProp.getProperty('BASE_URL'); 265 | var username = scriptProp.getProperty('USERNAME'); 266 | var password = scriptProp.getProperty('PASSWORD'); 267 | var token = scriptProp.getProperty('TOKEN'); 268 | 269 | if (!token) { 270 | token = getToken(baseUrl, username, password); 271 | scriptProp.setProperty('TOKEN', token); 272 | } 273 | 274 | status = getQuestionAndFillSheet(baseUrl, token, metabaseQuestionNum, sheetName); 275 | return status; 276 | } 277 | --------------------------------------------------------------------------------