diff --git a/js/tabletop.js b/js/tabletop.js new file mode 100644 index 0000000..2f2b7a8 --- /dev/null +++ b/js/tabletop.js @@ -0,0 +1,414 @@ +(function(global) { + "use strict"; + + var inNodeJS = false; + if (typeof process !== 'undefined') { + inNodeJS = true; + var request = require('request'); + } + + if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (obj, fromIndex) { + if (fromIndex === null) { + fromIndex = 0; + } else if (fromIndex < 0) { + fromIndex = Math.max(0, this.length + fromIndex); + } + for (var i = fromIndex, j = this.length; i < j; i++) { + if (this[i] === obj) { + 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.postProcess = options.postProcess; + this.debug = !!options.debug; + this.query = options.query || ''; + 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') { + this.endpoint = options.proxy; + this.simple_url = true; + this.singleton = true; + } + + 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 a key as a URL! Attempting to parse."); + this.key = this.key.match("key=(.*?)&")[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) { + 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 { + this.injectScript(path, callback); + } + }, + + /* + 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 this.wanted.indexOf(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(this.wanted.indexOf(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 sheet_id = data.feed.entry[i].link[3].href.substr( data.feed.entry[i].link[3].href.length - 3, 3); + var json_path = "/feeds/list/" + this.key + "/" + sheet_id + "/public/values?sq=" + this.query + '&alt=' + if (inNodeJS) { + json_path += 'json'; + } else { + json_path += 'json-in-script'; + } + 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(this.model_names.indexOf(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); \ No newline at end of file