(function(global) { "use strict"; var inNodeJS = false; if (typeof module !== 'undefined' && module.exports) { inNodeJS = true; var request = require('request'); } var supportsCORS = false; var inLegacyIE = false; try { var testXHR = new XMLHttpRequest(); if (typeof testXHR.withCredentials !== 'undefined') { supportsCORS = true; } else { if ("XDomainRequest" in window) { supportsCORS = true; inLegacyIE = true; } } } catch (e) { } // Create a simple indexOf function for support // of older browsers. Uses native indexOf if // available. Code similar to underscores. // By making a separate function, instead of adding // to the prototype, we will not break bad for loops // in older browsers var indexOfProto = Array.prototype.indexOf; var ttIndexOf = function(array, item) { var i = 0, l = array.length; if (indexOfProto && array.indexOf === indexOfProto) return array.indexOf(item); for (; i < l; i++) if (array[i] === item) return i; return -1; }; /* Initialize with Tabletop.init( { key: '0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc' } ) OR! Initialize with Tabletop.init( { key: 'https://docs.google.com/spreadsheet/pub?hl=en_US&hl=en_US&key=0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc&output=html&widget=true' } ) OR! Initialize with Tabletop.init('0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc') */ var Tabletop = function(options) { // Make sure Tabletop is being used as a constructor no matter what. if(!this || !(this instanceof Tabletop)) { return new Tabletop(options); } if(typeof(options) === 'string') { options = { key : options }; } this.callback = options.callback; this.wanted = options.wanted || []; this.key = options.key; this.simpleSheet = !!options.simpleSheet; this.parseNumbers = !!options.parseNumbers; this.wait = !!options.wait; this.reverse = !!options.reverse; this.postProcess = options.postProcess; this.debug = !!options.debug; this.query = options.query || ''; this.orderby = options.orderby; this.endpoint = options.endpoint || "https://spreadsheets.google.com"; this.singleton = !!options.singleton; this.simple_url = !!options.simple_url; this.callbackContext = options.callbackContext; if(typeof(options.proxy) !== 'undefined') { // Remove trailing slash, it will break the app this.endpoint = options.proxy.replace(/\/$/,''); this.simple_url = true; this.singleton = true; // Let's only use CORS (straight JSON request) when // fetching straight from Google supportsCORS = false } this.parameterize = options.parameterize || false; if(this.singleton) { if(typeof(Tabletop.singleton) !== 'undefined') { this.log("WARNING! Tabletop singleton already defined"); } Tabletop.singleton = this; } /* Be friendly about what you accept */ if(/key=/.test(this.key)) { this.log("You passed an old Google Docs url as the key! Attempting to parse."); this.key = this.key.match("key=(.*?)&")[1]; } if(/pubhtml/.test(this.key)) { this.log("You passed a new Google Spreadsheets url as the key! Attempting to parse."); this.key = this.key.match("d\\/(.*?)\\/pubhtml")[1]; } if(!this.key) { this.log("You need to pass Tabletop a key!"); return; } this.log("Initializing with key " + this.key); this.models = {}; this.model_names = []; this.base_json_path = "/feeds/worksheets/" + this.key + "/public/basic?alt="; if (inNodeJS || supportsCORS) { this.base_json_path += 'json'; } else { this.base_json_path += 'json-in-script'; } if(!this.wait) { this.fetch(); } }; // A global storage for callbacks. Tabletop.callbacks = {}; // Backwards compatibility. Tabletop.init = function(options) { return new Tabletop(options); }; Tabletop.sheets = function() { this.log("Times have changed! You'll want to use var tabletop = Tabletop.init(...); tabletop.sheets(...); instead of Tabletop.sheets(...)"); }; Tabletop.prototype = { fetch: function(callback) { if(typeof(callback) !== "undefined") { this.callback = callback; } this.requestData(this.base_json_path, this.loadSheets); }, /* This will call the environment appropriate request method. In browser it will use JSON-P, in node it will use request() */ requestData: function(path, callback) { if (inNodeJS) { this.serverSideFetch(path, callback); } else { //CORS only works in IE8/9 across the same protocol //You must have your server on HTTPS to talk to Google, or it'll fall back on injection var protocol = this.endpoint.split("//").shift() || "http"; if (supportsCORS && (!inLegacyIE || protocol === location.protocol)) { this.xhrFetch(path, callback); } else { this.injectScript(path, callback); } } }, /* Use Cross-Origin XMLHttpRequest to get the data in browsers that support it. */ xhrFetch: function(path, callback) { //support IE8's separate cross-domain object var xhr = inLegacyIE ? new XDomainRequest() : new XMLHttpRequest(); xhr.open("GET", this.endpoint + path); var self = this; xhr.onload = function() { try { var json = JSON.parse(xhr.responseText); } catch (e) { console.error(e); } callback.call(self, json); }; xhr.send(); }, /* Insert the URL into the page as a script tag. Once it's loaded the spreadsheet data it triggers the callback. This helps you avoid cross-domain errors http://code.google.com/apis/gdata/samples/spreadsheet_sample.html Let's be plain-Jane and not use jQuery or anything. */ injectScript: function(path, callback) { var script = document.createElement('script'); var callbackName; if(this.singleton) { if(callback === this.loadSheets) { callbackName = 'Tabletop.singleton.loadSheets'; } else if (callback === this.loadSheet) { callbackName = 'Tabletop.singleton.loadSheet'; } } else { var self = this; callbackName = 'tt' + (+new Date()) + (Math.floor(Math.random()*100000)); // Create a temp callback which will get removed once it has executed, // this allows multiple instances of Tabletop to coexist. Tabletop.callbacks[ callbackName ] = function () { var args = Array.prototype.slice.call( arguments, 0 ); callback.apply(self, args); script.parentNode.removeChild(script); delete Tabletop.callbacks[callbackName]; }; callbackName = 'Tabletop.callbacks.' + callbackName; } var url = path + "&callback=" + callbackName; if(this.simple_url) { // We've gone down a rabbit hole of passing injectScript the path, so let's // just pull the sheet_id out of the path like the least efficient worker bees if(path.indexOf("/list/") !== -1) { script.src = this.endpoint + "/" + this.key + "-" + path.split("/")[4]; } else { script.src = this.endpoint + "/" + this.key; } } else { script.src = this.endpoint + url; } if (this.parameterize) { script.src = this.parameterize + encodeURIComponent(script.src); } document.getElementsByTagName('script')[0].parentNode.appendChild(script); }, /* This will only run if tabletop is being run in node.js */ serverSideFetch: function(path, callback) { var self = this request({url: this.endpoint + path, json: true}, function(err, resp, body) { if (err) { return console.error(err); } callback.call(self, body); }); }, /* Is this a sheet you want to pull? If { wanted: ["Sheet1"] } has been specified, only Sheet1 is imported Pulls all sheets if none are specified */ isWanted: function(sheetName) { if(this.wanted.length === 0) { return true; } else { return (ttIndexOf(this.wanted, sheetName) !== -1); } }, /* What gets send to the callback if simpleSheet === true, then don't return an array of Tabletop.this.models, only return the first one's elements */ data: function() { // If the instance is being queried before the data's been fetched // then return undefined. if(this.model_names.length === 0) { return undefined; } if(this.simpleSheet) { if(this.model_names.length > 1 && this.debug) { this.log("WARNING You have more than one sheet but are using simple sheet mode! Don't blame me when something goes wrong."); } return this.models[ this.model_names[0] ].all(); } else { return this.models; } }, /* Add another sheet to the wanted list */ addWanted: function(sheet) { if(ttIndexOf(this.wanted, sheet) === -1) { this.wanted.push(sheet); } }, /* Load all worksheets of the spreadsheet, turning each into a Tabletop Model. Need to use injectScript because the worksheet view that you're working from doesn't actually include the data. The list-based feed (/feeds/list/key..) does, though. Calls back to loadSheet in order to get the real work done. Used as a callback for the worksheet-based JSON */ loadSheets: function(data) { var i, ilen; var toLoad = []; this.foundSheetNames = []; for(i = 0, ilen = data.feed.entry.length; i < ilen ; i++) { this.foundSheetNames.push(data.feed.entry[i].title.$t); // Only pull in desired sheets to reduce loading if( this.isWanted(data.feed.entry[i].content.$t) ) { var linkIdx = data.feed.entry[i].link.length-1; var sheet_id = data.feed.entry[i].link[linkIdx].href.split('/').pop(); var json_path = "/feeds/list/" + this.key + "/" + sheet_id + "/public/values?alt=" if (inNodeJS || supportsCORS) { json_path += 'json'; } else { json_path += 'json-in-script'; } if(this.query) { json_path += "&sq=" + this.query; } if(this.orderby) { json_path += "&orderby=column:" + this.orderby.toLowerCase(); } if(this.reverse) { json_path += "&reverse=true"; } toLoad.push(json_path); } } this.sheetsToLoad = toLoad.length; for(i = 0, ilen = toLoad.length; i < ilen; i++) { this.requestData(toLoad[i], this.loadSheet); } }, /* Access layer for the this.models .sheets() gets you all of the sheets .sheets('Sheet1') gets you the sheet named Sheet1 */ sheets: function(sheetName) { if(typeof sheetName === "undefined") { return this.models; } else { if(typeof(this.models[ sheetName ]) === "undefined") { // alert( "Can't find " + sheetName ); return; } else { return this.models[ sheetName ]; } } }, /* Parse a single list-based worksheet, turning it into a Tabletop Model Used as a callback for the list-based JSON */ loadSheet: function(data) { var model = new Tabletop.Model( { data: data, parseNumbers: this.parseNumbers, postProcess: this.postProcess, tabletop: this } ); this.models[ model.name ] = model; if(ttIndexOf(this.model_names, model.name) === -1) { this.model_names.push(model.name); } this.sheetsToLoad--; if(this.sheetsToLoad === 0) this.doCallback(); }, /* Execute the callback upon loading! Rely on this.data() because you might only request certain pieces of data (i.e. simpleSheet mode) Tests this.sheetsToLoad just in case a race condition happens to show up */ doCallback: function() { if(this.sheetsToLoad === 0) { this.callback.apply(this.callbackContext || this, [this.data(), this]); } }, log: function(msg) { if(this.debug) { if(typeof console !== "undefined" && typeof console.log !== "undefined") { Function.prototype.apply.apply(console.log, [console, arguments]); } } } }; /* Tabletop.Model stores the attribute names and parses the worksheet data to turn it into something worthwhile Options should be in the format { data: XXX }, with XXX being the list-based worksheet */ Tabletop.Model = function(options) { var i, j, ilen, jlen; this.column_names = []; this.name = options.data.feed.title.$t; this.elements = []; this.raw = options.data; // A copy of the sheet's raw data, for accessing minutiae if(typeof(options.data.feed.entry) === 'undefined') { options.tabletop.log("Missing data for " + this.name + ", make sure you didn't forget column headers"); this.elements = []; return; } for(var key in options.data.feed.entry[0]){ if(/^gsx/.test(key)) this.column_names.push( key.replace("gsx$","") ); } for(i = 0, ilen = options.data.feed.entry.length ; i < ilen; i++) { var source = options.data.feed.entry[i]; var element = {}; for(var j = 0, jlen = this.column_names.length; j < jlen ; j++) { var cell = source[ "gsx$" + this.column_names[j] ]; if (typeof(cell) !== 'undefined') { if(options.parseNumbers && cell.$t !== '' && !isNaN(cell.$t)) element[ this.column_names[j] ] = +cell.$t; else element[ this.column_names[j] ] = cell.$t; } else { element[ this.column_names[j] ] = ''; } } if(element.rowNumber === undefined) element.rowNumber = i + 1; if( options.postProcess ) options.postProcess(element); this.elements.push(element); } }; Tabletop.Model.prototype = { /* Returns all of the elements (rows) of the worksheet as objects */ all: function() { return this.elements; }, /* Return the elements as an array of arrays, instead of an array of objects */ toArray: function() { var array = [], i, j, ilen, jlen; for(i = 0, ilen = this.elements.length; i < ilen; i++) { var row = []; for(j = 0, jlen = this.column_names.length; j < jlen ; j++) { row.push( this.elements[i][ this.column_names[j] ] ); } array.push(row); } return array; } }; if(inNodeJS) { module.exports = Tabletop; } else { global.Tabletop = Tabletop; } })(this);