├── .jshintrc ├── .npmignore ├── index.js ├── .gitignore ├── example ├── cred-loader.js ├── edit-basic.js ├── edit-basic-async-await.js ├── edit-autosize.js ├── read-basic-async-await.js ├── edit-metadata.js ├── read-basic.js ├── read-metadata.js └── edit-cell-names.js ├── package.json ├── lib ├── auth.js ├── metadata.js ├── util.js └── spreadsheet.js ├── get_oauth2_permissions.js ├── test └── spreadsheet.js └── README.md /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node" : true 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/ 3 | example/ -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/spreadsheet'); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | example/creds.json 3 | example/oauth2-creds.json 4 | browseroauth2/ -------------------------------------------------------------------------------- /example/cred-loader.js: -------------------------------------------------------------------------------- 1 | //load example credential file 2 | //in production, use enviroment variables instead 3 | try { 4 | module.exports = require("./oauth2-creds.json"); 5 | } catch (err) { 6 | console.log( 7 | 'Please make a "oauth2-creds.json" file in this folder to use these examples.' 8 | ); 9 | console.log( 10 | '{ "client_id": "...", "client_secret": "...", "refresh_token": "..." }' 11 | ); 12 | console.log("To retrieve this token, see ../get_oauth2_permissions.js"); 13 | process.exit(1); 14 | } 15 | -------------------------------------------------------------------------------- /example/edit-basic.js: -------------------------------------------------------------------------------- 1 | var Spreadsheet = require("../"); 2 | 3 | Spreadsheet.load( 4 | { 5 | debug: true, 6 | oauth2: require("./cred-loader"), 7 | spreadsheetName: "edit-spreadsheet-example", 8 | worksheetName: "Sheet1" 9 | }, 10 | function run(err, spreadsheet) { 11 | if (err) throw err; 12 | //insert 'hello!' at E3 13 | spreadsheet.add({3: {5: "hello!"}}); 14 | 15 | spreadsheet.send(function(err) { 16 | if (err) throw err; 17 | console.log("Updated Cell at row 3, column 5 to 'hello!'"); 18 | }); 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /example/edit-basic-async-await.js: -------------------------------------------------------------------------------- 1 | const Spreadsheet = require("../"); 2 | const creds = require("./cred-loader"); 3 | 4 | (async () => { 5 | try { 6 | let spreadsheet = await Spreadsheet.load({ 7 | debug: true, 8 | oauth2: creds, 9 | spreadsheetName: "node-spreadsheet-example", 10 | worksheetName: "Sheet1" 11 | }); 12 | //insert 'Zip zop' at E3 13 | spreadsheet.add({3: {5: "Zip zop"}}); 14 | await spreadsheet.send(); 15 | console.log("Updated Cell E3"); 16 | } catch (err) { 17 | console.error("EROR", err); 18 | } 19 | })(); 20 | -------------------------------------------------------------------------------- /example/edit-autosize.js: -------------------------------------------------------------------------------- 1 | var Spreadsheet = require("../"); 2 | 3 | Spreadsheet.load( 4 | { 5 | debug: true, 6 | oauth2: require("./cred-loader"), 7 | spreadsheetName: "edit-spreadsheet-example", 8 | worksheetName: "Sheet1" 9 | }, 10 | function run(err, spreadsheet) { 11 | if (err) throw err; 12 | spreadsheet.add({300: {50: "hello!"}}); 13 | spreadsheet.send({autoSize: true}, function(err) { 14 | if (err) throw err; 15 | console.log( 16 | "Resized then updated Cell at row 300, column 50 to 'hello!'" 17 | ); 18 | }); 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /example/read-basic-async-await.js: -------------------------------------------------------------------------------- 1 | const Spreadsheet = require("../"); 2 | const creds = require("./cred-loader"); 3 | 4 | (async () => { 5 | try { 6 | let spreadsheet = await Spreadsheet.load({ 7 | debug: true, 8 | oauth2: creds, 9 | spreadsheetName: "node-spreadsheet-example", 10 | worksheetName: "Sheet1" 11 | }); 12 | //receive all cells 13 | let [rows, info] = await spreadsheet.receive({getValues: false}); 14 | console.log("Found rows:", rows); 15 | console.log("With info:", info); 16 | } catch (err) { 17 | console.error("EROR", err); 18 | } 19 | })(); 20 | -------------------------------------------------------------------------------- /example/edit-metadata.js: -------------------------------------------------------------------------------- 1 | var Spreadsheet = require("../"); 2 | var util = require("util"); 3 | 4 | Spreadsheet.load( 5 | { 6 | debug: true, 7 | oauth2: require("./cred-loader"), 8 | spreadsheetName: "edit-spreadsheet-example", 9 | worksheetName: "Sheet1" 10 | }, 11 | function run(err, spreadsheet) { 12 | if (err) throw err; 13 | spreadsheet.metadata( 14 | { 15 | title: "Sheet1", 16 | rowCount: 5, 17 | colCount: 5 18 | }, 19 | function(err, metadata) { 20 | if (err) throw err; 21 | console.log(metadata); 22 | } 23 | ); 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /example/read-basic.js: -------------------------------------------------------------------------------- 1 | var Spreadsheet = require("../"); 2 | var creds = require("./cred-loader"); 3 | 4 | Spreadsheet.load( 5 | { 6 | debug: true, 7 | oauth2: require("./cred-loader"), 8 | spreadsheetName: "node-edit-spreadsheet", 9 | worksheetName: "Sheet1" 10 | }, 11 | function run(err, spreadsheet) { 12 | if (err) return console.log(err); 13 | //receive all cells 14 | spreadsheet.receive({getValues: false}, function(err, rows, info) { 15 | if (err) return console.log(err); 16 | console.log("Found rows:", rows); 17 | console.log("With info:", info); 18 | }); 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /example/read-metadata.js: -------------------------------------------------------------------------------- 1 | var Spreadsheet = require("../"); 2 | var creds = require("./cred-loader"); 3 | var util = require("util"); 4 | 5 | Spreadsheet.load( 6 | { 7 | debug: true, 8 | oauth2: require("./cred-loader"), 9 | spreadsheetName: "edit-spreadsheet-example", 10 | worksheetName: "Sheet1" 11 | }, 12 | function run(err, spreadsheet) { 13 | if (err) throw err; 14 | spreadsheet.metadata(function(err, metadata) { 15 | if (err) throw err; 16 | console.log(metadata); 17 | // { title: 'Sheet1', rowCount: '100', colCount: '20', 18 | // updated: Sun Jul 28 2013 12:07:31 GMT+1000 (EST) } 19 | }); 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edit-google-spreadsheet", 3 | "version": "0.3.0", 4 | "scripts": { 5 | "test": "mocha test --recursive" 6 | }, 7 | "dependencies": { 8 | "google-auth-library": "^0.9.6", 9 | "async": "^2.5.0", 10 | "colors": "^1.1.2", 11 | "google-oauth-jwt": "^0.2.0", 12 | "lodash": "^4.17.4", 13 | "request": "^2.82.0", 14 | "xml2js": "^0.4.19" 15 | }, 16 | "repository": "git://github.com/jpillora/node-edit-google-spreadsheet.git", 17 | "devDependencies": { 18 | "chai": "^3.3.0", 19 | "mocha": "^2.3.3", 20 | "mockery": "^1.4.0", 21 | "sinon": "^1.17.1", 22 | "sinon-chai": "^2.8.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/edit-cell-names.js: -------------------------------------------------------------------------------- 1 | var Spreadsheet = require("../"); 2 | 3 | Spreadsheet.load( 4 | { 5 | debug: true, 6 | oauth2: require("./cred-loader"), 7 | spreadsheetName: "edit-spreadsheet-example", 8 | worksheetName: "Sheet1" 9 | }, 10 | function run(err, spreadsheet) { 11 | if (err) throw err; 12 | spreadsheet.add({ 13 | 3: { 14 | 4: {name: "a", val: 42}, //'42' though tagged as "a" 15 | 5: {name: "b", val: 21}, //'21' though tagged as "b" 16 | 6: "={{ a }}+{{ b }}" //forumla adding row3,col4 with row3,col5 => '=D3+E3' 17 | } 18 | }); 19 | spreadsheet.send(function(err) { 20 | if (err) throw err; 21 | console.log("Cells updated"); 22 | }); 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /lib/auth.js: -------------------------------------------------------------------------------- 1 | var GoogleOAuthJWT = require("google-oauth-jwt"); 2 | var GoogleAuth = require("google-auth-library"); 3 | var googleAuth = new GoogleAuth(); 4 | 5 | //client auth helper 6 | module.exports = function(opts, callback) { 7 | if (opts.username && opts.password) callback("Client login not supported"); 8 | else if (opts.oauth) oAuth1Login(opts.oauth, opts.useHTTPS, callback); 9 | else if (opts.oauth2) oAuth2Login(opts.oauth2, callback); 10 | else if (opts.accessToken) accessTokenLogin(opts.accessToken, callback); 11 | }; 12 | 13 | function oAuth1Login(oauth, useHTTPS, callback) { 14 | if (!oauth.scopes) { 15 | oauth.scopes = ["http" + useHTTPS + "://spreadsheets.google.com/feeds"]; 16 | } 17 | GoogleOAuthJWT.authenticate(oauth, function(err, token) { 18 | if (err) callback("Google OAuth Error: " + err); 19 | else callback(null, {type: "Bearer", token: token}); 20 | }); 21 | } 22 | 23 | function oAuth2Login(oauth2, callback) { 24 | var oAuth2Client = new googleAuth.OAuth2( 25 | oauth2.client_id, 26 | oauth2.client_secret, 27 | "urn:ietf:wg:oauth:2.0:oob" 28 | ); 29 | 30 | oAuth2Client.setCredentials({ 31 | access_token: "DUMMY", 32 | expiry_date: 1, 33 | refresh_token: oauth2.refresh_token, 34 | token_type: "Bearer" 35 | }); 36 | oAuth2Client.getAccessToken(function(err, token) { 37 | if (err) callback("Google OAuth2 Error: " + err); 38 | else callback(null, {type: "Bearer", token: token}); 39 | }); 40 | } 41 | 42 | function accessTokenLogin(accessToken, callback) { 43 | function gotToken(err, t) { 44 | if (err) return callback(err); 45 | if (!t.type || !t.token) 46 | return callback("Missing token or token type information"); 47 | //got token 48 | callback(null, {type: t.type, token: t.token}); 49 | } 50 | // 51 | if (typeof accessToken === "function") accessToken(gotToken); 52 | else if (typeof accessToken === "object") gotToken(null, accessToken); 53 | else callback("Invalid access token"); 54 | } 55 | -------------------------------------------------------------------------------- /lib/metadata.js: -------------------------------------------------------------------------------- 1 | var request = require("request"); 2 | var _ = require("lodash"); 3 | 4 | var Metadata = module.exports = function(spreadsheet){ 5 | this.spreadsheet = spreadsheet; 6 | }; 7 | 8 | Metadata.prototype.url = function(isId) { 9 | return this.spreadsheet.protocol+'://spreadsheets.google.com/feeds/worksheets/' + 10 | this.spreadsheet.spreadsheetId + '/' + (isId?'':'private/full/') + this.spreadsheet.worksheetId; 11 | }; 12 | 13 | Metadata.prototype.extract = function(result) { 14 | return { 15 | updated: new Date(result.entry.updated), 16 | title: result.entry.title, 17 | rowCount: result.entry['gs:rowCount'], 18 | colCount: result.entry['gs:colCount'] 19 | }; 20 | }; 21 | 22 | Metadata.prototype.get = function(callback) { 23 | var _this = this; 24 | this.spreadsheet.request({ 25 | url: this.url() 26 | }, function(err, result) { 27 | if(err) return callback(err); 28 | callback(null, _this.extract(result)); 29 | }); 30 | }; 31 | 32 | Metadata.prototype.set = function(data, callback) { 33 | var _this = this; 34 | //must retrieve current col/row counts if missing 35 | if(data.colCount === undefined || data.rowCount === undefined) 36 | this.get(function(err, metadata) { 37 | if(err) return callback(err); 38 | _this._set(_.extend(metadata, data), callback); 39 | }); 40 | else 41 | _this._set(data, callback); 42 | }; 43 | 44 | Metadata.prototype._set = function(data, callback) { 45 | var _this = this; 46 | var entry = ''+ 48 | ''+this.url(true)+'' + 49 | ''+data.title+'' + 50 | ''+data.colCount+'' + 51 | ''+data.rowCount+'' + 52 | ''; 53 | 54 | this.spreadsheet.request({ 55 | method: 'PUT', 56 | url: this.url(), 57 | body: entry 58 | }, function(err, result) { 59 | if(err) return callback(err); 60 | callback(null, _this.extract(result)); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 2 | //parse number 3 | exports.num = function(obj) { 4 | if (obj === undefined) return 0; 5 | if (typeof obj === "number" || typeof obj === "boolean") return obj; 6 | if (typeof obj === "string") { 7 | //ensure that the string is *only* a number 8 | if (!/^\-?\d+(\.\d+)?$/.test(obj)) return obj; 9 | var res = parseFloat(obj, 10); 10 | if (isNaN(res)) return obj; 11 | return res; 12 | } 13 | throw "Invalid number: " + JSON.stringify(obj); 14 | }; 15 | 16 | exports.int2cell = function(r, c) { 17 | return String.fromCharCode(64 + c) + r; 18 | }; 19 | 20 | exports.gcell2cell = function(cell, getValue, useTextValues) { 21 | //get formula AND has a formula? 22 | if (!getValue && /^=/.test(cell.inputValue)) { 23 | //must convert '=RC[-2]+R[3]C[-1]' to '=B5+C8' 24 | return cell.inputValue.replace( 25 | /(R(\[(-?\d+)\])?)(C(\[(-?\d+)\])?)/g, 26 | function() { 27 | return exports.int2cell( 28 | exports.num(cell.row) + exports.num(arguments[3] || 0), 29 | exports.num(cell.col) + exports.num(arguments[6] || 0) 30 | ); 31 | } 32 | ); 33 | } 34 | //get value 35 | return useTextValues ? exports.num(cell.$t) : exports.num(cell.inputValue); 36 | }; 37 | 38 | exports.promisify = function(fn) { 39 | return function wrapped() { 40 | //store promise on callback 41 | var resolve, reject; 42 | var promise = new Promise(function(res, rej) { 43 | resolve = res; 44 | reject = rej; 45 | }); 46 | // 47 | var args = Array.from(arguments); 48 | var callback = args[args.length - 1]; 49 | var hasCallback = typeof callback === "function"; 50 | //resolve/reject promise 51 | var fullfilled = function(err, data) { 52 | if (err) { 53 | reject(err); 54 | } else { 55 | var datas = Array.prototype.slice.call(arguments, 1); 56 | if (datas.length >= 2) { 57 | resolve(datas); 58 | } else { 59 | resolve(data); 60 | } 61 | } 62 | if (hasCallback) { 63 | callback.apply(this, arguments); 64 | } 65 | }; 66 | //replace/add callback 67 | if (hasCallback) { 68 | args[args.length - 1] = fullfilled; 69 | } else { 70 | args.push(fullfilled); 71 | } 72 | //call underlying function 73 | var returned = fn.apply(this, args); 74 | if (typeof returned !== "undefined") { 75 | console.log("promisify warning: discarded return value"); 76 | } 77 | //return promise! 78 | return promise; 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /get_oauth2_permissions.js: -------------------------------------------------------------------------------- 1 | // Based on https://raw.githubusercontent.com/google/google-api-nodejs-client/master/examples/oauth2.js 2 | // Adapted by Oystein Steimler 3 | 4 | /** 5 | * Copyright 2012 Google Inc. All Rights Reserved. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | 20 | var readline = require("readline"); 21 | 22 | var googleAuth = require("google-auth-library"); 23 | var OAuth2Client = new googleAuth().OAuth2; 24 | 25 | // Client ID and client secret are available at 26 | // https://code.google.com/apis/console 27 | // 1. Create or pick a project 28 | // 2. Choose "API & Auth" and then "Credentials" 29 | // 3. Click "Create new Client ID" 30 | // 4. Select "Installed application" and "Other" 31 | // 5. Copy your ClientID and Client Secret into the fields beneath 32 | 33 | var CLIENT_ID = ''; 34 | var CLIENT_SECRET = ''; 35 | 36 | // 6. This scope 'https://spreadsheets.google.com/feeds' provides full access to all Spreadsheets in 37 | // your Google Drive. Find more scopes here: https://developers.google.com/drive/web/scopes 38 | // and https://developers.google.com/google-apps/spreadsheets/authorize 39 | var PERMISSION_SCOPE = "https://spreadsheets.google.com/feeds"; //space-delimited string or an array of scopes 40 | 41 | // 6. Run this script: `node get_oauth2_permissions.js' 42 | // 7. Visit the URL printed, authenticate the google user, grant the permission 43 | // 8. Copy the authorization code and paste it at the prompt of this program. 44 | // 9. The refresh_token you get is needed with the client_id and client_secret when using edit-google-spreadsheet 45 | 46 | var oauth2Client = new OAuth2Client( 47 | CLIENT_ID, 48 | CLIENT_SECRET, 49 | "urn:ietf:wg:oauth:2.0:oob" 50 | ); 51 | 52 | var rl = readline.createInterface({ 53 | input: process.stdin, 54 | output: process.stdout 55 | }); 56 | 57 | // generate consent page url 58 | var url = oauth2Client.generateAuthUrl({ 59 | access_type: "offline", 60 | scope: PERMISSION_SCOPE 61 | }); 62 | 63 | console.log("Visit this url:\n%s\n", url); // provides a refresh token 64 | 65 | rl.question("Enter the code here: ", function(code) { 66 | console.log("\nGetting token..."); 67 | oauth2Client.getToken(code, function(err, tokens) { 68 | if (err) return console.log("Error getting token: " + err); 69 | var creds = { 70 | client_id: CLIENT_ID, 71 | client_secret: CLIENT_SECRET, 72 | refresh_token: tokens.refresh_token 73 | }; 74 | console.log( 75 | 'Use this in your Spreadsheet.load():\n"oauth2": %s', 76 | JSON.stringify(creds, true, 2) 77 | ); 78 | process.exit(0); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/spreadsheet.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | var sinon = require('sinon'); 3 | var sinonChai = require('sinon-chai'); 4 | chai.use(sinonChai); 5 | var expect = chai.expect; 6 | var mockery = require('mockery'); 7 | 8 | var mockRequestCbParams = { 9 | err: null, 10 | response: { statusCode: 200 }, 11 | body: {} 12 | }; 13 | var mockRequest = sinon.spy(function(opts, callback) { 14 | callback( 15 | mockRequestCbParams.err, 16 | mockRequestCbParams.response, 17 | mockRequestCbParams.body 18 | ); 19 | }); 20 | 21 | var mockAuth = sinon.spy(function(opts, callback) { 22 | if (mockRequestCbParams.response.statusCode == 401) { 23 | // we're authed now... 24 | mockRequestCbParams.response.statusCode = 200; 25 | } 26 | callback(null, 'mockToken'); 27 | }); 28 | 29 | var opts = { 30 | oauth2: 'oauth2', 31 | spreadsheetId: 'spreadsheetId', 32 | worksheetId: 'worksheetId' 33 | }; 34 | 35 | 36 | var Spreadsheet; 37 | 38 | describe('Spreadsheet', function() { 39 | before(function() { 40 | 41 | mockery.enable({warnOnUnregistered: false}); 42 | 43 | mockery.registerMock('request', mockRequest); 44 | mockery.registerMock('./auth', mockAuth); 45 | 46 | Spreadsheet = require('../lib/spreadsheet.js'); 47 | }); 48 | after(function() { 49 | mockery.deregisterAll(); 50 | mockery.disable(); 51 | }); 52 | 53 | describe('create (alias load)', function() { 54 | it('should call the callback', function() { 55 | var callback = sinon.spy(); 56 | Spreadsheet.create({}, callback); 57 | expect(callback).to.have.been.calledOnce; 58 | }); 59 | it('should call the opts.callback', function() { 60 | var callback = sinon.spy(); 61 | Spreadsheet.create({callback: callback}); 62 | expect(callback).to.have.been.calledOnce; 63 | }); 64 | it('should throw an error if there is no callback', function() { 65 | expect(function() { 66 | Spreadsheet.create({}); 67 | }).to.throw('Missing callback') 68 | }); 69 | it('should return an error if there is no auth mechanism', function() { 70 | var errValue; 71 | var callback = sinon.spy(function(err) {errValue = err;}); 72 | Spreadsheet.create({spreadsheetId: 'spreadsheetId', worksheetId: 'worksheetId'}, callback); 73 | expect(callback).to.have.been.calledOnce; 74 | expect(errValue).to.equal('Missing authentication information'); 75 | }); 76 | it('should return an error if there is no spreadsheet specified', function() { 77 | var errValue; 78 | var callback = sinon.spy(function(err) {errValue = err;}); 79 | Spreadsheet.create({oauth2: 'oauth2', worksheetId: 'worksheetId'}, callback); 80 | expect(callback).to.have.been.calledOnce; 81 | expect(errValue).to.equal("Missing 'spreadsheetId' or 'spreadsheetName'"); 82 | }); 83 | it('should return an error if there is no worksheet specified', function() { 84 | var errValue; 85 | var callback = sinon.spy(function(err) {errValue = err;}); 86 | Spreadsheet.create({oauth2: 'oauth2', spreadsheetId: 'spreadsheetId'}, callback); 87 | expect(callback).to.have.been.calledOnce; 88 | expect(errValue).to.equal("Missing 'worksheetId' or 'worksheetName'"); 89 | }); 90 | describe('when authing with defaults', function() { 91 | var authParams; 92 | before(function(done) { 93 | mockAuth.reset(); 94 | Spreadsheet.create(opts, function() { 95 | authParams = mockAuth.args[0][0]; 96 | done(); 97 | }); 98 | }); 99 | it('should default to use cell text values', function() { 100 | expect(authParams.useCellTextValues).to.be.true; 101 | }); 102 | it('should default to https', function() { 103 | expect(authParams.useHTTPS).to.equal('s'); 104 | }); 105 | it('should pass the specified parameters', function() { 106 | expect(authParams.oauth2).to.equal('oauth2'); 107 | expect(authParams.spreadsheetId).to.equal('spreadsheetId'); 108 | expect(authParams.worksheetId).to.equal('worksheetId'); 109 | }); 110 | }); 111 | it('should be able to override use cell text values', function(done) { 112 | mockAuth.reset(); 113 | var opts = { 114 | oauth2: 'oauth2', 115 | spreadsheetId: 'spreadsheetId', 116 | worksheetId: 'worksheetId', 117 | useCellTextValues: false 118 | }; 119 | Spreadsheet.create(opts, function() { 120 | expect(mockAuth.args[0][0].useCellTextValues).to.be.false; 121 | done(); 122 | }); 123 | }); 124 | it('should be able to override use HTTPS', function(done) { 125 | mockAuth.reset(); 126 | var opts = { 127 | oauth2: 'oauth2', 128 | spreadsheetId: 'spreadsheetId', 129 | worksheetId: 'worksheetId', 130 | useHTTPS: false 131 | }; 132 | Spreadsheet.create(opts, function() { 133 | expect(mockAuth.args[0][0].useHTTPS).to.be.empty; 134 | done(); 135 | }); 136 | }); 137 | it('should pass back an initialized spreadsheet instance', function(done) { 138 | Spreadsheet.create(opts, function(err, spreadsheet) { 139 | expect(err).to.not.exist; 140 | expect(spreadsheet.spreadsheetId).to.equal('spreadsheetId'); 141 | expect(spreadsheet.worksheetId).to.equal('worksheetId'); 142 | expect(spreadsheet.protocol).to.equal('https'); 143 | done(); 144 | }); 145 | }); 146 | }); 147 | 148 | describe('spreadsheet', function() { 149 | var spreadsheet; 150 | 151 | before(function(done) { 152 | Spreadsheet.create(opts, function(err, createdSheet) { 153 | spreadsheet = createdSheet; 154 | done(); 155 | }); 156 | }); 157 | 158 | describe('request', function() { 159 | var opts = { 160 | url: 'https://example.com' 161 | }; 162 | beforeEach(function() { 163 | mockRequestCbParams = { 164 | err: null, 165 | response: { statusCode: 200 }, 166 | body: '', 167 | headers: { 'content-type': 'application/atom+xml; charset=UTF-8; type=feed' } 168 | }; 169 | }); 170 | it('should return an error if no URL is provided', function(done) { 171 | spreadsheet.request({}, function(err, response) { 172 | expect(err).to.equal('Invalid request'); 173 | done(); 174 | }); 175 | }); 176 | it('should return an error if the request fails (ie. timeout)', function(done) { 177 | mockRequestCbParams.err = new Error('ETIMEDOUT'); 178 | spreadsheet.request(opts, function(err, response) { 179 | expect(err).to.be.an.instanceof(Error); 180 | done(); 181 | }); 182 | }); 183 | it('should return an error if the server does not respond', function(done) { 184 | mockRequestCbParams.response = undefined; 185 | spreadsheet.request(opts, function(err, response) { 186 | expect(err).to.equal('no response'); 187 | done(); 188 | }); 189 | }); 190 | it('should return an error if the server returns an error', function(done) { 191 | mockRequestCbParams.response.statusCode = 500; 192 | mockRequestCbParams.body = 'Something broke.'; 193 | spreadsheet.request(opts, function(err, response) { 194 | expect(err).to.equal('Something broke.'); 195 | done(); 196 | }); 197 | }); 198 | it('should reauth if the server returns a 401 (Unauthorized)', function(done) { 199 | mockAuth.reset(); 200 | mockRequestCbParams.response.statusCode = 401; 201 | spreadsheet.request(opts, function(err, response) { 202 | expect(err).to.not.exist; 203 | expect(mockAuth).to.have.been.calledOnce; 204 | done(); 205 | }); 206 | }); 207 | it('should parse text elements', function(done) { 208 | mockRequestCbParams.body = 'Income'; 209 | spreadsheet.request(opts, function(err, response) { 210 | expect(response.title).to.equal('Income'); 211 | done(); 212 | }); 213 | }); 214 | it('should coerce numeric integer elements', function(done) { 215 | mockRequestCbParams.body = '45'; 216 | spreadsheet.request(opts, function(err, response) { 217 | expect(response['gs:rowCount']).to.equal(45); 218 | done(); 219 | }); 220 | }); 221 | it('should coerce numeric floating elements', function(done) { 222 | mockRequestCbParams.body = "1.20"; 223 | spreadsheet.request(opts, function(err, response) { 224 | expect(response['gs:cell'].$t).to.equal(1.2); 225 | done(); 226 | }); 227 | }); 228 | it('should parse text attributes', function(done) { 229 | mockRequestCbParams.body = ""; 230 | spreadsheet.request(opts, function(err, response) { 231 | expect(response['batch:status'].reason).to.equal('Success'); 232 | done(); 233 | }); 234 | }); 235 | it('should coerce numeric integer attributes', function(done) { 236 | mockRequestCbParams.body = ""; 237 | spreadsheet.request(opts, function(err, response) { 238 | expect(response['batch:status'].code).to.equal(200); 239 | expect(response['batch:status'].code).to.be.a('number'); 240 | done(); 241 | }); 242 | }); 243 | it('should coerce numeric float attributes', function(done) { 244 | mockRequestCbParams.body = "1.20"; 245 | spreadsheet.request(opts, function(err, response) { 246 | expect(response['gs:cell'].numericValue).to.equal(1.2); 247 | expect(response['gs:cell'].numericValue).to.be.a('number'); 248 | done(); 249 | }); 250 | }); 251 | }); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | :warning: This module has been deprecated. 4 | 5 | Instead of upgrading this old Google Spreadsheets module to support the Google Sheets v4 API, I've chosen to deprecate it, and provide a guide on how to use the `googleapis` module directly. The `googleapis` module, along with the Sheets v4 API provides: 6 | 7 | - Faster responses 8 | - More features 9 | - Uses JSON instead of XML 10 | - `async`/`await` support 11 | 12 | Please see this new repository for more information 13 | 14 | ### https://github.com/jpillora/node-google-sheets 15 | 16 | --- 17 | 18 | ## Edit Google Spreadsheet 19 | 20 | > A simple API for reading and writing Google Spreadsheets in Node.js 21 | 22 | This module aims to be a complete wrapper around the [Google Sheets API version 3.0](https://developers.google.com/google-apps/spreadsheets/). If anything is missing, create an issue, or even better, a pull request. 23 | 24 | [![NPM version](https://nodei.co/npm/edit-google-spreadsheet.png?compact=true)](https://npmjs.org/package/edit-google-spreadsheet) 25 | 26 | [![Dependency Status](https://img.shields.io/david/jpillora/node-edit-google-spreadsheet.svg?style=flat-square)](https://david-dm.org/jpillora/node-edit-google-spreadsheet) 27 | 28 | :warning: Google has finally deprecated ClientLogin, which means you can no longer authenticate with your email and password. See https://github.com/jpillora/node-edit-google-spreadsheet/issues/72 for updates. 29 | 30 | #### Install 31 | 32 | ``` 33 | npm install edit-google-spreadsheet 34 | ``` 35 | 36 | #### Basic Usage 37 | 38 | Load a spreadsheet: 39 | 40 | ```js 41 | var Spreadsheet = require("edit-google-spreadsheet"); 42 | 43 | Spreadsheet.load( 44 | { 45 | debug: true, 46 | spreadsheetName: "edit-spreadsheet-example", 47 | worksheetName: "Sheet1", 48 | 49 | // Choose from 1 of the 5 authentication methods: 50 | 51 | // 1. Username and Password has been deprecated. OAuth2 is recommended. 52 | 53 | // OR 2. OAuth 54 | oauth: { 55 | email: "my-name@google.email.com", 56 | keyFile: "my-private-key.pem" 57 | }, 58 | 59 | // OR 3. OAuth2 (See get_oauth2_permissions.js) 60 | oauth2: { 61 | client_id: "generated-id.apps.googleusercontent.com", 62 | client_secret: "generated-secret", 63 | refresh_token: "token generated with get_oauth2_permission.js" 64 | }, 65 | 66 | // OR 4. Static Token 67 | accessToken: { 68 | type: "Bearer", 69 | token: "my-generated-token" 70 | }, 71 | 72 | // OR 5. Dynamic Token 73 | accessToken: function(callback) { 74 | //... async stuff ... 75 | callback(null, token); 76 | } 77 | }, 78 | function sheetReady(err, spreadsheet) { 79 | //use speadsheet! 80 | } 81 | ); 82 | ``` 83 | 84 | _Note: Using the options `spreadsheetName` and `worksheetName` will cause lookups for `spreadsheetId` and `worksheetId`. Use `spreadsheetId` and `worksheetId` for improved performance._ 85 | 86 | Update sheet: 87 | 88 | ```js 89 | function sheetReady(err, spreadsheet) { 90 | if (err) throw err; 91 | 92 | spreadsheet.add({ 3: { 5: "hello!" } }); 93 | 94 | spreadsheet.send(function(err) { 95 | if (err) throw err; 96 | console.log("Updated Cell at row 3, column 5 to 'hello!'"); 97 | }); 98 | } 99 | ``` 100 | 101 | Read sheet: 102 | 103 | ```js 104 | function sheetReady(err, spreadsheet) { 105 | if (err) throw err; 106 | 107 | spreadsheet.receive(function(err, rows, info) { 108 | if (err) throw err; 109 | console.log("Found rows:", rows); 110 | // Found rows: { '3': { '5': 'hello!' } } 111 | }); 112 | } 113 | ``` 114 | 115 | #### `async` / `await` Usage 116 | 117 | All functions which have a `callback` return a `Promise` tied 118 | to that callback and can therefore be used with `async` / `await`. 119 | 120 | ```js 121 | 122 | const Spreadsheet = require("../"); 123 | 124 | (async function example() { 125 | let spreadsheet = await Spreadsheet.load({ 126 | debug: true, 127 | oauth2: ..., 128 | spreadsheetName: "node-spreadsheet-example", 129 | worksheetName: "Sheet1" 130 | }); 131 | //receive all cells 132 | let [rows, info] = await spreadsheet.receive({getValues: false}); 133 | console.log("Found rows:", rows); 134 | console.log("With info:", info); 135 | })().catch; 136 | ``` 137 | 138 | #### Metadata 139 | 140 | Get metadata 141 | 142 | ```js 143 | function sheetReady(err, spreadsheet) { 144 | if (err) throw err; 145 | 146 | spreadsheet.metadata(function(err, metadata) { 147 | if (err) throw err; 148 | console.log(metadata); 149 | // { title: 'Sheet3', rowCount: '100', colCount: '20', updated: [Date] } 150 | }); 151 | } 152 | ``` 153 | 154 | Set metadata 155 | 156 | ```js 157 | function sheetReady(err, spreadsheet) { 158 | if(err) throw err; 159 | 160 | spreadsheet.metadata({ 161 | title: 'Sheet2' 162 | rowCount: 100, 163 | colCount: 20 164 | }, function(err, metadata){ 165 | if(err) throw err; 166 | console.log(metadata); 167 | }); 168 | } 169 | ``` 170 | 171 | **_WARNING: all cells outside the range of the new size will be silently deleted_** 172 | 173 | #### More `add` Examples 174 | 175 | Batch edit: 176 | 177 | ```js 178 | spreadsheet.add([[1, 2, 3], [4, 5, 6]]); 179 | ``` 180 | 181 | Batch edit starting from row 5: 182 | 183 | ```js 184 | spreadsheet.add({ 185 | 5: [[1, 2, 3], [4, 5, 6]] 186 | }); 187 | ``` 188 | 189 | Batch edit starting from row 5, column 7: 190 | 191 | ```js 192 | spreadsheet.add({ 193 | 5: { 194 | 7: [[1, 2, 3], [4, 5, 6]] 195 | } 196 | }); 197 | ``` 198 | 199 | Formula building with named cell references: 200 | 201 | ```js 202 | spreadsheet.add({ 203 | 3: { 204 | 4: { name: "a", val: 42 }, //'42' though tagged as "a" 205 | 5: { name: "b", val: 21 }, //'21' though tagged as "b" 206 | 6: "={{ a }}+{{ b }}" //forumla adding row3,col4 with row3,col5 => '=D3+E3' 207 | } 208 | }); 209 | ``` 210 | 211 | _Note: cell `a` and `b` are looked up on `send()`_ 212 | 213 | #### API 214 | 215 | ##### `Spreadsheet.load( options, callback( err, spreadsheet ) )` 216 | 217 | See [Options](https://github.com/jpillora/node-edit-google-spreadsheet#options) below 218 | 219 | ##### spreadsheet.`add( obj | array )` 220 | 221 | Add cells to the batch. See examples. 222 | 223 | ##### spreadsheet.`send( [options,] callback( err ) )` 224 | 225 | Sends off the batch of `add()`ed cells. Clears all cells once complete. 226 | 227 | `options.autoSize` When required, increase the worksheet size (rows and columns) in order to fit the batch - _NOTE: When enabled, this will trigger an extra request on every `send()`_ (default `false`). 228 | 229 | ##### spreadsheet.`receive( [options,] callback( err , rows , info ) )` 230 | 231 | Recieves the entire spreadsheet. The `rows` object is an object in the same format as the cells you `add()`, so `add(rows)` will be valid. The `info` object looks like: 232 | 233 | ``` 234 | { 235 | spreadsheetId: 'ttFmrFPIipJimDQYSFyhwTg', 236 | worksheetId: 'od6', 237 | worksheetTitle: 'Sheet1', 238 | worksheetUpdated: '2013-05-31T11:38:11.116Z', 239 | authors: [ { name: 'jpillora', email: 'dev@jpillora.com' } ], 240 | totalCells: 1, 241 | totalRows: 1, 242 | lastRow: 3 243 | } 244 | ``` 245 | 246 | `options.getValues` Always get the values (results) of forumla cells. 247 | 248 | ##### spreadsheet.`metadata( [data, ] callback )` 249 | 250 | Get and set metadata 251 | 252 | _Note: when setting new metadata, if `rowCount` and/or `colCount` is left out, 253 | an extra request will be made to retrieve the missing data._ 254 | 255 | ##### spreadsheet.`raw` 256 | 257 | The raw data recieved from Google when enumerating the spreedsheet and worksheet lists, _which are triggered when searching for IDs_. In order to see this array of all spreadsheets (`raw.spreadsheets`) the `spreadsheetName` option must be used. Similarly for worksheets (`raw.worksheets`), the `worksheetName` options must be used. 258 | 259 | #### Options 260 | 261 | ##### `callback` 262 | 263 | Function returning the authenticated Spreadsheet instance. 264 | 265 | ##### `debug` 266 | 267 | If `true`, will display colourful console logs outputing current actions. 268 | 269 | ##### `username` `password` 270 | 271 | Google account - _Be careful about committing these to public repos_. 272 | 273 | ##### `oauth` 274 | 275 | OAuth configuration object. See [google-oauth-jwt](https://github.com/extrabacon/google-oauth-jwt#specifying-options). _By default `oauth.scopes` is set to `['https://spreadsheets.google.com/feeds']` (`https` if `useHTTPS`)_ 276 | 277 | ##### `accessToken` 278 | 279 | Reuse a generated access `token` of the given `type`. If you set `accessToken` to an object, reauthentications will not work. Instead use a `function accessToken(callback(err, token)) { ... }` function, to allow token generation when required. 280 | 281 | ##### `spreadsheetName` `spreadsheetId` 282 | 283 | The spreadsheet you wish to edit. Either the Name or Id is required. 284 | 285 | ##### `worksheetName` `worksheetId` 286 | 287 | The worksheet you wish to edit. Either the Name or Id is required. 288 | 289 | ##### `useHTTPS` 290 | 291 | Whether to use `https` when connecting to Google (default: `true`) 292 | 293 | ##### `useCellTextValues` 294 | 295 | Return text values for cells or return values as typed. (default: `true`) 296 | 297 | #### Todo 298 | 299 | - Create New Spreadsheets 300 | - Read specific range of cells 301 | - Option to cache auth token in file 302 | 303 | #### FAQ 304 | 305 | - Q: How do I append rows to my spreadsheet ? 306 | - A: Using the `info` object returned from `receive()`, one could always begin `add()`ing at the `lastRow + 1`, thereby appending to the spreadsheet. 307 | 308 | #### Credits 309 | 310 | Thanks to `googleclientlogin` for easy Google API ClientLogin Tokens 311 | 312 | #### References 313 | 314 | - https://developers.google.com/google-apps/spreadsheets/ 315 | 316 | #### Donate 317 | 318 | BTC 1AxEWoz121JSC3rV8e9MkaN9GAc5Jxvs4 319 | 320 | #### MIT License 321 | 322 | Copyright © 2015 Jaime Pillora <dev@jpillora.com> 323 | 324 | Permission is hereby granted, free of charge, to any person obtaining 325 | a copy of this software and associated documentation files (the 326 | 'Software'), to deal in the Software without restriction, including 327 | without limitation the rights to use, copy, modify, merge, publish, 328 | distribute, sublicense, and/or sell copies of the Software, and to 329 | permit persons to whom the Software is furnished to do so, subject to 330 | the following conditions: 331 | 332 | The above copyright notice and this permission notice shall be 333 | included in all copies or substantial portions of the Software. 334 | 335 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 336 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 337 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 338 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 339 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 340 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 341 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 342 | -------------------------------------------------------------------------------- /lib/spreadsheet.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | //module for using the google api to get anayltics data in an object 4 | require("colors"); 5 | var request = require("request"); 6 | var _ = require("lodash"); 7 | var auth = require("./auth"); 8 | var util = require("./util"); 9 | var Metadata = require("./metadata"); 10 | var async = require("async"); 11 | var xml2js = require("xml2js"); 12 | 13 | //public api 14 | exports.create = exports.load = util.promisify(function(opts, callback) { 15 | if (!callback) { 16 | callback = opts.callback; 17 | } 18 | if ( 19 | !(opts.username && opts.password) && 20 | !opts.oauth && 21 | !opts.oauth2 && 22 | !opts.accessToken 23 | ) { 24 | return callback("Missing authentication information"); 25 | } else if (!opts.spreadsheetId && !opts.spreadsheetName) { 26 | return callback("Missing 'spreadsheetId' or 'spreadsheetName'"); 27 | } else if (!opts.worksheetId && !opts.worksheetName) { 28 | return callback("Missing 'worksheetId' or 'worksheetName'"); 29 | } 30 | //default to true if useCellTextValues is not set or defined. 31 | opts.useCellTextValues = _.has(opts, "useCellTextValues") 32 | ? opts.useCellTextValues 33 | : true; 34 | 35 | var spreadsheet = new Spreadsheet(opts); 36 | 37 | //default to http's' when undefined 38 | opts.useHTTPS = opts.useHTTPS === false ? "" : "s"; 39 | spreadsheet.protocol += opts.useHTTPS; 40 | 41 | //add to spreadsheet 42 | _.extend( 43 | spreadsheet, 44 | _.pick( 45 | opts, 46 | "spreadsheetId", 47 | "spreadsheetName", 48 | "worksheetId", 49 | "worksheetName", 50 | "debug" 51 | ) 52 | ); 53 | 54 | spreadsheet.log("Logging into Google...".grey); 55 | auth(opts, function(err, token) { 56 | if (err) return callback(err); 57 | spreadsheet.log("Logged into Google".green); 58 | spreadsheet.setToken(token); 59 | spreadsheet.init(callback); 60 | }); 61 | }); 62 | 63 | //spreadsheet class 64 | function Spreadsheet(opts) { 65 | this.opts = opts; 66 | this.raw = {}; 67 | this.protocol = "http"; 68 | this.xmlParser = new xml2js.Parser({ 69 | charkey: "$t", 70 | explicitArray: false, 71 | explicitCharkey: false, 72 | mergeAttrs: true, 73 | valueProcessors: [xml2js.processors.parseNumbers], 74 | attrValueProcessors: [xml2js.processors.parseNumbers] 75 | }); 76 | this.reset(); 77 | } 78 | 79 | Spreadsheet.prototype.init = function(callback) { 80 | var _this = this; 81 | this.getSheetId("spread", function(err) { 82 | if (err) return callback(err, null); 83 | _this.getSheetId("work", function(err) { 84 | if (err) return callback(err, null); 85 | _this.setTemplates(); 86 | callback(null, _this); 87 | }); 88 | }); 89 | }; 90 | 91 | Spreadsheet.prototype.log = function() { 92 | if (this.debug) console.log.apply(console, arguments); 93 | }; 94 | 95 | //spreadsheet.request wraps mikeal's request with 96 | //google spreadsheet specific additions (adds token, follow) 97 | Spreadsheet.prototype.request = util.promisify(function(opts, callback) { 98 | if (!_.isPlainObject(opts) || !opts.url) { 99 | return callback("Invalid request"); 100 | } 101 | if (!this.authHeaders) { 102 | return callback("No authorization token. Use auth() first."); 103 | } 104 | //use pre-generated authenication headers 105 | opts.headers = this.authHeaders; 106 | 107 | //default to GET 108 | if (!opts.method) opts.method = "GET"; 109 | 110 | //follow redirects - even from POSTs 111 | opts.followAllRedirects = true; 112 | 113 | var _this = this; 114 | request(opts, function(err, response, body) { 115 | //show error 116 | if (err) return callback(err); 117 | //missing the response??? 118 | if (!response) return callback("no response"); 119 | //reauth 120 | if ( 121 | response.statusCode === 401 && 122 | typeof _this.opts.accessToken !== "object" 123 | ) { 124 | _this.log( 125 | "Authentication token expired. Logging into Google again...".grey 126 | ); 127 | return auth(_this.opts, function(err, token) { 128 | if (err) return callback(err); 129 | _this.setToken(token); 130 | _this.request(opts, callback); 131 | }); 132 | } 133 | //body is error 134 | if (response.statusCode !== 200) return callback(body); 135 | //try to parse XML 136 | _this.xmlParser.parseString(body, callback); 137 | }); 138 | }); 139 | 140 | //get spreadsheet/worksheet ids by name 141 | Spreadsheet.prototype.getSheetId = util.promisify(function(type, callback) { 142 | var _this = this; 143 | var id = type + "sheetId"; 144 | var display = type.charAt(0).toUpperCase() + type.substr(1) + "sheet"; 145 | var name = this[type + "sheetName"]; 146 | var spreadsheetUrlId = type === "work" ? "/" + this.spreadsheetId : ""; 147 | //already have id 148 | if (this[id]) { 149 | return callback(null); 150 | } 151 | this.log(("Searching for " + display + " '" + name + "'...").grey); 152 | this.request( 153 | { 154 | url: 155 | this.protocol + 156 | "://spreadsheets.google.com/feeds/" + 157 | type + 158 | "sheets" + 159 | spreadsheetUrlId + 160 | "/private/full" 161 | }, 162 | function(err, result) { 163 | if (err) return callback(err); 164 | var entries = result.feed.entry || []; 165 | // Force array format for result 166 | if (!(entries instanceof Array)) { 167 | entries = [entries]; 168 | } 169 | //store raw mapped results 170 | _this.raw[type + "sheets"] = entries.map(function(e1) { 171 | var e2 = {}; 172 | for (var prop in e1) { 173 | var val = e1[prop]; 174 | //remove silly $t object 175 | if (typeof val === "object") { 176 | var keys = Object.keys(val); 177 | if (keys.length === 1 && keys[0] === "$t") val = val.$t; 178 | } 179 | //remove silly gs$ 180 | if (/^g[a-z]\$(\w+)/.test(prop)) { 181 | e2[RegExp.$1] = isNaN(Number(val)) ? val : Number(val); 182 | } else { 183 | e2[prop] = isNaN(Number(val)) ? val : Number(val); 184 | } 185 | } 186 | //search for 'name', extract only end portion of URL! 187 | if (e2.title === name && e2.id && /([^\/]+)$/.test(e2.id)) { 188 | _this[id] = RegExp.$1; 189 | } 190 | return e2; 191 | }); 192 | 193 | var m = null; 194 | if (!_this[id]) return callback(type + "sheet '" + name + "' not found"); 195 | 196 | _this.log( 197 | ("Tip: Use option '" + 198 | type + 199 | 'sheetId: "' + 200 | _this[id] + 201 | "\"' for improved performance").yellow 202 | ); 203 | callback(null); 204 | } 205 | ); 206 | }); 207 | 208 | Spreadsheet.prototype.setToken = function(token) { 209 | this.authHeaders = { 210 | Authorization: token.type + " " + token.token, 211 | "Content-Type": "application/atom+xml", 212 | "GData-Version": "3.0", 213 | "If-Match": "*" 214 | }; 215 | }; 216 | 217 | Spreadsheet.prototype.baseUrl = function() { 218 | return ( 219 | this.protocol + 220 | "://spreadsheets.google.com/feeds/cells/" + 221 | this.spreadsheetId + 222 | "/" + 223 | this.worksheetId + 224 | "/private/full" 225 | ); 226 | }; 227 | 228 | Spreadsheet.prototype.setTemplates = function() { 229 | this.bodyTemplate = _.template( 230 | '\n' + 233 | "" + 234 | this.baseUrl() + 235 | "\n" + 236 | "<%= entries %>\n" + 237 | "\n" 238 | ); 239 | 240 | this.entryTemplate = _.template( 241 | "\n" + 242 | " UpdateR<%= row %>C<%= col %>\n" + 243 | ' \n' + 244 | " " + 245 | this.baseUrl() + 246 | "/R<%= row %>C<%= col %>\n" + 247 | ' \n' + 251 | ' \'/>\n' + 252 | "\n" 253 | ); 254 | }; 255 | 256 | Spreadsheet.prototype.reset = function() { 257 | //map { r: { c: CELLX, c: CELLY }} 258 | this.entries = {}; 259 | //map { name: CELL } 260 | this.names = {}; 261 | }; 262 | 263 | Spreadsheet.prototype.add = function(cells) { 264 | //init data 265 | if (_.isArray(cells)) this.arr(cells, 0, 0); 266 | else this.obj(cells, 0, 0); 267 | }; 268 | 269 | Spreadsheet.prototype.arr = function(arr, ro, co) { 270 | var i, j, rows, cols, rs, cs; 271 | 272 | // _this.log("Add Array: " + JSON.stringify(arr)); 273 | ro = util.num(ro); 274 | co = util.num(co); 275 | 276 | rows = arr; 277 | for (i = 0, rs = rows.length; i < rs; ++i) { 278 | cols = rows[i]; 279 | if (!_.isArray(cols)) { 280 | this.addVal(cols, i + 1 + ro, 1 + co); 281 | continue; 282 | } 283 | for (j = 0, cs = cols.length; j < cs; ++j) { 284 | this.addVal(cols[j], i + 1 + ro, j + 1 + co); 285 | } 286 | } 287 | return; 288 | }; 289 | 290 | Spreadsheet.prototype.obj = function(obj, ro, co) { 291 | var row, col, cols; 292 | 293 | // _this.log("Add Object: " + JSON.stringify(obj)); 294 | ro = util.num(ro); 295 | co = util.num(co); 296 | 297 | for (row in obj) { 298 | row = util.num(row); 299 | cols = obj[row]; 300 | 301 | //insert array 302 | if (_.isArray(cols)) { 303 | this.arr(cols, row - 1, 0); 304 | continue; 305 | } 306 | 307 | //insert obj 308 | for (col in cols) { 309 | col = util.num(col); 310 | var data = cols[col]; 311 | if (_.isArray(data)) this.arr(data, row - 1 + ro, col - 1 + co); 312 | else this.addVal(data, row + ro, col + co); 313 | } 314 | } 315 | }; 316 | 317 | //dereference named cells {{ myCell }} 318 | Spreadsheet.prototype.getNames = function(curr) { 319 | var _this = this; 320 | return curr.val 321 | .replace(/\{\{\s*([\-\w\s]*?)\s*\}\}/g, function(str, name) { 322 | var link = _this.names[name]; 323 | if (!link) return _this.log(("WARNING: could not find: " + name).yellow); 324 | return util.int2cell(link.row, link.col); 325 | }) 326 | .replace(/\{\{\s*([\-\d]+)\s*,\s*([\-\d]+)\s*\}\}/g, function(both, r, c) { 327 | return util.int2cell(curr.row + util.num(r), curr.col + util.num(c)); 328 | }); 329 | }; 330 | 331 | Spreadsheet.prototype.addVal = function(val, row, col) { 332 | // _this.log(("Add Value at R"+row+"C"+col+": " + val).white); 333 | 334 | if (!this.entries[row]) this.entries[row] = {}; 335 | if (this.entries[row][col]) 336 | this.log(("WARNING: R" + row + "C" + col + " already exists").yellow); 337 | 338 | var obj = { 339 | row: row, 340 | col: col 341 | }, 342 | t = typeof val; 343 | 344 | if (t === "string" || t === "number" || t === "boolean") obj.val = val; 345 | else obj = _.extend(obj, val); 346 | 347 | if (obj.name) 348 | if (this.names[obj.name]) throw "Name already exists: " + obj.name; 349 | else this.names[obj.name] = obj; 350 | 351 | if (obj.val === undefined && !obj.ref) 352 | this.log(("WARNING: Missing value in: " + JSON.stringify(obj)).yellow); 353 | 354 | this.entries[row][col] = obj; 355 | }; 356 | 357 | //convert pending batch into a string 358 | Spreadsheet.prototype.compile = function() { 359 | var row, 360 | col, 361 | strs = []; 362 | this.maxRow = 0; 363 | this.maxCol = 0; 364 | for (row in this.entries) 365 | for (col in this.entries[row]) { 366 | var obj = this.entries[row][col]; 367 | this.maxRow = Math.max(this.maxRow, row); 368 | this.maxCol = Math.max(this.maxCol, col); 369 | if (typeof obj.val === "string") obj.val = this.getNames(obj); 370 | 371 | if (obj.val === undefined) continue; 372 | else obj.val = _.escape(obj.val.toString()); 373 | 374 | strs.push(this.entryTemplate(obj)); 375 | } 376 | 377 | return this.bodyTemplate({ 378 | entries: strs.join("\n") 379 | }); 380 | }; 381 | 382 | //send (bulk) changes 383 | Spreadsheet.prototype.send = util.promisify(function(options, callback) { 384 | if (typeof options === "function") { 385 | callback = options; 386 | options = {}; 387 | } else if (!callback) { 388 | callback = function() {}; 389 | } 390 | if (!this.bodyTemplate || !this.entryTemplate) 391 | return callback( 392 | "No templates have been created. Use setTemplates() first." 393 | ); 394 | 395 | var _this = this, 396 | body = this.compile(); 397 | 398 | //finally send all the entries 399 | _this.log("Sending updates...".grey); 400 | // _this.log(entries.white); 401 | async.series( 402 | [ 403 | function autoSize(next) { 404 | if (!options.autoSize) return next(); 405 | _this.log("Determining worksheet size...".grey); 406 | _this.metadata(function(err, metadata) { 407 | if (err) return next(err); 408 | 409 | //no resize needed 410 | if ( 411 | metadata.rowCount >= _this.maxRow && 412 | metadata.colCount >= _this.maxCol 413 | ) 414 | return next(null); 415 | 416 | _this.log("Resizing worksheet...".grey); 417 | //resize with maximums 418 | metadata.rowCount = Math.max(metadata.rowCount, _this.maxRow); 419 | metadata.colCount = Math.max(metadata.colCount, _this.maxCol); 420 | _this.metadata(metadata, next); 421 | }); 422 | }, 423 | function send(next) { 424 | _this.request( 425 | { 426 | method: "POST", 427 | url: _this.baseUrl() + "/batch", 428 | body: body 429 | }, 430 | function(err, result) { 431 | var status = null; 432 | 433 | if (err) return next(err); 434 | 435 | if (!result.feed) 436 | return next( 437 | "Google Spreadsheets API has changed please post an issue!" 438 | ); 439 | 440 | if (!result.feed.entry) { 441 | _this.log("No updates in current send()".yellow); 442 | _this.reset(); 443 | return next(null); 444 | } 445 | 446 | var entries = _.isArray(result.feed.entry) 447 | ? result.feed.entry 448 | : [result.feed.entry]; 449 | 450 | // DEBUG: displays raw response from Google 451 | // console.log(entries); 452 | 453 | var errors = entries 454 | .map(function(e) { 455 | return {status: e["batch:status"]}; 456 | }) 457 | .filter(function(e) { 458 | return e.status && e.status.code !== 200; 459 | }); 460 | 461 | if (errors.length > 0) { 462 | _this.log("Error updating spreadsheet:"); 463 | _.each(errors, function(e, i) { 464 | _this.log( 465 | " #" + 466 | (i + 1) + 467 | " [" + 468 | e.status.code + 469 | "] " + 470 | e.status.reason 471 | ); 472 | }); 473 | 474 | //concat error messages 475 | var msg = 476 | "Error updating spreadsheet: " + 477 | errors 478 | .map(function(e, i) { 479 | // var msg = errors.length > 1 ? ("#"+(i+1)) : ""; 480 | var msg = e.status.reason; 481 | //swap out no message or a the silly "wait a bit" message 482 | if ( 483 | !msg || 484 | /Please wait a bit and try reloading your spreadsheet/.test( 485 | msg 486 | ) 487 | ) 488 | msg = 489 | "Your update may not fit in the worksheet. See the 'autoSize' option."; 490 | return msg; 491 | }) 492 | .join(" "); 493 | 494 | next(msg); 495 | return; 496 | } 497 | 498 | _this.log("Successfully Updated Spreadsheet".green); 499 | //data has been successfully sent, clear it 500 | _this.reset(); 501 | next(null); 502 | return; 503 | } 504 | ); 505 | } 506 | ], 507 | callback 508 | ); 509 | }); 510 | 511 | //Get entire spreadsheet 512 | Spreadsheet.prototype.receive = util.promisify(function(options, callback) { 513 | if (typeof options === "function") { 514 | callback = options; 515 | options = {}; 516 | } 517 | var _this = this; 518 | // get whole spreadsheet 519 | this.request( 520 | { 521 | url: this.baseUrl() 522 | }, 523 | function(err, result) { 524 | if (!result || !result.feed) { 525 | err = "Error Reading Spreadsheet"; 526 | _this.log( 527 | err.red.underline + 528 | "\nData:\n" + 529 | JSON.stringify(_this.entries, null, 2) 530 | ); 531 | callback(err, null); 532 | return; 533 | } 534 | 535 | var entries = result.feed.entry || []; 536 | // Force array format for result 537 | if (!(entries instanceof Array)) { 538 | entries = [entries]; 539 | } 540 | var rows = {}; 541 | var info = { 542 | spreadsheetId: _this.spreadsheetId, 543 | worksheetId: _this.worksheetId, 544 | worksheetTitle: result.feed.title.$t || result.feed.title || null, 545 | worksheetUpdated: 546 | new Date(result.feed.updated.$t || result.feed.updated) || null, 547 | authors: result.feed.author && result.feed.author.name, 548 | totalCells: entries.length, 549 | totalRows: 0, 550 | lastRow: 1, 551 | nextRow: 1 552 | }; 553 | var maxRow = 0; 554 | 555 | _.each(entries, function(entry) { 556 | var cell = entry["gs:cell"], 557 | r = cell.row, 558 | c = cell.col; 559 | if (!rows[r]) { 560 | info.totalRows++; 561 | rows[r] = {}; 562 | } 563 | 564 | rows[r][c] = util.gcell2cell( 565 | cell, 566 | options.getValues, 567 | _this.opts.useCellTextValues 568 | ); 569 | info.lastRow = util.num(r); 570 | }); 571 | 572 | if (entries.length) info.nextRow = info.lastRow + 1; 573 | 574 | _this.log( 575 | ("Retrieved " + 576 | entries.length + 577 | " cells and " + 578 | info.totalRows + 579 | " rows").green 580 | ); 581 | 582 | callback(null, rows, info); 583 | } 584 | ); 585 | }); 586 | 587 | Spreadsheet.prototype.metadata = util.promisify(function(data, callback) { 588 | var meta = new Metadata(this); 589 | if (typeof data === "function") { 590 | callback = data; 591 | meta.get(callback); 592 | return; 593 | } else if (!callback) { 594 | callback = function() {}; 595 | } 596 | meta.set(data, callback); 597 | }); 598 | --------------------------------------------------------------------------------