├── 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 |  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('Failed to import:
'); 101 | for (var i = 0; i < questionsError.length; i++) { 102 | htmlOutput.append('