From 9035d3d4194cea1add842ae5bbe380f687867ccc Mon Sep 17 00:00:00 2001 From: Michael Stanton Date: Mon, 8 Dec 2014 16:43:49 +0100 Subject: [PATCH] Climbstats. --- climbstats/climbstats.js | 72 +++++ climbstats/modules/tabletop.js | 474 +++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 climbstats/climbstats.js create mode 100755 climbstats/modules/tabletop.js diff --git a/climbstats/climbstats.js b/climbstats/climbstats.js new file mode 100644 index 0000000..333f3bb --- /dev/null +++ b/climbstats/climbstats.js @@ -0,0 +1,72 @@ +// Copyright 2014, Michael Stanton. +var Tabletop = require("./modules/tabletop"); +var ClimbGrades = require("../climbgrades/climbgrades"); +var fs = require("fs"); + +var public_url = + "https://docs.google.com/spreadsheet/pub?key=0Aik9iNgOEySpdGxObG8ySmNCalJaS2R3YTZpSXFmWlE&single=true&gid=0&output=html"; + +var data = null; +var data_file = "climbstats.json"; + +function getData(data_in, tabletop) { + // Write data to a file. + fs.writeFile(data_file, JSON.stringify(data_in, null, 4), + function(err) { + if (err) { + console.log(err); + } else { + ProcessData(data); + } + }); +} + +function ProcessData(data) { + // First, turn the climbing grades into a proper ordering. + data = data.map(function(entry) { + var result = entry; + result.index = ClimbGrades.ToIndex(result.grade, ClimbGrades.GRADE.UIAA); + return result; + }); + // I want to create a record for each day with: + // hardest route climbed. + // hardest route attempted. + // mean grade (climbed or attempted). + // median grade (climbed or attempted). +} + + +function Main() { + var arguments = process.argv.slice(2); + if (arguments.length != 0 && arguments.length != 1) { + print("Usage: " + process.argv[1] + + "[]"); + print("where saved json data is a local file with the spreadsheet data."); + print("if no file is specified climbstats.json will be (over)written."); + return 1; + } + + if (arguments.length == 0) { + // Use tabletop to get the spreadsheet. + Tabletop.init({ + key: public_url, + callback: getData, + simpleSheet: true + }); + return 0; + } + + // Read the data from a file. + fs.readFile(data_file, null, function(err, data) { + if (err) { + console.log(err); + return; + } + data = JSON.parse(data); + ProcessData(data); + } + + return 0; +} + +Main(); diff --git a/climbstats/modules/tabletop.js b/climbstats/modules/tabletop.js new file mode 100755 index 0000000..82b2fc9 --- /dev/null +++ b/climbstats/modules/tabletop.js @@ -0,0 +1,474 @@ +(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);