├── .gitignore ├── .npmignore ├── README.md ├── demo ├── index.html ├── locations.xls ├── mapsheet.js └── tabletop.js ├── package.json └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simplegdocserver 2 | 3 | Simple REST Server that emulates Google Docs interface using your Excel files. 4 | (currently read-only) 5 | 6 | ## Installation 7 | 8 | (requires node + npm) 9 | 10 | ``` 11 | npm install -g simplegdocserver 12 | ``` 13 | 14 | ## Usage 15 | 16 | To host files from the current directory, on port 7263: 17 | 18 | ``` 19 | $ simplegdocserver 20 | ``` 21 | 22 | To use a different port, either set the PORT environment variable or pass it 23 | as an argument: 24 | 25 | ``` 26 | $ simplegdocserver 8000 27 | ``` 28 | 29 | To host from a different directory, pass a third argument or set the BASE_DIR 30 | environment variable: 31 | 32 | ``` 33 | $ simplegdocserver 8000 ./test_files 34 | ``` 35 | 36 | ## Interaction with Client Libraries 37 | 38 | Using [tabletop](https://github.com/jsoma/tabletop), just set the endpoint: 39 | 40 | ``` 41 | Tabletop.init( { 42 | endpoint:"http://localhost:7263", // <-- adjust based on server settings 43 | key: "myfile.xls", // <-- the actual filename 44 | ... 45 | }); 46 | ``` 47 | 48 | The demo directory includes a demo using [mapsheet](https://github.com/jsoma/mapsheet) to annotate a map. 49 | 50 | ## Notes 51 | 52 | *there is no caching*. This is intentional: you can quickly change your data 53 | in Excel and simplegdocserver will immediately see the change. 54 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 22 | 23 | 25 | 30 | 31 | 32 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /demo/locations.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SheetJS/sgds/5664504616ad62651856840897f8b1c99d69162a/demo/locations.xls -------------------------------------------------------------------------------- /demo/mapsheet.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | "use strict"; 3 | 4 | function merge_options(obj1, obj2) { 5 | var obj3 = {}; 6 | var attrname; 7 | for (attrname in obj1) { obj3[attrname] = obj1[attrname]; } 8 | for (attrname in obj2) { obj3[attrname] = obj2[attrname]; } 9 | return obj3; 10 | } 11 | 12 | var Mapsheet = global.Mapsheet = function(options) { 13 | // Make sure Mapsheet is being used as a constructor no matter what. 14 | if(!this || !(this instanceof Mapsheet)) { 15 | return new Mapsheet(options); 16 | } 17 | 18 | this.key = options.key; 19 | this.click = options.click; 20 | this.passedMap = options.map; 21 | this.element = options.element; 22 | this.sheetName = options.sheetName; 23 | this.provider = options.provider || Mapsheet.Providers.Google; 24 | this.renderer = new this.provider( { map: options.passedMap, mapOptions: options.mapOptions, layerOptions: options.layerOptions, markerLayer: options.markerLayer } ); 25 | this.fields = options.fields; 26 | this.titleColumn = options.titleColumn; 27 | this.popupContent = options.popupContent; 28 | this.popupTemplate = options.popupTemplate; 29 | this.callbackContext = options.callbackContext; 30 | this.callback = options.callback; 31 | 32 | // Let's automatically engage simpleSheet mode, 33 | // which allows for easier using of data later on 34 | // if you have multiple sheets, you'll want to 35 | // disable this 36 | var simpleSheet = true; 37 | 38 | if(typeof(this.popupTemplate) === 'string') { 39 | var source = document.getElementById(this.popupTemplate).innerHTML; 40 | this.popupTemplate = Handlebars.compile(source); 41 | } 42 | this.markerOptions = options.markerOptions || {}; 43 | 44 | if(typeof(this.element) === 'string') { 45 | this.element = document.getElementById(this.element); 46 | }; 47 | 48 | this.tabletop = new Tabletop( { key: this.key, callback: this.loadPoints, callbackContext: this, simpleSheet: simpleSheet, proxy: options.proxy, endpoint: options.endpoint } ); 49 | }; 50 | 51 | 52 | Mapsheet.prototype = { 53 | 54 | loadPoints: function(data, tabletop) { 55 | this.points = []; 56 | 57 | if(typeof(this.sheetName) === 'undefined') { 58 | this.sheetName = tabletop.model_names[0]; 59 | } 60 | 61 | var elements = tabletop.sheets(this.sheetName).elements; 62 | 63 | for(var i = 0; i < elements.length; i++) { 64 | var point = new Mapsheet.Point( { model: elements[i], fields: this.fields, popupContent: this.popupContent, popupTemplate: this.popupTemplate, markerOptions: this.markerOptions, titleColumn: this.titleColumn, click: this.click } ); 65 | this.points.push(point); 66 | }; 67 | 68 | this.draw(); 69 | }, 70 | 71 | draw: function() { 72 | this.renderer.initialize(this.element); 73 | this.renderer.drawPoints(this.points); 74 | if(this.callback) { 75 | this.callback.apply(this.callbackContext || this, [this, this.tabletop]); 76 | } 77 | }, 78 | 79 | log: function(msg) { 80 | if(this.debug) { 81 | if(typeof console !== "undefined" && typeof console.log !== "undefined") { 82 | Function.prototype.apply.apply(console.log, [console, arguments]); 83 | } 84 | } 85 | }, 86 | 87 | map: function() { 88 | return (this.passedMap || this.renderer.map); 89 | } 90 | 91 | }; 92 | 93 | Mapsheet.Point = function(options) { 94 | this.model = options.model; 95 | this.fields = options.fields; 96 | this.popupContent = options.popupContent; 97 | this.popupTemplate = options.popupTemplate; 98 | this.titleColumn = options.titleColumn; 99 | this.markerOptions = options.markerOptions; 100 | this.click = options.click 101 | }; 102 | 103 | Mapsheet.Point.prototype = { 104 | coords: function() { 105 | return [ this.latitude(), this.longitude() ]; 106 | }, 107 | 108 | latitude: function() { 109 | return parseFloat( this.model["latitude"] || this.model["lat"] ); 110 | }, 111 | 112 | longitude: function() { 113 | return parseFloat( this.model["longitude"] || this.model["lng"] || this.model["long"] ); 114 | }, 115 | 116 | get: function(fieldName) { 117 | if(typeof(fieldName) === 'undefined') { 118 | return; 119 | } 120 | return this.model[fieldName.toLowerCase().replace(/ +/,'')]; 121 | }, 122 | 123 | title: function() { 124 | return this.get(this.titleColumn); 125 | }, 126 | 127 | isValid: function() { 128 | return !isNaN( this.latitude() ) && !isNaN( this.longitude() ) 129 | }, 130 | 131 | content: function() { 132 | var html = ""; 133 | if(typeof(this.popupContent) !== 'undefined') { 134 | html = this.popupContent.call(this, this.model); 135 | } else if(typeof(this.popupTemplate) !== 'undefined') { 136 | html = this.popupTemplate.call(this, this.model); 137 | } else if(typeof(this.fields) !== 'undefined') { 138 | if(typeof(this.title()) !== 'undefined' && this.title() !== '') { 139 | html += "

" + this.title() + "

"; 140 | } 141 | for(var i = 0; i < this.fields.length; i++) { 142 | html += "

" + this.fields[i] + ": " + this.get(this.fields[i]) + "

"; 143 | } 144 | } else { 145 | return ''; 146 | } 147 | return "
" + html + "
" 148 | } 149 | }; 150 | 151 | /* 152 | 153 | Providers only need respond to initialize and drawPoints 154 | 155 | */ 156 | 157 | Mapsheet.Providers = {}; 158 | 159 | 160 | /* 161 | 162 | Google Maps 163 | 164 | */ 165 | 166 | Mapsheet.Providers.Google = function(options) { 167 | this.map = options.map; 168 | this.mapOptions = merge_options( { mapTypeId: google.maps.MapTypeId.ROADMAP }, options.mapOptions || {} ); 169 | // We'll be nice and allow center to be a lat/lng array instead of a Google Maps LatLng 170 | if(this.mapOptions.center && this.mapOptions.center.length == 2) { 171 | this.mapOptions.center = new google.maps.LatLng(this.mapOptions.center[0], this.mapOptions.center[1]); 172 | } 173 | }; 174 | 175 | Mapsheet.Providers.Google.prototype = { 176 | initialize: function(element) { 177 | if(typeof(this.map) === 'undefined') { 178 | this.map = new google.maps.Map(element, this.mapOptions); 179 | } 180 | this.bounds = new google.maps.LatLngBounds(); 181 | this.infowindow = new google.maps.InfoWindow({ content: "loading...", maxWidth: '300' }); 182 | }, 183 | 184 | /* 185 | Google Maps only colors markers #FE7569, but turns out you can use 186 | the Google Charts API to make markers any hex color! Amazing. 187 | 188 | This code was pulled from 189 | http://stackoverflow.com/questions/7095574/google-maps-api-3-custom-marker-color-for-default-dot-marker/7686977#7686977 190 | */ 191 | 192 | setMarkerIcon: function(marker) { 193 | if(typeof(marker.point.get('icon url')) !== 'undefined' && marker.point.get('icon url') !== '') { 194 | marker.setIcon(marker.point.get('icon url')); 195 | return; 196 | }; 197 | 198 | if(typeof(marker.point.markerOptions['iconUrl']) !== 'undefined' && marker.point.markerOptions['iconUrl'] !== '') { 199 | marker.setIcon( marker.point.markerOptions['iconUrl']); 200 | return; 201 | }; 202 | 203 | var pinColor = marker.point.get('hexcolor') || "FE7569"; 204 | pinColor = pinColor.replace('#',''); 205 | 206 | var pinImage = new google.maps.MarkerImage("http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=|" + pinColor, 207 | new google.maps.Size(21, 34), 208 | new google.maps.Point(0,0), 209 | new google.maps.Point(10, 34)); 210 | var pinShadow = new google.maps.MarkerImage("http://chart.apis.google.com/chart?chst=d_map_pin_shadow", 211 | new google.maps.Size(40, 37), 212 | new google.maps.Point(0, 0), 213 | new google.maps.Point(12, 35)); 214 | marker.setShadow(pinShadow); 215 | marker.setIcon(pinImage); 216 | }, 217 | 218 | drawMarker: function(point) { 219 | var latLng = new google.maps.LatLng(point.latitude(), point.longitude()); 220 | 221 | var marker = new google.maps.Marker({ 222 | position: latLng, 223 | point: point, 224 | title: point.title() 225 | }); 226 | 227 | this.setMarkerIcon(marker); 228 | this.initInfoWindow(marker); 229 | 230 | if(point.click) { 231 | google.maps.event.addListener(marker, 'click', function(e) { 232 | point.click.call(this, e, point); 233 | }); 234 | } 235 | 236 | return marker; 237 | }, 238 | 239 | /* 240 | Google only lets you draw one InfoWindow on the page, so you 241 | end up having to re-write to the original one each time. 242 | */ 243 | 244 | initInfoWindow: function(marker) { 245 | var infowindow = this.infowindow; 246 | var clickedOpen = false; 247 | 248 | // All of the extra blah blahs are for making sure to not repopulate 249 | // the infowindow when it's already opened and populated with the 250 | // right content 251 | 252 | google.maps.event.addListener(marker, 'click', function() { 253 | if(infowindow.getAnchor() === marker && infowindow.opened) { 254 | return; 255 | } 256 | infowindow.setContent(this.point.content()); 257 | infowindow.open(this.map, this); 258 | clickedOpen = true; 259 | infowindow.opened = true; 260 | }); 261 | 262 | }, 263 | 264 | drawPoints: function(points) { 265 | for(var i = 0; i < points.length; i++) { 266 | if(!points[i].isValid()) { continue; } 267 | var marker = this.drawMarker(points[i]); 268 | marker.setMap(this.map); 269 | this.bounds.extend(marker.position); 270 | points[i].marker = marker; 271 | }; 272 | 273 | if(!this.mapOptions.zoom && !this.mapOptions.center) { 274 | this.map.fitBounds(this.bounds); 275 | } 276 | } 277 | } 278 | 279 | /* 280 | 281 | MapQuest (OpenStreetMaps & free) 282 | 283 | */ 284 | 285 | Mapsheet.Providers.MapQuest = function(options) { 286 | this.map = options.map; 287 | this.mapOptions = merge_options({ mapTypeId: 'osm', zoom: 13, bestFitMargin: 0, zoomOnDoubleClick: true, latLng:{lat:40.735383, lng:-73.984655} }, options.mapOptions || {}); 288 | }; 289 | 290 | Mapsheet.Providers.MapQuest.prototype = { 291 | initialize: function(element) { 292 | if(typeof(this.map) === 'undefined') { 293 | this.map = new MQA.TileMap( merge_options({ elt: element }, this.mapOptions) ); 294 | } 295 | }, 296 | 297 | // We need custom icons! 298 | 299 | drawMarker: function(point) { 300 | var marker = new MQA.Poi( { lat: point.latitude(), lng: point.longitude() } ); 301 | 302 | marker.setRolloverContent(point.title()); 303 | marker.setInfoContentHTML(point.content()); 304 | 305 | if(point.click) { 306 | MQA.EventManager.addListener(marker, 'click', function(e) { 307 | point.click.call(this, e, point); 308 | }); 309 | } 310 | 311 | return marker; 312 | }, 313 | 314 | drawPoints: function(points) { 315 | for(var i = 0; i < points.length; i++) { 316 | if(!points[i].isValid()) { continue; } 317 | var marker = this.drawMarker(points[i]); 318 | this.map.addShape(marker); 319 | points[i].marker = marker; 320 | }; 321 | this.map.bestFit(); 322 | } 323 | } 324 | 325 | /* 326 | 327 | MapBox 328 | 329 | */ 330 | 331 | Mapsheet.Providers.MapBox = function(options) { 332 | this.map = options.map; 333 | this.mapOptions = merge_options({ mapId: 'examples.map-vyofok3q'}, options.mapOptions || {}); 334 | this.markerLayer = options.markerLayer || L.mapbox.markerLayer(); 335 | this.bounds = new L.LatLngBounds(); 336 | }; 337 | 338 | Mapsheet.Providers.MapBox.prototype = { 339 | initialize: function(element) { 340 | if(typeof(this.map) === 'undefined') { 341 | this.map = L.mapbox.map( element ); 342 | this.map.addLayer(L.mapbox.tileLayer(this.mapOptions['mapId'])); // add the base layer 343 | // this.map.ui.zoomer.add(); 344 | // this.map.ui.zoombox.add(); 345 | } 346 | }, 347 | 348 | drawMarker: function(point) { 349 | var marker = L.marker(point.coords()) 350 | .bindPopup(point.content()) 351 | 352 | if(typeof(point.get('icon url')) !== 'undefined' && point.get('icon url') !== '') { 353 | var options = merge_options( point.markerOptions, { iconUrl: point.get('icon url') } ); 354 | var icon = L.icon(options); 355 | marker.setIcon(icon); 356 | } else if(typeof(point.markerOptions['iconUrl']) !== 'undefined') { 357 | var icon = L.icon(point.markerOptions); 358 | marker.setIcon(icon); 359 | } 360 | 361 | if(point.click) { 362 | marker.on('click', function(e) { 363 | point.click.call(this, e, point); 364 | }); 365 | } 366 | // var icon = L.icon(); 367 | // marker.setIcon(icon); 368 | 369 | return marker; 370 | }, 371 | 372 | drawPoints: function(points) { 373 | for(var i = 0; i < points.length; i++) { 374 | if(!points[i].isValid()) { continue; } 375 | var marker = this.drawMarker(points[i]); 376 | marker.addTo(this.markerLayer); 377 | this.bounds.extend(marker.getLatLng()); 378 | points[i].marker = marker; 379 | }; 380 | 381 | this.markerLayer.addTo(this.map); 382 | 383 | if(!this.mapOptions.zoom && !this.mapOptions.center) { 384 | this.map.fitBounds(this.bounds); 385 | } 386 | } 387 | } 388 | 389 | /* 390 | 391 | Did you know you can pass in your own map? 392 | Check out https://gist.github.com/1804938 for some tips on using different tile providers 393 | 394 | */ 395 | 396 | Mapsheet.Providers.Leaflet = function(options) { 397 | this.map = options.map; 398 | 399 | var attribution = 'Map data © OpenStreetMap contributors, CC-BY-SA, tiles © MapQuest '; 400 | 401 | var layerDefaults = { 402 | styleId: 998, 403 | attribution: attribution, 404 | type: 'osm' 405 | }; 406 | 407 | this.layerOptions = merge_options(layerDefaults, options.layerOptions || {}); 408 | 409 | // Only overwrite if there's no tilePath, because the default subdomains is 'abc' 410 | if(!this.layerOptions.tilePath) { 411 | this.layerOptions.tilePath = 'http://otile{s}.mqcdn.com/tiles/1.0.0/{type}/{z}/{x}/{y}.png'; 412 | this.layerOptions.subdomains = '1234'; 413 | this.layerOptions.type = 'osm'; 414 | } 415 | this.markerLayer = options.markerLayer || new L.LayerGroup(); 416 | this.mapOptions = options.mapOptions || {}; 417 | this.bounds = new L.LatLngBounds(); 418 | }; 419 | 420 | Mapsheet.Providers.Leaflet.prototype = { 421 | initialize: function(element) { 422 | if(typeof(this.map) === 'undefined') { 423 | this.map = new L.Map('map', this.mapOptions); 424 | this.tileLayer = new L.TileLayer(this.layerOptions['tilePath'], this.layerOptions).addTo(this.map); 425 | } 426 | }, 427 | 428 | drawMarker: function(point) { 429 | var marker = L.marker(point.coords()) 430 | .bindPopup(point.content()) 431 | 432 | if(typeof(point.get('icon url')) !== 'undefined' && point.get('icon url') !== '') { 433 | var options = merge_options( point.markerOptions, { iconUrl: point.get('icon url') } ); 434 | var icon = L.icon(options); 435 | marker.setIcon(icon); 436 | } else if(typeof(point.markerOptions['iconUrl']) !== 'undefined') { 437 | var icon = L.icon(point.markerOptions); 438 | marker.setIcon(icon); 439 | } 440 | 441 | if(point.click) { 442 | marker.on('click', function(e) { 443 | point.click.call(this, e, point); 444 | }); 445 | } 446 | // var icon = L.icon(); 447 | // marker.setIcon(icon); 448 | 449 | return marker; 450 | }, 451 | 452 | drawPoints: function(points) { 453 | for(var i = 0; i < points.length; i++) { 454 | if(!points[i].isValid()) { continue; } 455 | var marker = this.drawMarker(points[i]); 456 | marker.addTo(this.markerLayer); 457 | this.bounds.extend(marker.getLatLng()); 458 | points[i].marker = marker; 459 | }; 460 | 461 | this.markerLayer.addTo(this.map); 462 | 463 | if(!this.mapOptions.zoom && !this.mapOptions.center) { 464 | this.map.fitBounds(this.bounds); 465 | } 466 | } 467 | } 468 | 469 | })(this); 470 | -------------------------------------------------------------------------------- /demo/tabletop.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | "use strict"; 3 | 4 | var inNodeJS = false; 5 | if (typeof module !== 'undefined' && module.exports) { 6 | inNodeJS = true; 7 | var request = require('request'); 8 | } 9 | 10 | var supportsCORS = false; 11 | var inLegacyIE = false; 12 | try { 13 | var testXHR = new XMLHttpRequest(); 14 | if (typeof testXHR.withCredentials !== 'undefined') { 15 | supportsCORS = true; 16 | } else { 17 | if ("XDomainRequest" in window) { 18 | supportsCORS = true; 19 | inLegacyIE = true; 20 | } 21 | } 22 | } catch (e) { } 23 | 24 | // Create a simple indexOf function for support 25 | // of older browsers. Uses native indexOf if 26 | // available. Code similar to underscores. 27 | // By making a separate function, instead of adding 28 | // to the prototype, we will not break bad for loops 29 | // in older browsers 30 | var indexOfProto = Array.prototype.indexOf; 31 | var ttIndexOf = function(array, item) { 32 | var i = 0, l = array.length; 33 | 34 | if (indexOfProto && array.indexOf === indexOfProto) return array.indexOf(item); 35 | for (; i < l; i++) if (array[i] === item) return i; 36 | return -1; 37 | }; 38 | 39 | /* 40 | Initialize with Tabletop.init( { key: '0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc' } ) 41 | OR! 42 | Initialize with Tabletop.init( { key: 'https://docs.google.com/spreadsheet/pub?hl=en_US&hl=en_US&key=0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc&output=html&widget=true' } ) 43 | OR! 44 | Initialize with Tabletop.init('0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc') 45 | */ 46 | 47 | var Tabletop = function(options) { 48 | // Make sure Tabletop is being used as a constructor no matter what. 49 | if(!this || !(this instanceof Tabletop)) { 50 | return new Tabletop(options); 51 | } 52 | 53 | if(typeof(options) === 'string') { 54 | options = { key : options }; 55 | } 56 | 57 | this.callback = options.callback; 58 | this.wanted = options.wanted || []; 59 | this.key = options.key; 60 | this.simpleSheet = !!options.simpleSheet; 61 | this.parseNumbers = !!options.parseNumbers; 62 | this.wait = !!options.wait; 63 | this.reverse = !!options.reverse; 64 | this.postProcess = options.postProcess; 65 | this.debug = !!options.debug; 66 | this.query = options.query || ''; 67 | this.orderby = options.orderby; 68 | this.endpoint = options.endpoint || "https://spreadsheets.google.com"; 69 | this.singleton = !!options.singleton; 70 | this.simple_url = !!options.simple_url; 71 | this.callbackContext = options.callbackContext; 72 | 73 | if(typeof(options.proxy) !== 'undefined') { 74 | this.endpoint = options.proxy; 75 | this.simple_url = true; 76 | this.singleton = true; 77 | // Let's only use CORS (straight JSON request) when 78 | // fetching straight from Google 79 | supportsCORS = false 80 | } 81 | 82 | this.parameterize = options.parameterize || false; 83 | 84 | if(this.singleton) { 85 | if(typeof(Tabletop.singleton) !== 'undefined') { 86 | this.log("WARNING! Tabletop singleton already defined"); 87 | } 88 | Tabletop.singleton = this; 89 | } 90 | 91 | /* Be friendly about what you accept */ 92 | if(/key=/.test(this.key)) { 93 | this.log("You passed a key as a URL! Attempting to parse."); 94 | this.key = this.key.match("key=(.*?)&")[1]; 95 | } 96 | 97 | if(!this.key) { 98 | this.log("You need to pass Tabletop a key!"); 99 | return; 100 | } 101 | 102 | this.log("Initializing with key " + this.key); 103 | 104 | this.models = {}; 105 | this.model_names = []; 106 | 107 | this.base_json_path = "/feeds/worksheets/" + this.key + "/public/basic?alt="; 108 | 109 | if (inNodeJS || supportsCORS) { 110 | this.base_json_path += 'json'; 111 | } else { 112 | this.base_json_path += 'json-in-script'; 113 | } 114 | 115 | if(!this.wait) { 116 | this.fetch(); 117 | } 118 | }; 119 | 120 | // A global storage for callbacks. 121 | Tabletop.callbacks = {}; 122 | 123 | // Backwards compatibility. 124 | Tabletop.init = function(options) { 125 | return new Tabletop(options); 126 | }; 127 | 128 | Tabletop.sheets = function() { 129 | this.log("Times have changed! You'll want to use var tabletop = Tabletop.init(...); tabletop.sheets(...); instead of Tabletop.sheets(...)"); 130 | }; 131 | 132 | Tabletop.prototype = { 133 | 134 | fetch: function(callback) { 135 | if(typeof(callback) !== "undefined") { 136 | this.callback = callback; 137 | } 138 | this.requestData(this.base_json_path, this.loadSheets); 139 | }, 140 | 141 | /* 142 | This will call the environment appropriate request method. 143 | 144 | In browser it will use JSON-P, in node it will use request() 145 | */ 146 | requestData: function(path, callback) { 147 | if (inNodeJS) { 148 | this.serverSideFetch(path, callback); 149 | } else { 150 | //CORS only works in IE8/9 across the same protocol 151 | //You must have your server on HTTPS to talk to Google, or it'll fall back on injection 152 | var protocol = this.endpoint.split("//").shift() || "http"; 153 | if (supportsCORS && (!inLegacyIE || protocol === location.protocol)) { 154 | this.xhrFetch(path, callback); 155 | } else { 156 | this.injectScript(path, callback); 157 | } 158 | } 159 | }, 160 | 161 | /* 162 | Use Cross-Origin XMLHttpRequest to get the data in browsers that support it. 163 | */ 164 | xhrFetch: function(path, callback) { 165 | //support IE8's separate cross-domain object 166 | var xhr = inLegacyIE ? new XDomainRequest() : new XMLHttpRequest(); 167 | xhr.open("GET", this.endpoint + path); 168 | var self = this; 169 | xhr.onload = function() { 170 | try { 171 | var json = JSON.parse(xhr.responseText); 172 | } catch (e) { 173 | console.error(e); 174 | } 175 | callback.call(self, json); 176 | }; 177 | xhr.send(); 178 | }, 179 | 180 | /* 181 | Insert the URL into the page as a script tag. Once it's loaded the spreadsheet data 182 | it triggers the callback. This helps you avoid cross-domain errors 183 | http://code.google.com/apis/gdata/samples/spreadsheet_sample.html 184 | 185 | Let's be plain-Jane and not use jQuery or anything. 186 | */ 187 | injectScript: function(path, callback) { 188 | var script = document.createElement('script'); 189 | var callbackName; 190 | 191 | if(this.singleton) { 192 | if(callback === this.loadSheets) { 193 | callbackName = 'Tabletop.singleton.loadSheets'; 194 | } else if (callback === this.loadSheet) { 195 | callbackName = 'Tabletop.singleton.loadSheet'; 196 | } 197 | } else { 198 | var self = this; 199 | callbackName = 'tt' + (+new Date()) + (Math.floor(Math.random()*100000)); 200 | // Create a temp callback which will get removed once it has executed, 201 | // this allows multiple instances of Tabletop to coexist. 202 | Tabletop.callbacks[ callbackName ] = function () { 203 | var args = Array.prototype.slice.call( arguments, 0 ); 204 | callback.apply(self, args); 205 | script.parentNode.removeChild(script); 206 | delete Tabletop.callbacks[callbackName]; 207 | }; 208 | callbackName = 'Tabletop.callbacks.' + callbackName; 209 | } 210 | 211 | var url = path + "&callback=" + callbackName; 212 | 213 | if(this.simple_url) { 214 | // We've gone down a rabbit hole of passing injectScript the path, so let's 215 | // just pull the sheet_id out of the path like the least efficient worker bees 216 | if(path.indexOf("/list/") !== -1) { 217 | script.src = this.endpoint + "/" + this.key + "-" + path.split("/")[4]; 218 | } else { 219 | script.src = this.endpoint + "/" + this.key; 220 | } 221 | } else { 222 | script.src = this.endpoint + url; 223 | } 224 | 225 | if (this.parameterize) { 226 | script.src = this.parameterize + encodeURIComponent(script.src); 227 | } 228 | 229 | document.getElementsByTagName('script')[0].parentNode.appendChild(script); 230 | }, 231 | 232 | /* 233 | This will only run if tabletop is being run in node.js 234 | */ 235 | serverSideFetch: function(path, callback) { 236 | var self = this 237 | request({url: this.endpoint + path, json: true}, function(err, resp, body) { 238 | if (err) { 239 | return console.error(err); 240 | } 241 | callback.call(self, body); 242 | }); 243 | }, 244 | 245 | /* 246 | Is this a sheet you want to pull? 247 | If { wanted: ["Sheet1"] } has been specified, only Sheet1 is imported 248 | Pulls all sheets if none are specified 249 | */ 250 | isWanted: function(sheetName) { 251 | if(this.wanted.length === 0) { 252 | return true; 253 | } else { 254 | return (ttIndexOf(this.wanted, sheetName) !== -1); 255 | } 256 | }, 257 | 258 | /* 259 | What gets send to the callback 260 | if simpleSheet === true, then don't return an array of Tabletop.this.models, 261 | only return the first one's elements 262 | */ 263 | data: function() { 264 | // If the instance is being queried before the data's been fetched 265 | // then return undefined. 266 | if(this.model_names.length === 0) { 267 | return undefined; 268 | } 269 | if(this.simpleSheet) { 270 | if(this.model_names.length > 1 && this.debug) { 271 | this.log("WARNING You have more than one sheet but are using simple sheet mode! Don't blame me when something goes wrong."); 272 | } 273 | return this.models[ this.model_names[0] ].all(); 274 | } else { 275 | return this.models; 276 | } 277 | }, 278 | 279 | /* 280 | Add another sheet to the wanted list 281 | */ 282 | addWanted: function(sheet) { 283 | if(ttIndexOf(this.wanted, sheet) === -1) { 284 | this.wanted.push(sheet); 285 | } 286 | }, 287 | 288 | /* 289 | Load all worksheets of the spreadsheet, turning each into a Tabletop Model. 290 | Need to use injectScript because the worksheet view that you're working from 291 | doesn't actually include the data. The list-based feed (/feeds/list/key..) does, though. 292 | Calls back to loadSheet in order to get the real work done. 293 | 294 | Used as a callback for the worksheet-based JSON 295 | */ 296 | loadSheets: function(data) { 297 | var i, ilen; 298 | var toLoad = []; 299 | this.foundSheetNames = []; 300 | 301 | for(i = 0, ilen = data.feed.entry.length; i < ilen ; i++) { 302 | this.foundSheetNames.push(data.feed.entry[i].title.$t); 303 | // Only pull in desired sheets to reduce loading 304 | if( this.isWanted(data.feed.entry[i].content.$t) ) { 305 | var sheet_id = data.feed.entry[i].link[3].href.substr( data.feed.entry[i].link[3].href.length - 3, 3); 306 | var json_path = "/feeds/list/" + this.key + "/" + sheet_id + "/public/values?sq=" + this.query + '&alt=' 307 | if (inNodeJS || supportsCORS) { 308 | json_path += 'json'; 309 | } else { 310 | json_path += 'json-in-script'; 311 | } 312 | if(this.orderby) { 313 | json_path += "&orderby=column:" + this.orderby.toLowerCase(); 314 | } 315 | if(this.reverse) { 316 | json_path += "&reverse=true"; 317 | } 318 | toLoad.push(json_path); 319 | } 320 | } 321 | 322 | this.sheetsToLoad = toLoad.length; 323 | for(i = 0, ilen = toLoad.length; i < ilen; i++) { 324 | this.requestData(toLoad[i], this.loadSheet); 325 | } 326 | }, 327 | 328 | /* 329 | Access layer for the this.models 330 | .sheets() gets you all of the sheets 331 | .sheets('Sheet1') gets you the sheet named Sheet1 332 | */ 333 | sheets: function(sheetName) { 334 | if(typeof sheetName === "undefined") { 335 | return this.models; 336 | } else { 337 | if(typeof(this.models[ sheetName ]) === "undefined") { 338 | // alert( "Can't find " + sheetName ); 339 | return; 340 | } else { 341 | return this.models[ sheetName ]; 342 | } 343 | } 344 | }, 345 | 346 | /* 347 | Parse a single list-based worksheet, turning it into a Tabletop Model 348 | 349 | Used as a callback for the list-based JSON 350 | */ 351 | loadSheet: function(data) { 352 | var model = new Tabletop.Model( { data: data, 353 | parseNumbers: this.parseNumbers, 354 | postProcess: this.postProcess, 355 | tabletop: this } ); 356 | this.models[ model.name ] = model; 357 | if(ttIndexOf(this.model_names, model.name) === -1) { 358 | this.model_names.push(model.name); 359 | } 360 | this.sheetsToLoad--; 361 | if(this.sheetsToLoad === 0) 362 | this.doCallback(); 363 | }, 364 | 365 | /* 366 | Execute the callback upon loading! Rely on this.data() because you might 367 | only request certain pieces of data (i.e. simpleSheet mode) 368 | Tests this.sheetsToLoad just in case a race condition happens to show up 369 | */ 370 | doCallback: function() { 371 | if(this.sheetsToLoad === 0) { 372 | this.callback.apply(this.callbackContext || this, [this.data(), this]); 373 | } 374 | }, 375 | 376 | log: function(msg) { 377 | if(this.debug) { 378 | if(typeof console !== "undefined" && typeof console.log !== "undefined") { 379 | Function.prototype.apply.apply(console.log, [console, arguments]); 380 | } 381 | } 382 | } 383 | 384 | }; 385 | 386 | /* 387 | Tabletop.Model stores the attribute names and parses the worksheet data 388 | to turn it into something worthwhile 389 | 390 | Options should be in the format { data: XXX }, with XXX being the list-based worksheet 391 | */ 392 | Tabletop.Model = function(options) { 393 | var i, j, ilen, jlen; 394 | this.column_names = []; 395 | this.name = options.data.feed.title.$t; 396 | this.elements = []; 397 | this.raw = options.data; // A copy of the sheet's raw data, for accessing minutiae 398 | 399 | if(typeof(options.data.feed.entry) === 'undefined') { 400 | options.tabletop.log("Missing data for " + this.name + ", make sure you didn't forget column headers"); 401 | this.elements = []; 402 | return; 403 | } 404 | 405 | for(var key in options.data.feed.entry[0]){ 406 | if(/^gsx/.test(key)) 407 | this.column_names.push( key.replace("gsx$","") ); 408 | } 409 | 410 | for(i = 0, ilen = options.data.feed.entry.length ; i < ilen; i++) { 411 | var source = options.data.feed.entry[i]; 412 | var element = {}; 413 | for(var j = 0, jlen = this.column_names.length; j < jlen ; j++) { 414 | var cell = source[ "gsx$" + this.column_names[j] ]; 415 | if (typeof(cell) !== 'undefined') { 416 | if(options.parseNumbers && cell.$t !== '' && !isNaN(cell.$t)) 417 | element[ this.column_names[j] ] = +cell.$t; 418 | else 419 | element[ this.column_names[j] ] = cell.$t; 420 | } else { 421 | element[ this.column_names[j] ] = ''; 422 | } 423 | } 424 | if(element.rowNumber === undefined) 425 | element.rowNumber = i + 1; 426 | if( options.postProcess ) 427 | options.postProcess(element); 428 | this.elements.push(element); 429 | } 430 | 431 | }; 432 | 433 | Tabletop.Model.prototype = { 434 | /* 435 | Returns all of the elements (rows) of the worksheet as objects 436 | */ 437 | all: function() { 438 | return this.elements; 439 | }, 440 | 441 | /* 442 | Return the elements as an array of arrays, instead of an array of objects 443 | */ 444 | toArray: function() { 445 | var array = [], 446 | i, j, ilen, jlen; 447 | for(i = 0, ilen = this.elements.length; i < ilen; i++) { 448 | var row = []; 449 | for(j = 0, jlen = this.column_names.length; j < jlen ; j++) { 450 | row.push( this.elements[i][ this.column_names[j] ] ); 451 | } 452 | array.push(row); 453 | } 454 | return array; 455 | } 456 | }; 457 | 458 | if(inNodeJS) { 459 | module.exports = Tabletop; 460 | } else { 461 | global.Tabletop = Tabletop; 462 | } 463 | 464 | })(this); 465 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplegdocserver", 3 | "version": "0.0.1", 4 | "author": "sheetjs", 5 | "description": "Simple REST Server that fakes Google Docs interface for reading data", 6 | "keywords": [ "xls", "xlsx", "xlsb", "xlsm", "excel", "spreadsheet", "google-docs", "gdata" ], 7 | "bin": "./server.js", 8 | "license": "Apache-2.0", 9 | "repository": { "type":"git", "url":"https://github.com/sheetjs/sgds" }, 10 | "dependencies": { 11 | "j": "~0.2.6", 12 | "browserver-router": "~0.1.1", 13 | "cors": "~2.1.1", 14 | "connect": "~2.12.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* server.js (C) 2014 SheetJS -- http://sheetjs.com */ 3 | /* vim: set ts=2: */ 4 | /* setup: npm install j connect cors browserver-router 5 | start: node server.js [port [base_directory]] 6 | will serve xls{,x,b,m} files from the base_directory using standard connect 7 | 8 | use with tabletop: 9 | Tabletop.init( { 10 | endpoint:"http://localhost:7263", // <-- adjust accordingly 11 | key: "myfile.xls", // <-- the actual filename 12 | ... 13 | }); 14 | */ 15 | 16 | var http = require('http'), 17 | J = require('j'), 18 | connect = require('connect'), 19 | cors = require('cors'), 20 | Router = require('browserver-router'), 21 | path = require('path'); 22 | 23 | var port = Number(process.argv[2]||0) || process.env.PORT || 7263; 24 | var cwd = process.argv[3] || process.env.BASE_DIR || process.cwd(); 25 | 26 | var diropts = { 27 | icons: true, 28 | filter: function(name) { 29 | return name.match(/\.xls[xbm]?$/); 30 | } 31 | } 32 | 33 | var tt_entries = function(req, res) { 34 | var args = req.params[0].split("~"); 35 | var w = J.readFile(path.join(cwd,args[0])); 36 | var out = JSON.stringify({ 37 | feed: { 38 | entry: w[1].SheetNames.map(function(ss,i) { return { 39 | title:{"$t":ss}, 40 | content:{"$t":""}, 41 | link:[ 42 | {}, 43 | {}, 44 | {}, 45 | {href:"000"+String(i)} 46 | ] 47 | };}) 48 | } 49 | }) 50 | if(req.query && req.query.alt == 'json-in-script') { 51 | out = req.query.callback + '(JSON.parse(' + JSON.stringify(out) + '));' 52 | } 53 | res.end(out); 54 | }; 55 | 56 | var tt_data = function(req, res) { 57 | var name = req.params[0], sidx = req.params[1]; 58 | var w = J.readFile(path.join(cwd,name)); 59 | var s = w[1].SheetNames[Number(sidx)]; 60 | var o = J.utils.to_json(w, name, s); 61 | if(s) o = o[s]; 62 | o.forEach(function(r) { 63 | Object.keys(r).forEach(function(k) { 64 | r["gsx$"+k] = {"$t":r[k]}; delete r[k]; 65 | }); 66 | }); 67 | var out = JSON.stringify({feed:{entry:o, title:{"$t":s}}}); 68 | if(req.query && req.query.alt == 'json-in-script') { 69 | out = req.query.callback + '(JSON.parse(' + JSON.stringify(out) + '));' 70 | } 71 | res.writeHead(200, {'Content-Type':'application/javascript'}); 72 | res.end(out); 73 | }; 74 | 75 | var router = Router({ 76 | '/feeds/worksheets/:file/public/basic': tt_entries, 77 | '/feeds/list/:file/:idx/public/values': tt_data, 78 | }); 79 | 80 | var app = connect() 81 | .use(cors()) 82 | .use(connect.logger()) 83 | .use(connect.bodyParser()) 84 | .use(connect.query()) 85 | .use(router) 86 | .use(connect.directory(cwd, diropts)); 87 | 88 | http.createServer(app).listen(port); 89 | --------------------------------------------------------------------------------