├── db └── .gitkeep ├── po └── README ├── locale ├── README.md ├── compile-msg-json.sh ├── en_US │ ├── messages.json │ └── messages.js └── zh_TW │ └── messages.json ├── Procfile ├── test ├── frontend │ ├── config.js │ ├── test_model.js │ ├── index.html │ ├── test_util.js │ └── qunit │ │ └── qunit.css ├── fixtures │ └── db │ │ ├── anon │ │ └── data.json │ │ └── tester │ │ ├── data.json │ │ └── napoleon │ │ └── datapackage.json ├── base.js ├── authz.test.js ├── util.test.js ├── config.test.js ├── dao.test.js ├── logic.test.js └── app.test.js ├── public ├── css │ ├── drive20.png │ └── style.css ├── img │ └── destroy.png ├── js │ ├── dashboard.js │ ├── create.js │ ├── backend.gdocs.js │ └── view.js └── vendor │ ├── leaflet.label │ ├── leaflet.label.css │ └── leaflet.label.js │ └── backbone │ └── 0.5.1 │ └── backbone-localstorage.js ├── .travis.yml ├── .gitmodules ├── .gitignore ├── settings.json.tmpl ├── run.js ├── package.json ├── lib ├── authz.js ├── config.js ├── logic.js ├── util.js └── dao.js ├── views ├── dataview │ ├── edit.html │ ├── form.html │ ├── timemap.html │ └── create.html ├── dashboard.html ├── account │ └── view.html ├── index.html └── base.html ├── LICENSE ├── routes ├── api.js └── index.js ├── app.js └── README.md /db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /po/README: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /locale/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node run.js 2 | 3 | -------------------------------------------------------------------------------- /test/frontend/config.js: -------------------------------------------------------------------------------- 1 | // Configuration for tests 2 | 3 | -------------------------------------------------------------------------------- /public/css/drive20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okfn/timemapper/HEAD/public/css/drive20.png -------------------------------------------------------------------------------- /public/img/destroy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okfn/timemapper/HEAD/public/img/destroy.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | script: 2 | - "test npm run test" 3 | 4 | language: node_js 5 | 6 | node_js: 7 | - 0.10 8 | 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | # [submodule "public/vendor/recline"] 2 | # path = public/vendor/recline 3 | # url = git://github.com/okfn/recline.git 4 | -------------------------------------------------------------------------------- /test/fixtures/db/anon/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "anon", 3 | "fullname": "Anonmyous", 4 | "_created": "2013-07-26T07:00:00" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/db/tester/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "tester", 3 | "fullname": "The Tester", 4 | "_created": "2013-07-26T07:00:00" 5 | } 6 | -------------------------------------------------------------------------------- /locale/compile-msg-json.sh: -------------------------------------------------------------------------------- 1 | find locale -name "*.js" | xargs rm 2 | find locale -name "*.json" | xargs rm 3 | ./node_modules/i18n-abide/bin/compile-json po locale 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/* 3 | .*.swp 4 | sandbox/* 5 | app.cfg 6 | node_modules 7 | # nodemon 8 | .monitor 9 | settings.json* 10 | .env 11 | test/tmpdb/* 12 | db/* 13 | test/db 14 | -------------------------------------------------------------------------------- /settings.json.tmpl: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "host": "localhost" 4 | , "port": 9200 5 | , "name": "hypernotes" 6 | , "backend": "fs" 7 | } 8 | , "express": { 9 | "port": 3000 10 | , "secret": "your session secret" 11 | } 12 | , "twitter": { 13 | "key": "twitter key" 14 | , "secret": "twitter secret" 15 | , "url": "twitter url" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | var logic = require('./lib/logic') 2 | , config = require('./lib/config.js') 3 | , app = require('./app').app 4 | ; 5 | 6 | // ====================================== 7 | // Boot the Server 8 | // ====================================== 9 | 10 | logic.ensureAnonAccountExists(function(err) { 11 | if (err) { 12 | console.error(err); 13 | throw err; 14 | } 15 | app.listen(config.get('express:port'), function() { 16 | console.log("Express server listening on port " + config.get('express:port') + " in mode " + app.get('env')); 17 | }); 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /public/js/dashboard.js: -------------------------------------------------------------------------------- 1 | jQuery(function($) { 2 | // handle deletion nicely 3 | $('.js-delete').on('click', function(e) { 4 | e.preventDefault(); 5 | var $a = $(e.target) 6 | , $view = $a.closest('li.view-summary') 7 | , owner = $a.data('owner') 8 | , name = $a.data('name') 9 | , url = '/api/dataview/' + owner + '/' + name 10 | ; 11 | $.ajax({ 12 | url: url, 13 | type: 'DELETE', 14 | beforeSend: function() { 15 | $view.animate({'backgroundColor':'#fb6c6c'},300); 16 | }, 17 | success: function() { 18 | // remove element from dom 19 | $view.slideUp(300, function() { 20 | $view.remove(); 21 | }); 22 | }, 23 | error: function(err) { 24 | alert(err); 25 | } 26 | }); 27 | }); 28 | }); 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hypernotes" 3 | , "version": "0.2.0" 4 | , "author": "@OKFN" 5 | , "license": "MIT" 6 | , "dependencies": { 7 | "express": "~3.2.6" 8 | , "nunjucks": "~0.1.8" 9 | , "i18n-abide": "" 10 | , "nconf": "0.7.1" 11 | , "passport": "" 12 | , "passport-twitter": "" 13 | , "knox": "" 14 | , "underscore": "" 15 | , "async": "" 16 | } 17 | , "devDependencies": { 18 | "supertest": "" 19 | , "mocha": "" 20 | , "wrench": "" 21 | } 22 | , "scripts": { 23 | "test": "./node_modules/.bin/mocha", 24 | "gen-pot": "./node_modules/.bin/extract-pot -l po -t jinja -f html views", 25 | "merge-po": "./node_modules/i18n-abide/bin/merge-po.sh po", 26 | "update-po": "npm run gen-pot && npm run merge-po", 27 | "gen-po-json": "./locale/compile-msg-json.sh" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/base.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | , fs = require('fs') 3 | , dao = require('../lib/dao.js') 4 | , wrench = require('wrench') 5 | ; 6 | 7 | var fixtures = path.join(__dirname, 'fixtures', 'db'); 8 | var testDir = path.join('test', 'db'); 9 | // dao.config.set('database:backend', 's3'); 10 | // fs option 11 | if (dao.config.get('database:backend') === 'fs') { 12 | testDir = path.join(__dirname, 'tmpdb'); 13 | } 14 | dao.config.set('database:path', testDir); 15 | 16 | // TODO: support for s3 17 | exports.setupDb = function() { 18 | wrench.mkdirSyncRecursive(testDir); 19 | wrench.copyDirSyncRecursive(fixtures, testDir, { forceDelete: true }); 20 | } 21 | 22 | exports.resetDb = function() { 23 | exports.cleanDb(); 24 | exports.setupDb(); 25 | } 26 | 27 | exports.cleanDb = function() { 28 | if (fs.existsSync(testDir)) { 29 | wrench.rmdirSyncRecursive(testDir); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /public/vendor/leaflet.label/leaflet.label.css: -------------------------------------------------------------------------------- 1 | .leaflet-label { 2 | background: rgb(235, 235, 235); 3 | background: rgba(235, 235, 235, 0.81); 4 | background-clip: padding-box; 5 | border-color: #777; 6 | border-color: rgba(0,0,0,0.25); 7 | border-radius: 4px; 8 | border-style: solid; 9 | border-width: 4px; 10 | color: #111; 11 | display: block; 12 | font: 12px/20px "Helvetica Neue", Arial, Helvetica, sans-serif; 13 | font-weight: bold; 14 | padding: 1px 6px; 15 | position: absolute; 16 | -webkit-user-select: none; 17 | -moz-user-select: none; 18 | -ms-user-select: none; 19 | user-select: none; 20 | white-space: nowrap; 21 | z-index: 6; 22 | } 23 | 24 | .leaflet-label:before { 25 | border-right: 6px solid black; 26 | border-right-color: inherit; 27 | border-top: 6px solid transparent; 28 | border-bottom: 6px solid transparent; 29 | content: ""; 30 | position: absolute; 31 | top: 5px; 32 | left: -10px; 33 | } -------------------------------------------------------------------------------- /lib/authz.js: -------------------------------------------------------------------------------- 1 | var AUTHORIZATION = { 2 | 'account': { 3 | anonymous: ['create', 'read'] 4 | , user: ['read'] 5 | , owner: ['read', 'update', 'delete'] 6 | } 7 | ,'dataview': { 8 | anonymous: ['read', 'create'] 9 | , user: ['read'] 10 | , owner: ['create', 'read', 'update', 'delete'] 11 | } 12 | }; 13 | 14 | exports.isAuthorized = function(accountId, action, object) { 15 | if (accountId instanceof Object) { 16 | accountId = accountId.id; 17 | } 18 | var accountRole = ''; 19 | if (accountId === null || accountId === 'anon') { 20 | accountRole = 'anonymous'; 21 | } else if ( 22 | (object.__type__ === 'account' && object.id === accountId) 23 | || 24 | (accountId === object.get('owner')) 25 | ) { 26 | accountRole = 'owner'; 27 | } else { 28 | accountRole = 'user'; 29 | } 30 | var section = AUTHORIZATION[object.__type__][accountRole]; 31 | if (section.indexOf(action) != -1) { 32 | return true; 33 | } else { 34 | return false; 35 | } 36 | }; 37 | 38 | -------------------------------------------------------------------------------- /test/frontend/test_model.js: -------------------------------------------------------------------------------- 1 | module("Model"); 2 | 3 | test("Create note and note list", function () { 4 | var indata = { 5 | title: 'My New Note', 6 | body: '## Xyz', 7 | tags: ['abc', 'efg'] 8 | }; 9 | var note = new HyperNotes.Model.Note(indata); 10 | equals(note.get('title'), indata.title); 11 | 12 | // test we can persist 13 | note.save(); 14 | var outnote = new HyperNotes.Model.Note({id: note.id}); 15 | equals(outnote.get('title'), ''); 16 | outnote.fetch(); 17 | // TODO: reinstate once have stub backend 18 | // equals(outnote.get('title'), indata.title); 19 | 20 | // test collection 21 | indata2 = { 22 | title: 'My New Note 2' 23 | } 24 | var notelist = new HyperNotes.Model.NoteList(); 25 | notelist.add([note]); 26 | equals(notelist.length, 1); 27 | }); 28 | 29 | test("createNoteFromSummary", function () { 30 | var note = null; 31 | HyperNotes.Model.createNoteFromSummary('^1st August 1914^', function(out) { 32 | note = out; 33 | }); 34 | console.log(note); 35 | equals(note.get('start_parsed'), '1914-08-01'); 36 | }); 37 | -------------------------------------------------------------------------------- /views/dataview/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block bodyclass %}edit{% endblock %} 4 | 5 | {% block title %}{{ gettext('Edit - ') }}{{dataview.title}}{% endblock %} 6 | 7 | {% block content %} 8 | 21 | {% endblock %} 22 | 23 | {% block extrabody %} 24 | 25 | 26 | 27 | 28 | 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2007-2014 Open Knowledge Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/frontend/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |

Tests

24 |

25 |

26 |
    27 | 28 | 29 | -------------------------------------------------------------------------------- /views/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ gettext('Dashboard') }}{% endblock %} 4 | {% block bodyclass %}dashboard{% endblock %} 5 | 6 | {% block content %} 7 | 10 | 11 | 16 | 17 |

    {{ gettext('Your Existing TimeMaps') }}

    18 | 35 | 36 | {% endblock %} 37 | 38 | {% block extrabody %} 39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /views/account/view.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} 4 | {{account.id}} / {{account.fullname}} 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 15 | 16 |
    17 |
    18 | 29 | 34 |
    35 | 36 |
    37 | 49 |
    50 |
    51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /test/authz.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , authz = require('../lib/authz.js') 3 | , dao = require('../lib/dao.js') 4 | ; 5 | 6 | describe('Authz', function() { 7 | it('isAuthorized correctly', function() { 8 | var note = dao.DataView.create({ 9 | 'name': 'xyz', 10 | 'owner': 'joe' 11 | }); 12 | assert.equal(authz.isAuthorized('joe', 'update', note), true); 13 | assert.equal(authz.isAuthorized(null, 'read', note), true); 14 | 15 | var account = dao.Account.create({ 16 | 'id': 'joe' 17 | }); 18 | assert.equal(authz.isAuthorized(null, 'create', account), true); 19 | assert.equal(authz.isAuthorized('joe', 'update', account), true); 20 | assert.equal(authz.isAuthorized(null, 'update', account), false); 21 | assert.equal(authz.isAuthorized(null, 'read', account), true); 22 | }); 23 | 24 | var note = dao.DataView.create({ 25 | 'name': 'xyz', 26 | 'owner': 'anon' 27 | }); 28 | it('isAuthorized anon correctly', function() { 29 | assert(authz.isAuthorized(null, 'create', note), 'anon can create view'); 30 | assert(authz.isAuthorized('anon', 'create', note), 'anon can create view'); 31 | 32 | assert(authz.isAuthorized('anon', 'read', note), 'anon can read'); 33 | 34 | assert(!authz.isAuthorized(null, 'update', note), 'anon cannot update'); 35 | assert(!authz.isAuthorized('anon', 'update', note), 'anon cannot update view'); 36 | 37 | assert(!authz.isAuthorized(null, 'delete', note), 'anon cannot delete view'); 38 | assert(!authz.isAuthorized('anon', 'delete', note), 'anon cannot delete view'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , util = require('../lib/util.js') 3 | ; 4 | 5 | describe('util', function() { 6 | it('distanceOfTimeInWords works', function() { 7 | var from = new Date('2011-08-01'); 8 | var to = new Date('2011-09-01'); 9 | var out = util.distanceOfTimeInWords(from, to); 10 | assert.equal(out, '1 month ago'); 11 | }); 12 | it('makeDefaultConfig works', function() { 13 | dfg = util.makeDefaultConfig({'BACKEND': 's3'}); 14 | assert.deepEqual(dfg, {'backend': 's3'}); 15 | 16 | dfg = util.makeDefaultConfig({'TWITTER_KEY': 'twkey'}); 17 | assert.deepEqual(dfg, {'twitter':{'key': 'twkey'}}); 18 | 19 | dfg = util.makeDefaultConfig({'TWITTER_KEY': 'twkey', 20 | 'TWITTER_SECURITY': 'twsec'}); 21 | assert.deepEqual(dfg, {'twitter':{'key': 'twkey', 22 | 'security': 'twsec'}}); 23 | 24 | dfg = util.makeDefaultConfig({'BACKEND': 's3', 25 | 'TWITTER_KEY': 'twkey', 26 | 'TWITTER_SECURITY': 'twsec'}); 27 | 28 | assert.deepEqual(dfg, {'backend': 's3', 29 | 'twitter':{'key': 'twkey', 30 | 'security': 'twsec'}}); 31 | 32 | // test for config by environment var. 33 | process.env.BACKEND = 'fs'; 34 | process.env.TWITTER_KEY = 'yooo'; 35 | dfg = util.makeDefaultConfig({'BACKEND': 's3', 36 | 'TWITTER_KEY': 'twkey'}); 37 | assert.deepEqual(dfg, {'backend': 'fs', 38 | 'twitter': {'key': 'yooo'}}); 39 | }); 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var nconf = require('nconf'); 3 | var path = require('path'); 4 | var util = require('../lib/util.js'); 5 | 6 | nconf.argv() 7 | .env() 8 | .file({file: path.join( 9 | path.dirname(__dirname), '/settings.json') 10 | }) 11 | 12 | // commenting this out as not working on heroku I think 13 | // in particular heroku sets port variable in PORT var not EXPRESS_PORT 14 | // nconf.defaults(util.makeDefaultConfig({ 15 | // 'DATABASE_PATH': 'db', 16 | // 'DATABASE_BACKEND': 's3', 17 | // 'EXPRESS_SECRET': "a random session secret", 18 | // 'EXPRESS_PORT': 5000, 19 | // 'TWITTER_KEY' : null, 20 | // 'TWITTER_SECRET': null, 21 | // 'TWITTER_CALLBACK': "http://timemapper.okfnlabs.org/account/auth/twitter/callback", 22 | // 'S3_KEY': null, 23 | // 'S3_SECRET': null, 24 | // 'S3_BUCKET': "timemapper-data.okfnlabs.org", 25 | // 'TEST_TESTING': false, 26 | // 'TEST_USER': 'tester' 27 | // })); 28 | 29 | nconf.defaults({ 30 | "database": { 31 | // WARNING: for s3 this must *not* have a leading '/' and *should* have a 32 | // trailing slash 33 | "path": process.env.DB_PATH || "db", 34 | // s3 or fs 35 | "backend": process.env.BACKEND || "s3" 36 | } 37 | , "express": { 38 | "secret": "a random session secret" 39 | , "port": process.env.PORT || 5000 40 | } 41 | , "twitter": { 42 | "key": process.env.TWITTER_KEY, 43 | "secret": process.env.TWITTER_SECRET, 44 | "url": process.env.TWITTER_CALLBACK || "http://timemapper.okfnlabs.org/account/auth/twitter/callback" 45 | } 46 | , "s3": { 47 | "key": process.env.S3_KEY, 48 | "secret": process.env.S3_SECRET, 49 | "bucket": process.env.S3_BUCKET || "timemapper-data.okfnlabs.org" 50 | } 51 | // config for testing mode 52 | , "test": { 53 | "testing": "false" 54 | // test user to use 55 | , "user": "tester" 56 | } 57 | }); 58 | 59 | module.exports = nconf; 60 | 61 | -------------------------------------------------------------------------------- /test/frontend/test_util.js: -------------------------------------------------------------------------------- 1 | module("Util"); 2 | 3 | var utils = HyperNotes.Util; 4 | 5 | test("Parse note summary", function () { 6 | var _data = [ 7 | { 8 | input: "A test note", 9 | output: { 10 | title: "A test note", 11 | tags: [] 12 | } 13 | } 14 | , { 15 | input: "A test note #abc", 16 | output: { 17 | title: "A test note", 18 | tags: ['abc'] 19 | } 20 | } 21 | , { 22 | input: "A test note #abc #xyz", 23 | output: { 24 | title: "A test note", 25 | tags: ['abc', 'xyz'] 26 | } 27 | } 28 | , { 29 | input: "A test note #abc @London@", 30 | output: { 31 | title: "A test note", 32 | tags: ['abc'], 33 | location: { 34 | unparsed: 'London' 35 | } 36 | } 37 | } 38 | , { 39 | input: "@London@ #abc A test note", 40 | output: { 41 | title: "A test note", 42 | tags: ['abc'], 43 | location: { 44 | unparsed: 'London' 45 | } 46 | } 47 | } 48 | , { 49 | input: "A test note #abc ^1st January 1900^ ^1st January 2010^", 50 | output: { 51 | title: "A test note", 52 | tags: ['abc'], 53 | start: '1st January 1900', 54 | end: '1st January 2010' 55 | } 56 | } 57 | ]; 58 | for(idx in _data) { 59 | var _exp = _data[idx].output; 60 | var _out = utils.parseNoteSummary(_data[idx].input); 61 | same(_out, _exp); 62 | } 63 | }); 64 | 65 | test("parseDate ", function () { 66 | var _data = [ 67 | { 68 | input: '1st September 1914', 69 | output: '1914-09-01' 70 | } 71 | ]; 72 | for(idx in _data) { 73 | var _exp = _data[idx].output; 74 | var _out = utils.parseDate(_data[idx].input); 75 | equals(_out, _exp); 76 | } 77 | }); 78 | 79 | test('lookupLocation', function() { 80 | // Pause the test 81 | stop(); 82 | 83 | utils.lookupLocation('London, UK', function(data) { 84 | equals(data.geonameId, 2643743); 85 | }) 86 | 87 | utils.lookupLocation('London, Canada', function(data) { 88 | equals(data.geonameId, 6058560); 89 | }) 90 | 91 | setTimeout(function() { 92 | start(); 93 | }, 1000); 94 | }); 95 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | var config = require('../lib/config.js') 2 | , dao = require('../lib/dao.js') 3 | , logic = require('../lib/logic.js') 4 | , util = require('../lib/util.js') 5 | , authz = require('../lib/authz.js') 6 | ; 7 | 8 | exports.getAccount = function(req, res) { 9 | var obj = dao.Account.create({id: req.params.id}); 10 | logic.getObject(obj, req.user, function(err, domainObj) { 11 | if(err) { 12 | res.json(err.message, err.code); 13 | } else { 14 | res.json(domainObj.toJSON()); 15 | } 16 | }); 17 | }; 18 | 19 | exports.getDataView = function(req, res) { 20 | var dataViewInfo = { 21 | owner: req.params.owner, 22 | name: req.params.name 23 | }; 24 | logic.getDataView(dataViewInfo, req.user, function(err, dataViewObj) { 25 | if(err) { 26 | res.json(err.message, err.code); 27 | } else { 28 | res.json(dataViewObj.toJSON()); 29 | } 30 | }); 31 | }; 32 | 33 | var apiUpsert = function(obj, action, req, res) { 34 | var userId = req.user ? req.user.id : null; 35 | var isAuthz = authz.isAuthorized(userId, action, obj); 36 | if (isAuthz) { 37 | obj.save(function(outData) { 38 | res.json(outData) 39 | }); 40 | } else { 41 | msg = { 42 | error: 'Access not allowed' 43 | , status: 401 44 | }; 45 | res.json(msg, 401); 46 | } 47 | }; 48 | 49 | // app.post('/api/v1/:objecttype', apiUpsert); 50 | exports.createDataView = function(req, res) { 51 | var data = req.body; 52 | var obj = dao.DataView.create(data); 53 | // check whether already exists 54 | obj.fetch(function(err) { 55 | // TODO: we assume error is 404 but could be something else ... 56 | if (!err) { 57 | res.json(409, {message: 'Conflict - Object already exists'}); 58 | } 59 | else { 60 | apiUpsert(obj, 'create', req, res); 61 | } 62 | }); 63 | }; 64 | 65 | exports.updateDataView = function(req, res) { 66 | var data = req.body; 67 | var obj = dao.DataView.create(data); 68 | // TODO: ? check whether it exists? 69 | apiUpsert(obj, 'update', req, res); 70 | }; 71 | 72 | exports.deleteDataView = function(req, res) { 73 | var dataViewInfo = { 74 | owner: req.params.owner, 75 | name: req.params.name 76 | }; 77 | logic.deleteDataView(dataViewInfo, req.user, function(err) { 78 | if (err) { 79 | res.json(err.message, err.code) 80 | } else { 81 | res.json({}); 82 | } 83 | }); 84 | }; 85 | -------------------------------------------------------------------------------- /test/config.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , config; 3 | 4 | describe('config', function() { 5 | afterEach(function(done){ 6 | delete require.cache[require.resolve('../lib/config')] 7 | done(); 8 | }); 9 | it('should use default variable if config.json or env variables are not present.', function(done){ 10 | config = require('../lib/config'); 11 | assert.equal(config.get('database:path'), 'db'); 12 | assert.equal(config.get('database:backend'), 's3'); 13 | assert.equal(config.get('express:secret'), 'a random session secret'); 14 | assert.equal(config.get('express:port'), 5000); 15 | assert.equal(config.get('twitter:key'), undefined); 16 | assert.equal(config.get('twitter:secret'), undefined); 17 | assert.equal(config.get('twitter:callback'), 18 | "http://timemapper.okfnlabs.org/account/auth/twitter/callback"); 19 | assert.equal(config.get('s3:key'), undefined); 20 | assert.equal(config.get('s3:secret'), undefined); 21 | assert.equal(config.get('s3:bucket'), "timemapper-data.okfnlabs.org"); 22 | assert.equal(config.get('test:testing'), false); 23 | assert.equal(config.get('test:user'), 'tester'); 24 | done(); 25 | }); 26 | it('should use environment variable if it is given. .', function(done) { 27 | var bk_process = process 28 | process = {'env': {}} 29 | process.env['DATABASE_PATH'] = 'x' 30 | process.env['DATABASE_BACKEND'] = 'x' 31 | process.env['EXPRESS_PORT'] = 'x' 32 | process.env['EXPRESS_SECRET'] = 'x' 33 | process.env['TWITTER_KEY'] = 'x' 34 | process.env['TWITTER_SECRET'] = 'x' 35 | process.env['TWITTER_CALLBACK'] = 'x' 36 | process.env['S3_KEY'] = 'x' 37 | process.env['S3_SECRET'] = 'x' 38 | process.env['S3_BUCKET'] = 'x' 39 | 40 | config = require('../lib/config'); 41 | assert.equal(config.get('database:path'), 'x'); 42 | assert.equal(config.get('database:backend'), 'x'); 43 | assert.equal(config.get('express:secret'), 'x'); 44 | assert.equal(config.get('express:port'), 'x'); 45 | assert.equal(config.get('twitter:key'), 'x'); 46 | assert.equal(config.get('twitter:secret'), 'x'); 47 | assert.equal(config.get('twitter:callback'), 'x'); 48 | assert.equal(config.get('s3:key'), 'x'); 49 | assert.equal(config.get('s3:secret'), 'x'); 50 | assert.equal(config.get('s3:bucket'), 'x'); 51 | process = bk_process; 52 | done(); 53 | }); 54 | }); 55 | 56 | -------------------------------------------------------------------------------- /public/vendor/backbone/0.5.1/backbone-localstorage.js: -------------------------------------------------------------------------------- 1 | // A simple module to replace `Backbone.sync` with *localStorage*-based 2 | // persistence. Models are given GUIDS, and saved into a JSON object. Simple 3 | // as that. 4 | 5 | // Generate four random hex digits. 6 | function S4() { 7 | return (((1+Math.random())*0x10000)|0).toString(16).substring(1); 8 | }; 9 | 10 | // Generate a pseudo-GUID by concatenating random hexadecimal. 11 | function guid() { 12 | return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); 13 | }; 14 | 15 | // Our Store is represented by a single JS object in *localStorage*. Create it 16 | // with a meaningful name, like the name you'd give a table. 17 | var Store = function(name) { 18 | this.name = name; 19 | var store = localStorage.getItem(this.name); 20 | this.data = (store && JSON.parse(store)) || {}; 21 | }; 22 | 23 | _.extend(Store.prototype, { 24 | 25 | // Save the current state of the **Store** to *localStorage*. 26 | save: function() { 27 | localStorage.setItem(this.name, JSON.stringify(this.data)); 28 | }, 29 | 30 | // Add a model, giving it a (hopefully)-unique GUID, if it doesn't already 31 | // have an id of it's own. 32 | create: function(model) { 33 | if (!model.id) model.id = model.attributes.id = guid(); 34 | this.data[model.id] = model; 35 | this.save(); 36 | return model; 37 | }, 38 | 39 | // Update a model by replacing its copy in `this.data`. 40 | update: function(model) { 41 | this.data[model.id] = model; 42 | this.save(); 43 | return model; 44 | }, 45 | 46 | // Retrieve a model from `this.data` by id. 47 | find: function(model) { 48 | return this.data[model.id]; 49 | }, 50 | 51 | // Return the array of all models currently in storage. 52 | findAll: function() { 53 | return _.values(this.data); 54 | }, 55 | 56 | // Delete a model from `this.data`, returning it. 57 | destroy: function(model) { 58 | delete this.data[model.id]; 59 | this.save(); 60 | return model; 61 | } 62 | 63 | }); 64 | 65 | // Override `Backbone.sync` to use delegate to the model or collection's 66 | // *localStorage* property, which should be an instance of `Store`. 67 | Backbone.sync = function(method, model, options) { 68 | 69 | var resp; 70 | var store = model.localStorage || model.collection.localStorage; 71 | 72 | switch (method) { 73 | case "read": resp = model.id ? store.find(model) : store.findAll(); break; 74 | case "create": resp = store.create(model); break; 75 | case "update": resp = store.update(model); break; 76 | case "delete": resp = store.destroy(model); break; 77 | } 78 | 79 | if (resp) { 80 | options.success(resp); 81 | } else { 82 | options.error("Record not found"); 83 | } 84 | }; -------------------------------------------------------------------------------- /test/dao.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , path = require('path') 3 | , dao = require('../lib/dao.js') 4 | , _ = require('underscore') 5 | // sets up db path 6 | , base = require('./base') 7 | ; 8 | 9 | var indexName = 'hypernotes-test-njs'; 10 | var username = 'tester'; 11 | var threadName = 'my-test-thread'; 12 | var inuser = { 13 | 'id': username, 14 | 'fullname': 'The Tester' 15 | }; 16 | var inthread = { 17 | 'name': threadName 18 | , 'title': 'My Test Thread' 19 | ,'description': 'None at the moment' 20 | , 'owner': username 21 | }; 22 | 23 | describe('DAO Basics', function() { 24 | it('getDomainObjectClass', function(done) { 25 | var out = dao.getDomainObjectClass('account'); 26 | assert.equal(out, dao.Account); 27 | done(); 28 | }); 29 | it('Create Account DomainObject', function() { 30 | var account = dao.Account.create({ 31 | fullname: 'myname' 32 | , email: 'mytest@email.xyz' 33 | }); 34 | assert.equal(account.get('fullname'), 'myname'); 35 | var raw = account.toJSON(); 36 | assert.equal(raw.fullname, 'myname'); 37 | assert.equal(raw.password, undefined, 'password should not be in Account.toJSON'); 38 | assert.equal(raw.email, undefined, 'email should not be in Account.toJSON'); 39 | }); 40 | }); 41 | 42 | describe('DAO Storage', function() { 43 | before(function(done) { 44 | base.resetDb(); 45 | done(); 46 | }); 47 | it('FETCH Account', function(done) { 48 | var acc = dao.Account.create({id: username}); 49 | acc.fetch(function(error, account) { 50 | assert.equal(error, null); 51 | assert.equal(account.id, username, 'username incorrect'); 52 | var res = account.toJSON(); 53 | assert.equal(res.fullname, inuser.fullname); 54 | done(); 55 | }); 56 | }); 57 | it('FETCH DataView', function(done) { 58 | var viz = dao.DataView.create({owner: username, name: 'napoleon'}); 59 | viz.fetch(function(error) { 60 | var res = viz.toJSON(); 61 | assert.equal(res.title, 'Battles in the Napoleonic Wars'); 62 | done(); 63 | }); 64 | }); 65 | it('SAVE Account', function(done) { 66 | var account = dao.Account.create({ 67 | id: 'xyz' 68 | , fullname: 'myname' 69 | , email: 'mytest@email.xyz' 70 | }); 71 | account.save(function(error) { 72 | var _now = new Date().toISOString(); 73 | assert.equal(account.get('_created').slice(0,4), _now.slice(0,4)); 74 | done(); 75 | }); 76 | }); 77 | it('Upsert DataView', function(done) { 78 | var viz = dao.DataView.create(inthread); 79 | viz.upsert(function(error) { 80 | assert(error === null); 81 | done(); 82 | }) 83 | }); 84 | it('List DataView', function(done) { 85 | // tester has at least napoleon as a subdirectory 86 | this.timeout(5000); 87 | var viz = dao.DataView.getByOwner(username, function(err, data) { 88 | assert.equal(err, null); 89 | var names = _.pluck(data, 'name'); 90 | // console.log(data[0]); 91 | assert(names.indexOf('napoleon') != -1, names); 92 | assert.equal(names.indexOf('data.json'), -1, 'data.json should not be in list'); 93 | done(); 94 | }); 95 | }); 96 | }); 97 | 98 | -------------------------------------------------------------------------------- /test/logic.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | , path = require('path') 3 | , logic = require('../lib/logic.js') 4 | , dao = require('../lib/dao.js') 5 | // sets up db path 6 | , base = require('./base') 7 | ; 8 | 9 | describe('getDataView', function() { 10 | before(function(done) { 11 | base.resetDb(); 12 | done(); 13 | }); 14 | 15 | it('works ok', function(done) { 16 | var data = { 17 | owner: 'tester', 18 | name: 'napoleon' 19 | }; 20 | var user = { 21 | id: 'jones' 22 | }; 23 | logic.getDataView(data, user, function(err, out) { 24 | assert(!err, err); 25 | out = out.toJSON(); 26 | assert.equal(out.name, 'napoleon'); 27 | assert.equal(out.title, 'Battles in the Napoleonic Wars'); 28 | done(); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('createDataView', function() { 34 | before(function(done) { 35 | base.resetDb(); 36 | done(); 37 | }); 38 | it('conflict', function(done) { 39 | var data = { 40 | name: 'napoleon', 41 | owner: 'tester' 42 | }; 43 | logic.createDataView(data, {id: 'tester'}, function(err, out) { 44 | assert(err); 45 | assert.equal(err.code, 409); 46 | done(); 47 | }); 48 | }); 49 | 50 | var data = { 51 | name: 'createdataview', 52 | title: 'Create Data View', 53 | owner: 'tester', 54 | url: 'xxxxx' 55 | }; 56 | it('OK', function(done) { 57 | logic.createDataView(data, {id: 'tester'}, function(err, out) { 58 | assert(!err, err); 59 | var view = dao.DataView.create({ 60 | owner: data.owner, 61 | name: data.name 62 | }); 63 | view.fetch(function() { 64 | var lic = view.get('licenses'); 65 | var exp = [{ 66 | type: 'cc-by', 67 | name: 'Creative Commons Attribution', 68 | version: '3.0', 69 | url: 'http://creativecommons.org/licenses/by/3.0/' 70 | }]; 71 | assert.deepEqual(lic, exp); 72 | assert.equal(view.get('title'), data.title); 73 | assert(!view.get('url')); 74 | assert(view.get('resources')[0].url, data.url); 75 | done(); 76 | }); 77 | }); 78 | }); 79 | it('anonymous - OK', function(done) { 80 | var data = { 81 | title: 'Xyz', 82 | url: 'xxxx' 83 | }; 84 | logic.createDataView(data, null, function(err, out) { 85 | var out = out.toJSON(); 86 | assert(!err, err); 87 | assert.equal(out.title, data.title); 88 | assert.equal(out.name.substr(7), data.title.toLowerCase()); 89 | done(); 90 | }); 91 | }); 92 | }); 93 | 94 | describe('upsertDataView', function() { 95 | before(function(done) { 96 | base.resetDb(); 97 | done(); 98 | }); 99 | 100 | var data = { 101 | name: 'createdataview', 102 | title: 'Create Data View', 103 | owner: 'tester' 104 | } 105 | , obj = dao.DataView.create(data) 106 | , user = { id: 'tester' } 107 | , wrongUser = { id: 'wrong' } 108 | 109 | 110 | it('should get 401 with anon', function(done) { 111 | logic.upsertDataView(obj, 'update', null, function(err, out) { 112 | assert(err); 113 | assert.equal(err.code, 401); 114 | done(); 115 | }); 116 | }); 117 | it('should get 401 with wrong user', function(done) { 118 | logic.upsertDataView(obj, 'update', wrongUser, function(err, out) { 119 | assert(err); 120 | assert.equal(err.code, 401); 121 | done(); 122 | }); 123 | }); 124 | it('should work', function(done) { 125 | logic.upsertDataView(obj, 'create', user, function(err, out) { 126 | assert(!err, err); 127 | done(); 128 | }); 129 | }); 130 | }); 131 | 132 | -------------------------------------------------------------------------------- /lib/logic.js: -------------------------------------------------------------------------------- 1 | var authz = require('./authz') 2 | , _ = require('underscore') 3 | , dao = require('./dao') 4 | , util = require('./util') 5 | ; 6 | 7 | // Common aspects of the code 8 | // 9 | // Errors look like 10 | // 11 | // { code: 404, message: ... } 12 | 13 | exports.getDataView = function(data, user, cb) { 14 | var obj = dao.DataView.create(data); 15 | exports.getObject(obj, user, cb); 16 | }; 17 | 18 | // get an object doing all the tedious stuff like checking it exists and that you are authorized to view 19 | exports.getObject = function(obj, user, cb) { 20 | obj.fetch(function(err, domainObj) { 21 | if (err) { 22 | cb(err); 23 | return; 24 | } 25 | if (domainObj===null) { 26 | var err = { 27 | message: 'Cannot find ' + req.params.objecttype + ' with id ' + req.params.id 28 | , code: 404 29 | }; 30 | cb(err); 31 | return; 32 | } 33 | var userId = user ? user.id : null; 34 | var isAuthz = authz.isAuthorized(userId, 'read', domainObj); 35 | if (isAuthz) { 36 | cb(null, domainObj); 37 | } else { 38 | cb({ 39 | message: 'Access not allowed' 40 | , code: 401 41 | }, 42 | null 43 | ); 44 | } 45 | }); 46 | }; 47 | 48 | exports.createDataView = function(data, user, cb) { 49 | var ourdata = _.extend({ 50 | licenses: [{ 51 | type: 'cc-by', 52 | name: 'Creative Commons Attribution', 53 | version: '3.0', 54 | url: 'http://creativecommons.org/licenses/by/3.0/' 55 | }], 56 | resources: [{ 57 | backend: 'gdocs', 58 | url: data.url 59 | }], 60 | } 61 | , data); 62 | if (data.url) { 63 | delete ourdata.url 64 | } 65 | ourdata.owner = user ? user.id : 'anon'; 66 | // generate the name 67 | if (ourdata.owner === 'anon') { 68 | // 6 character random id 69 | var randomId = ("000000" + (Math.random()*Math.pow(36,6) << 0).toString(36)).substr(-6) 70 | ourdata.name = randomId + '-' + util.sluggify(ourdata.title); 71 | } 72 | 73 | var obj = dao.DataView.create(ourdata); 74 | // check whether already exists 75 | obj.fetch(function(err) { 76 | // TODO: we assume error is 404 but could be something else ... 77 | if (!err) { 78 | cb({ 79 | code: 409, 80 | message: 'Conflict - Object already exists' 81 | }); 82 | } 83 | else { 84 | exports.upsertDataView(obj, 'create', user, cb); 85 | } 86 | }); 87 | } 88 | 89 | exports.upsertDataView = function(obj, action, user, cb) { 90 | var userId = user ? user.id : null; 91 | var isAuthz = authz.isAuthorized(userId, action, obj); 92 | if (isAuthz) { 93 | obj.save(cb); 94 | } else { 95 | msg = { 96 | message: 'Access not allowed' 97 | , code: 401 98 | }; 99 | cb(msg); 100 | } 101 | }; 102 | 103 | exports.deleteDataView = function(data, user, cb) { 104 | exports.getDataView(data, user, function(err, domainObj) { 105 | if (err) { 106 | cb(err); 107 | return; 108 | } 109 | var userId = user ? user.id : null; 110 | var isAuthz = authz.isAuthorized(userId, 'delete', domainObj); 111 | if (!isAuthz) { 112 | cb({ 113 | message: 'Access not allowed' 114 | , code: 401 115 | }, 116 | null 117 | ); 118 | return; 119 | } 120 | domainObj.setattr('state', 'deleted'); 121 | domainObj.save(cb); 122 | }); 123 | }; 124 | 125 | exports.ensureAnonAccountExists = function(cb) { 126 | var obj = dao.Account.create({ 127 | id: 'anon', 128 | fullname: 'Anonymous' 129 | }); 130 | // check whether already exists 131 | obj.fetch(function(err) { 132 | if (err) { // does not exist yet 133 | obj.save(cb); 134 | } else { // already exists so ok 135 | cb(null); 136 | } 137 | }); 138 | } 139 | 140 | -------------------------------------------------------------------------------- /test/fixtures/db/tester/napoleon/datapackage.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "napoleon", 3 | "owner" : "tester", 4 | "title" : "Battles in the Napoleonic Wars", 5 | "licenses": [{ 6 | "type": "cc-by-sa", 7 | "name": "Creative Commons Attribution ShareAlike", 8 | "url": "http://creativecommons.org/licenses/by-sa/3.0/" 9 | }], 10 | "resources" : [{ 11 | "name": "default", 12 | "schema": { 13 | "fields": null 14 | }, 15 | "records": [ 16 | { 17 | "id" : "a880106e-ce43-4463-8884-bafe67270aa8", 18 | "title" : "Battle of Austerlitz", 19 | "image" : "http://upload.wikimedia.org/wikipedia/commons/5/56/Austerlitz-baron-Pascal.jpg", 20 | "description" : 21 | "The Battle of Austerlitz (Czech: 'Battle of Slavkov'), also known as the Battle of the Three Emperors, was one of Napoleon's greatest victories, effectively destroying the Third Coalition against the French Empire. On December 2 1805, French troops, commanded by Emperor Napoleon I, decisively defeated a Russo-Austrian army, commanded by Tsar Alexander I, after nearly nine hours", 22 | "source" : "http://en.wikipedia.org/wiki/Battle_of_Austerlitz", 23 | "start" : "1805-12-02", 24 | "location" : {"type": "Point", "coordinates": [16.7622 , 49.1281] }, 25 | "license" : "GFDL" 26 | }, 27 | { 28 | "id" : "04d3d5a3-a2e5-41ab-a71b-306c424b2ccb", 29 | "title" : "Battle of Borodino", 30 | "description" : "The Battle of Borodino (September 7, 1812, or August 26 in the Julian calendar then used in Russia), was the largest and bloodiest single-day battle of the Napoleonic Wars, involving more than a quarter of a million soldiers. It was fought by the French ''Grande Armee'' under Napoleon I and the Imperial Russian army of General Mikhail Kutusov near the village of Borodino, west", 31 | "start" : "1812-09-07", 32 | "source" : "http://en.wikipedia.org/wiki/Battle_of_Borodino", 33 | "location" : {"type": "Point", "coordinates": [35.8212, 55.5085]}, 34 | "license" : "GFDL" 35 | }, 36 | { 37 | "id" : "40432a1e-b08a-413e-b36a-4f281ce06f7d", 38 | "title" : "Battle of Waterloo", 39 | "source" : "http://en.wikipedia.org/wiki/Battle_of_Waterloo", 40 | "description" : "1815, was Napoleon Bonaparte's last battle. His defeat put a final end to his rule as Emperor of France. The Battle of Waterloo also marked the end of the period known as the Hundred Days, which began in March 1815 after Napoleon's return from Elba, where he had been exiled after his defeat at the battle of Leipzig in 1813", 41 | "start" : "1815-06-18", 42 | "location" : {"type": "Point", "coordinates": [4.406 , 50.679] }, 43 | "license" : "GFDL" 44 | }, 45 | { 46 | "id" : "67c0272a-d469-4bc8-8c3e-5fe14c8f3b60", 47 | "title" : "Battle of Trafalgar", 48 | "source" : "http://en.wikipedia.org/wiki/Battle_of_Trafalgar", 49 | "description" : "", 50 | "start" : "1805-10-21", 51 | "location" : {"type": "Point", "coordinates": [-6.25, 36.166] }, 52 | "license" : "GFDL" 53 | }, 54 | { 55 | "id" : "a4728f72-405a-4804-b2c6-9b37ba2e44ed", 56 | "title" : "Battle of Jena-Auerstadt", 57 | "source" : "http://en.wikipedia.org/wiki/Battle_of_Jena-Auerstedt", 58 | "start" : "1806-10-14", 59 | "location" : {"type": "Point", "coordinates": [50.92722, 11.58611] }, 60 | "license" : "GFDL" 61 | }, 62 | { 63 | "id" : "3a1d1201-ea85-4a5b-a59f-b957b42d22fc", 64 | "title" : "Battle of Friedland", 65 | "start" : "1807-06-14", 66 | "description" : "The Battle of Friedland, fought on June 14, 1807 about twenty-seven miles (43 km) southeast of the modern Russian city of Kaliningrad, just north of Poland, was a major engagement in the Napoleonic Wars effectively ending the War of the Fourth Coalition. The conflict involved forces of the First French Empire against the army of the Russian Empire", 67 | "location" : {"type": "Point", "coordinates": [21.0167 , 54.45] }, 68 | "license" : "GFDL" 69 | } 70 | ] 71 | }] 72 | } 73 | 74 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block bodyclass %}home{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |

    8 | TimeMapper 9 | {{ gettext('Elegant timelines and maps created in seconds') }} 10 |

    11 |
    12 |

    13 | 14 | {{ gettext("It's free and easy to use") }} – 15 | {{ gettext("Get started now") }} » 16 | 17 |

    18 |
    19 | 20 |
    21 |

    {{ gettext("Watch the 1 minute Tutorial") }}

    22 | 23 |
    24 | 25 |
    26 |
    27 |

    {{ gettext("Examples") }}

    28 |
    29 | 35 | 41 | 47 |
    48 | 49 | 50 |

    {{ gettext("How It Works") }}

    51 |
    52 |
    53 |
    54 |

    {{ gettext("1. Create a Spreadsheet") }}

    55 |

    {{ gettext("Add your dates and places to a Google Spreadsheet.") }}

    56 |
    57 |
    58 |
    59 |
    60 |

    {{ gettext("2. Connect and Customize") }}

    61 |

    {{ gettext("Connect your spreadsheet with TimeMapper and customize the results.") }}

    62 |
    63 |
    64 |
    65 |
    66 |

    {{ gettext("3. Publish, Embed and Share") }}

    67 |

    {{ gettext("Publish your TimeMap at your own personal url, then share or embed on your site.") }}

    68 |
    69 |
    70 |
    71 | 72 |

    {{ gettext("Credits") }}

    73 |

    74 | {{ gettext('TimeMapper is an open-source project of Open Knowledge Foundation Labs.') }} {{ gettext('It is possible thanks to a set of awesome open-source components including TimelineJS, ReclineJS, Leaflet, Backbone and Bootstrap. You can find the full open-source source for TimeMapper on GitHub here') }}. 75 |

    76 |
    77 | {% endblock %} 78 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | // Convert distance in time between two dates into words (e.g. '1 month ago') 2 | // 3 | // All arguments other than first are optional (if ref_date omitted we automatically computed against present). 4 | // 5 | // Inspired by python webhelpers.distance_of_time_in_words(from_time, to_time=0, granularity='second', round=False) 6 | // 7 | // Directly based (with some minor modifications) on https://github.com/layam/js_humanized_time_span - Copyright (C) 2011 by Will Tomlins. Licensed under MIT license. 8 | exports.distanceOfTimeInWords = function(date, ref_date) { 9 | //Date Formats must be be ordered smallest -> largest and must end in a format with ceiling of null 10 | date_formats = { 11 | past: [ 12 | { ceiling: 60, text: "$seconds seconds ago" }, 13 | { ceiling: 3600, text: "$minutes minutes ago" }, 14 | { ceiling: 86400, text: "$hours hours ago" }, 15 | { ceiling: 2629744, text: "$days days ago" }, 16 | { ceiling: 31556926, text: "$months months ago" }, 17 | { ceiling: null, text: "$years years ago" } 18 | ], 19 | future: [ 20 | { ceiling: 60, text: "in $seconds seconds" }, 21 | { ceiling: 3600, text: "in $minutes minutes" }, 22 | { ceiling: 86400, text: "in $hours hours" }, 23 | { ceiling: 2629744, text: "in $days days" }, 24 | { ceiling: 31556926, text: "in $months months" }, 25 | { ceiling: null, text: "in $years years" } 26 | ] 27 | }; 28 | //Time units must be be ordered largest -> smallest 29 | time_units = [ 30 | [31556926, 'years'], 31 | [2629744, 'months'], 32 | [86400, 'days'], 33 | [3600, 'hours'], 34 | [60, 'minutes'], 35 | [1, 'seconds'] 36 | ]; 37 | 38 | date = new Date(date); 39 | ref_date = ref_date ? new Date(ref_date) : new Date(); 40 | var seconds_difference = (ref_date - date) / 1000; 41 | 42 | var tense = 'past'; 43 | if (seconds_difference < 0) { 44 | tense = 'future'; 45 | seconds_difference = 0-seconds_difference; 46 | } 47 | 48 | function get_format() { 49 | for (var i=0; i 2 | 3 | 4 | 5 | 6 | {% block title%}{{title}}{% endblock %} 7 | - {{ gettext('TimeMapper - Make Timelines and TimeMaps fast!') }} 8 | - {{ gettext('from the Open Knowledge Foundation Labs') }} 9 | 10 | 11 | 12 | 13 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% block extrahead %}{% endblock %} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% block navbar %} 36 |
    37 | 38 |
    39 | 42 | 73 | {% endblock %} 74 | 75 |
    76 |
    77 | 78 | {% block content %}{{content}}{% endblock %} 79 |
    80 |
    81 |
    82 | 83 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 110 | 111 | {% block extrabody %}{% endblock %} 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /public/js/backend.gdocs.js: -------------------------------------------------------------------------------- 1 | var recline = recline || {}; 2 | recline.Backend = recline.Backend || {}; 3 | recline.Backend.GDocs = recline.Backend.GDocs || {}; 4 | 5 | // note module is *defined* in qunit tests :-( 6 | if (typeof module !== 'undefined' && module != null && typeof require !== 'undefined') { 7 | var _ = require('underscore'); 8 | module.exports = recline; 9 | } 10 | 11 | (function(my) { 12 | my.__type__ = 'gdocs'; 13 | 14 | var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred; 15 | 16 | // Fetch data from a Google Docs spreadsheet. 17 | // 18 | // For details of config options and returned values see the README in 19 | // the repo at https://github.com/Recline/backend.gdocs/ 20 | my.fetch = function(config) { 21 | var dfd = new Deferred(); 22 | var urls = my.getGDocsApiUrls(config.url); 23 | var data = [] 24 | 25 | $.ajax({ 26 | type: "GET", 27 | url: urls.worksheetAPI, 28 | dataType: "text", 29 | success: function(response) 30 | { 31 | data = $.csv.toArrays(response); 32 | var result = my.parseData(data); 33 | var fields = _.map(result.fields, function(fieldId) { 34 | return {id: fieldId}; 35 | }); 36 | var metadata = _.extend(urls, { 37 | title: response.spreadsheetTitle +" - "+ result.worksheetTitle, 38 | spreadsheetTitle: response.spreadsheetTitle, 39 | worksheetTitle : result.worksheetTitle 40 | }) 41 | dfd.resolve({ 42 | metadata: metadata, 43 | records : result.records, 44 | fields : fields, 45 | useMemoryStore: true 46 | }); 47 | 48 | } 49 | }); 50 | 51 | 52 | return dfd.promise(); 53 | }; 54 | 55 | // ## parseData 56 | // 57 | // Parse data from Google Docs API into a reasonable form 58 | // 59 | // :options: (optional) optional argument dictionary: 60 | // columnsToUse: list of columns to use (specified by field names) 61 | // colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). 62 | // :return: tabular data object (hash with keys: field and data). 63 | // 64 | // Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. 65 | my.parseData = function(gdocsWorksheet, options) { 66 | var options = options || {}; 67 | var colTypes = options.colTypes || {}; 68 | var results = { 69 | fields : [], 70 | records: [] 71 | }; 72 | var entries = gdocsWorksheet; 73 | var key; 74 | var colName; 75 | // percentage values (e.g. 23.3%) 76 | var rep = /^([\d\.\-]+)\%$/; 77 | 78 | for(key of entries[0]) { 79 | results.fields.push(key.toLowerCase()); 80 | } 81 | 82 | // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) 83 | results.records = _.map(entries.slice(1), function(entry) { 84 | var row = {}; 85 | 86 | _.each(results.fields, function(col, i) { 87 | var value = entry[i]; 88 | var num; 89 | 90 | // TODO cover this part of code with test 91 | // TODO use the regexp only once 92 | // if labelled as % and value contains %, convert 93 | if(colTypes[col] === 'percent' && rep.test(value)) { 94 | num = rep.exec(value)[1]; 95 | value = parseFloat(num) / 100; 96 | } 97 | 98 | row[col] = value; 99 | }); 100 | 101 | return row; 102 | }); 103 | 104 | results.worksheetTitle = ''; 105 | return results; 106 | }; 107 | 108 | // Convenience function to get GDocs JSON API Url from standard URL 109 | // 110 | // @param url: url to gdoc to the GDoc API (or just the key/id for the Google Doc) 111 | my.getGDocsApiUrls = function(url, worksheetIndex) { 112 | console.log('THE URL', url) 113 | let url_without_csv = /https:\/\/docs.google.com\/spreadsheets\/d\/(\w+)$/g 114 | if (url.indexOf('/pubhtml') > 0) { 115 | url = url.replace('pubhtml', 'pub?output=csv') 116 | } 117 | if (url.indexOf('/edit') > 0) { 118 | url = url.split('/edit')[0] + '/pub?output=csv' 119 | } else if (url.indexOf('key=') > 0) { 120 | let doc_id = url.split('key=')[1].split('&')[0] 121 | if (doc_id.indexOf('#')) { 122 | doc_id = doc_id.split('#')[0] 123 | } 124 | url = `https://docs.google.com/spreadsheets/d/${doc_id}/pub?output=csv` 125 | } else if (url_without_csv.test(url)) { 126 | let back_slash = '/' 127 | if (url[url.length - 1] == '/') { 128 | back_slash = '' 129 | } 130 | url = url + back_slash + 'pub?output=csv' 131 | } 132 | console.log('NEW URL', url) 133 | return { 134 | worksheetAPI: url, 135 | spreadsheetAPI: url, 136 | spreadsheetKey: '', 137 | worksheetIndex: 0 138 | }; 139 | }; 140 | }(recline.Backend.GDocs)); 141 | 142 | -------------------------------------------------------------------------------- /test/frontend/qunit/qunit.css: -------------------------------------------------------------------------------- 1 | /** Font Family and Sizes */ 2 | 3 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 4 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial; 5 | } 6 | 7 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 8 | #qunit-tests { font-size: smaller; } 9 | 10 | 11 | /** Resets */ 12 | 13 | #qunit-tests, #qunit-tests ol, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | 19 | /** Header */ 20 | 21 | #qunit-header { 22 | padding: 0.5em 0 0.5em 1em; 23 | 24 | color: #8699a4; 25 | background-color: #0d3349; 26 | 27 | font-size: 1.5em; 28 | line-height: 1em; 29 | font-weight: normal; 30 | 31 | border-radius: 15px 15px 0 0; 32 | -moz-border-radius: 15px 15px 0 0; 33 | -webkit-border-top-right-radius: 15px; 34 | -webkit-border-top-left-radius: 15px; 35 | } 36 | 37 | #qunit-header a { 38 | text-decoration: none; 39 | color: #c2ccd1; 40 | } 41 | 42 | #qunit-header a:hover, 43 | #qunit-header a:focus { 44 | color: #fff; 45 | } 46 | 47 | #qunit-banner { 48 | height: 5px; 49 | } 50 | 51 | #qunit-testrunner-toolbar { 52 | padding: 0.5em 0 0.5em 2em; 53 | color: #5E740B; 54 | background-color: #eee; 55 | } 56 | 57 | #qunit-userAgent { 58 | padding: 0.5em 0 0.5em 2.5em; 59 | background-color: #2b81af; 60 | color: #fff; 61 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 62 | } 63 | 64 | 65 | /** Tests: Pass/Fail */ 66 | 67 | #qunit-tests { 68 | list-style-position: inside; 69 | } 70 | 71 | #qunit-tests li { 72 | padding: 0.4em 0.5em 0.4em 2.5em; 73 | border-bottom: 1px solid #fff; 74 | list-style-position: inside; 75 | } 76 | 77 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 78 | display: none; 79 | } 80 | 81 | #qunit-tests li strong { 82 | cursor: pointer; 83 | } 84 | 85 | #qunit-tests li a { 86 | padding: 0.5em; 87 | color: #c2ccd1; 88 | text-decoration: none; 89 | } 90 | #qunit-tests li a:hover, 91 | #qunit-tests li a:focus { 92 | color: #000; 93 | } 94 | 95 | #qunit-tests ol { 96 | margin-top: 0.5em; 97 | padding: 0.5em; 98 | 99 | background-color: #fff; 100 | 101 | border-radius: 15px; 102 | -moz-border-radius: 15px; 103 | -webkit-border-radius: 15px; 104 | 105 | box-shadow: inset 0px 2px 13px #999; 106 | -moz-box-shadow: inset 0px 2px 13px #999; 107 | -webkit-box-shadow: inset 0px 2px 13px #999; 108 | } 109 | 110 | #qunit-tests table { 111 | border-collapse: collapse; 112 | margin-top: .2em; 113 | } 114 | 115 | #qunit-tests th { 116 | text-align: right; 117 | vertical-align: top; 118 | padding: 0 .5em 0 0; 119 | } 120 | 121 | #qunit-tests td { 122 | vertical-align: top; 123 | } 124 | 125 | #qunit-tests pre { 126 | margin: 0; 127 | white-space: pre-wrap; 128 | word-wrap: break-word; 129 | } 130 | 131 | #qunit-tests del { 132 | background-color: #e0f2be; 133 | color: #374e0c; 134 | text-decoration: none; 135 | } 136 | 137 | #qunit-tests ins { 138 | background-color: #ffcaca; 139 | color: #500; 140 | text-decoration: none; 141 | } 142 | 143 | /*** Test Counts */ 144 | 145 | #qunit-tests b.counts { color: black; } 146 | #qunit-tests b.passed { color: #5E740B; } 147 | #qunit-tests b.failed { color: #710909; } 148 | 149 | #qunit-tests li li { 150 | margin: 0.5em; 151 | padding: 0.4em 0.5em 0.4em 0.5em; 152 | background-color: #fff; 153 | border-bottom: none; 154 | list-style-position: inside; 155 | } 156 | 157 | /*** Passing Styles */ 158 | 159 | #qunit-tests li li.pass { 160 | color: #5E740B; 161 | background-color: #fff; 162 | border-left: 26px solid #C6E746; 163 | } 164 | 165 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 166 | #qunit-tests .pass .test-name { color: #366097; } 167 | 168 | #qunit-tests .pass .test-actual, 169 | #qunit-tests .pass .test-expected { color: #999999; } 170 | 171 | #qunit-banner.qunit-pass { background-color: #C6E746; } 172 | 173 | /*** Failing Styles */ 174 | 175 | #qunit-tests li li.fail { 176 | color: #710909; 177 | background-color: #fff; 178 | border-left: 26px solid #EE5757; 179 | } 180 | 181 | #qunit-tests > li:last-child { 182 | border-radius: 0 0 15px 15px; 183 | -moz-border-radius: 0 0 15px 15px; 184 | -webkit-border-bottom-right-radius: 15px; 185 | -webkit-border-bottom-left-radius: 15px; 186 | } 187 | 188 | #qunit-tests .fail { color: #000000; background-color: #EE5757; } 189 | #qunit-tests .fail .test-name, 190 | #qunit-tests .fail .module-name { color: #000000; } 191 | 192 | #qunit-tests .fail .test-actual { color: #EE5757; } 193 | #qunit-tests .fail .test-expected { color: green; } 194 | 195 | #qunit-banner.qunit-fail { background-color: #EE5757; } 196 | 197 | 198 | /** Result */ 199 | 200 | #qunit-testresult { 201 | padding: 0.5em 0.5em 0.5em 2.5em; 202 | 203 | color: #2b81af; 204 | background-color: #D2E0E6; 205 | 206 | border-bottom: 1px solid white; 207 | } 208 | 209 | /** Fixture */ 210 | 211 | #qunit-fixture { 212 | position: absolute; 213 | top: -10000px; 214 | left: -10000px; 215 | } 216 | -------------------------------------------------------------------------------- /views/dataview/form.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 |
    5 |

    6 | 7 | ({{ gettext('If nothing happens check you are not blocking popups ...') }}) 8 |

    9 | 16 | {{ gettext('Important') }}: {{ gettext("you must \"publish\" your Google Spreadsheet: go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. See") }} {{ gettext('the FAQ below') }} {{ gettext('for more details') }}. 17 | 18 |
    19 |
    20 |
    21 |
    22 | 23 |
    24 | 31 |
    32 |
    33 | {% if currentUser %} 34 |
    35 | 36 |
    37 |
    38 | /{{currentUser.id}}/ 39 | 45 |
    46 | {{ gettext('The url of your new timemap. This must be different from the name for any of your existing timemaps. Choose wisely as this is hard to change!') }} 47 |
    48 |
    49 |
    50 | {% endif %} 51 |
    52 | 55 |
    56 | {% for val in ['timemap', 'timeline', 'map'] %} 57 | 68 | {% endfor %} 69 | 70 | {{ gettext('Choose the visualization type of your data - TimeMap (Timeline and Map combined), Timeline or Map.') }} 71 | 72 |
    73 |
    74 | 75 |

    {{ gettext('More Options') }}

    76 |
    77 | 80 |
    81 | 92 | 103 | 104 | {{ gettext('How to handle ambiguous dates like "05/08/2012" in source data (could be read as 5th August or 8th of May).') }} 105 |
    106 | {{ gettext('If you do not have any dates formatted like this then you can ignore this!') }} 107 |
    108 |
    109 |
    110 |
    111 | 114 |
    115 | {% for val in [['start', 'First event'], ['end', 'Last event'], ['today', 'Today']] %} 116 | 127 | {% endfor %} 128 | 129 | {{ gettext('Where on the timeline should the user start.') }} 130 | 131 |
    132 |
    133 |
    134 | 135 | -------------------------------------------------------------------------------- /views/dataview/timemap.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block bodyclass %}{{ gettext('view') }}{% if embed %} {{ gettext('embed') }}{% endif %}{% endblock %} 4 | 5 | {% block extrahead %} 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% endblock %} 24 | 25 | {% block navbar %} 26 | 59 | {% endblock %} 60 | 61 | {% block content %} 62 |
    63 |
    64 |
    65 |
    66 |
    67 | 68 | 69 |
    70 |
    71 |
    72 |
    73 |
    74 |
    75 |
    76 |   77 |
    78 |
    79 |
    80 |
    81 |
    82 |
    83 | 84 | 94 | 95 | 96 |
    {{ gettext('Loading data...') }}
    97 | {% endblock %} 98 | 99 | {% block footer %} 100 | {{title}} by {{viz.owner}} {{ gettext('using') }} TimeMapper 101 | {% if viz.licenses.length != 0 %} 102 | – 103 | {{ gettext('License') }} 104 | {% endif %} 105 | – 106 | {{ gettext('Source Data') }} 107 | {% endblock %} 108 | 109 | {% block extrabody %} 110 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | {% endblock %} 133 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | , nunjucks = require('nunjucks') 3 | , i18n = require('i18n-abide') 4 | , config = require('./lib/config.js') 5 | , passport = require('passport') 6 | , TwitterStrategy = require('passport-twitter').Strategy 7 | 8 | , dao = require('./lib/dao.js') 9 | , routes = require('./routes/index.js') 10 | , api = require('./routes/api.js') 11 | ; 12 | 13 | var app = express(); 14 | 15 | // Configuration 16 | app.configure(function(){ 17 | app.set('views', __dirname + '/views'); 18 | app.use( i18n.abide({ 19 | supported_languages: [ 20 | "en-US" , "zh-TW" 21 | ], 22 | default_lang: "en-US", 23 | translation_directory: "locale", 24 | locale_on_url: true 25 | })); 26 | app.use(express.bodyParser()); 27 | app.use(express.methodOverride()); 28 | app.use(express.cookieParser()); 29 | app.use(express.session({ secret: config.get('express:secret')})); 30 | app.use(passport.initialize()); 31 | app.use(passport.session()); 32 | app.use(app.router); 33 | app.use(express.static(__dirname + '/public')); 34 | }); 35 | 36 | var env = new nunjucks.Environment(new nunjucks.FileSystemLoader('views')); 37 | env.express(app); 38 | 39 | 40 | 41 | app.configure('production', function(){ 42 | app.use(express.errorHandler()); 43 | }); 44 | 45 | app.configure('development', function(){ 46 | app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); 47 | }); 48 | 49 | app.configure('testuser', function(){ 50 | app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); 51 | }); 52 | 53 | app.configure('test', function(){ 54 | app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); 55 | // TODO: repeats test/base.js (have to because runs independently of base.js for tests ...) 56 | var dbName = 'hypernotes-test-njs'; 57 | config.set('database:name', dbName); 58 | }); 59 | 60 | 61 | // ====================================== 62 | // Pre-preparation for views 63 | // ====================================== 64 | 65 | function getFlashMessages(req) { 66 | var messages = req.flash() 67 | , types = Object.keys(messages) 68 | , len = types.length 69 | , result = []; 70 | 71 | for (var i = 0; i < len; ++i) { 72 | var type = types[i] 73 | , msgs = messages[type]; 74 | for (var j = 0, l = msgs.length; j < l; ++j) { 75 | var msg = msgs[j]; 76 | result.push({ 77 | category: type 78 | , text: msg 79 | }); 80 | } 81 | } 82 | return result; 83 | } 84 | 85 | // app.dynamicHelpers({ 86 | // messages: function(req,res) { 87 | // return getFlashMessages(req); 88 | // } 89 | // }); 90 | // 91 | // app.helpers({ 92 | // distanceOfTimeInWords: util.distanceOfTimeInWords 93 | // }); 94 | 95 | app.all('*', function(req, res, next) { 96 | function setup(req) { 97 | app.locals.currentUser = req.user ? req.user.toJSON() : null; 98 | next(); 99 | } 100 | if (config.get('test:testing') === true && config.get('test:user')) { 101 | var userid = config.get('test:user'); 102 | var acc = dao.Account.create({id: userid}); 103 | acc.fetch(function() { 104 | req.user = acc; 105 | setup(req); 106 | }); 107 | } else { 108 | setup(req); 109 | } 110 | }); 111 | 112 | // ====================================== 113 | // Main pages 114 | // ====================================== 115 | 116 | app.get('/', function(req, res){ 117 | if (req.user) { 118 | routes.dashboard(req, res); 119 | } else { 120 | res.render('index.html', {title: 'Home'}); 121 | } 122 | }); 123 | 124 | app.get('/create', routes.create); 125 | app.post('/create', routes.createPost); 126 | app.get('/view', routes.preview); 127 | 128 | // ====================================== 129 | // User Accounts 130 | // ====================================== 131 | 132 | app.get('/account/login', passport.authenticate('twitter')); 133 | 134 | app.get('/account/auth/twitter/callback', 135 | passport.authenticate('twitter', { successRedirect: '/', 136 | failureRedirect: '/login' })); 137 | 138 | var siginOrRegister = function(token, tokenSecret, profile, done) { 139 | // twitter does not provide access to user email so this is always null :-( 140 | var email = profile.emails && profile.emails.length > 0 ? profile.emails[0].value : null; 141 | var photo = profile.photos && profile.photos.length > 0 ? profile.photos[0].value : null; 142 | account = dao.Account.create({ 143 | id: profile.username.toLowerCase(), 144 | fullname: profile.displayName, 145 | // hackish 146 | description: profile._json.description, 147 | provider: 'twitter', 148 | email: email, 149 | image: photo, 150 | manifest_version: 1 }); 151 | account.save(function(err) { 152 | if (err) { return done(err); } 153 | // req.flash('success', 'Thanks for signing-up'); 154 | done(null, account); 155 | }); 156 | }; 157 | 158 | passport.use(new TwitterStrategy({ 159 | consumerKey: config.get('twitter:key'), 160 | consumerSecret: config.get('twitter:secret'), 161 | callbackURL: config.get('twitter:url') 162 | }, 163 | siginOrRegister 164 | )); 165 | 166 | passport.serializeUser(function(user, done) { 167 | done(null, user.id); 168 | }); 169 | 170 | passport.deserializeUser(function(id, done) { 171 | var account = dao.Account.create({ 172 | id: id 173 | }); 174 | account.fetch(function(err, user) { 175 | done(err, account); 176 | }); 177 | }); 178 | 179 | app.get('/account/logout', function(req, res){ 180 | req.logout(); 181 | res.redirect('/'); 182 | }); 183 | 184 | // ====================================== 185 | // User Pages and Dashboards 186 | // ====================================== 187 | 188 | app.get('/:userId', routes.userShow); 189 | 190 | // ====================================== 191 | // Data Views 192 | // ====================================== 193 | 194 | app.get('/:userId/:threadName', routes.timeMap); 195 | 196 | app.get('/:userId/:threadName/edit', routes.dataViewEdit); 197 | app.post('/:userId/:threadName/edit', routes.dataViewEditPost); 198 | 199 | // ====================================== 200 | // API 201 | // ====================================== 202 | 203 | app.get('/api/account/:id', api.getAccount); 204 | 205 | app.get('/api/dataview/:owner/:name', api.getDataView); 206 | app.post('/api/dataview', api.createDataView); 207 | app.post('/api/dataview/:userId/:name', api.updateDataView); 208 | app.delete('/api/dataview/:owner/:name', api.deleteDataView); 209 | 210 | exports.app = app; 211 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore') 2 | , config = require('../lib/config.js') 3 | , dao = require('../lib/dao.js') 4 | , logic = require('../lib/logic') 5 | , util = require('../lib/util.js') 6 | ; 7 | 8 | exports.create = function(req, res) { 9 | // just a stub for form 10 | var dataview = { 11 | tmconfig: { 12 | // default 13 | viewtype: 'timemap' 14 | } 15 | }; 16 | res.render('dataview/create.html', { 17 | title: 'Create', 18 | dataview: dataview 19 | }); 20 | } 21 | 22 | exports.createPost = function(req, res) { 23 | var data = req.body; 24 | logic.createDataView(data, req.user, function(err, out) { 25 | if (err) { 26 | res.send(err.code, err.message); 27 | } else { 28 | var out = out.toJSON(); 29 | // req.flash('Data View Created'); 30 | res.redirect(urlFor(out.owner, out.name)); 31 | } 32 | }); 33 | } 34 | 35 | function urlFor(owner, dataView) { 36 | return '/' + [ 37 | owner, 38 | dataView 39 | ].join('/') 40 | } 41 | 42 | exports.preview = function(req, res) { 43 | var threadData = { 44 | name: 'whatever-you-want', 45 | title: req.query.title || 'Untitled', 46 | owner: req.query.owner || 'Anonymous', 47 | resources: [ 48 | { 49 | url: req.query.url, 50 | backend: 'gdocs' 51 | } 52 | ], 53 | tmconfig: { 54 | dayfirst: req.query.dayfirst, 55 | startfrom: req.query.startfrom, 56 | viewtype: req.query.viewtype || 'timemap' 57 | } 58 | }; 59 | var isOwner = false; 60 | res.render('dataview/timemap.html', { 61 | title: threadData.title 62 | , embed: (req.query.embed !== undefined) 63 | , viz: threadData 64 | , vizJSON: JSON.stringify(threadData) 65 | , isOwner: isOwner 66 | }); 67 | } 68 | 69 | // ====================================== 70 | // User Pages and Dashboards 71 | // ====================================== 72 | 73 | exports.dashboard = function(req, res) { 74 | var userId = req.user.id; 75 | getUserInfoFull(req.user.id, function(error, account) { 76 | if (error) { 77 | res.send('Not found', 404); 78 | return; 79 | } 80 | var views = account.views.filter(function(view) { 81 | return !(view.state && view.state == 'deleted'); 82 | }); 83 | res.render('dashboard.html', { 84 | account: account.toJSON(), 85 | views: views 86 | }); 87 | }); 88 | }; 89 | 90 | exports.userShow = function(req, res) { 91 | var userId = req.params.userId; 92 | var account = dao.Account.create({id: userId}); 93 | getUserInfoFull(userId, function(error, account) { 94 | if (error) { 95 | res.send('Not found', 404); 96 | return; 97 | } 98 | var isOwner = (req.currentUser && req.currentUser.id == userId); 99 | var accountJson = account.toTemplateJSON(); 100 | accountJson.createdNice = new Date(accountJson._created).toDateString(); 101 | var views = account.views.filter(function(view) { 102 | return !(view.state && view.state == 'deleted'); 103 | }); 104 | res.render('account/view.html', { 105 | account: accountJson 106 | , views: views 107 | , isOwner: isOwner 108 | , bodyclass: 'account' 109 | }); 110 | }); 111 | }; 112 | 113 | function getUserInfoFull(userId, cb) { 114 | var account = dao.Account.create({id: userId}); 115 | account.fetch(function(error) { 116 | if (error) { 117 | cb(error); 118 | return; 119 | } 120 | dao.DataView.getByOwner(userId, function(error, views) { 121 | account.views = views; 122 | cb(error, account); 123 | }); 124 | }); 125 | } 126 | 127 | // ====================================== 128 | // Data Views 129 | // ====================================== 130 | 131 | var routePrefixes = { 132 | 'js': '' 133 | , 'css': '' 134 | , 'vendor': '' 135 | , 'img': '' 136 | , 'account': '' 137 | , 'dashboard': '' 138 | }; 139 | 140 | exports.timeMap = function(req, res, next) { 141 | var userId = req.params.userId; 142 | // HACK: we only want to handle threads and not other stuff 143 | if (userId in routePrefixes) { 144 | next(); 145 | return; 146 | } 147 | var threadName = req.params.threadName; 148 | var viz = dao.DataView.create({owner: userId, name: threadName}); 149 | viz.fetch(function(error) { 150 | if (error) { 151 | res.send('Not found ' + error.message, 404); 152 | return; 153 | } 154 | var threadData = viz.toTemplateJSON(); 155 | var isOwner = (req.user && req.user.id == threadData.owner); 156 | res.render('dataview/timemap.html', { 157 | title: threadData.title 158 | , permalink: 'http://timemapper.okfnlabs.org/' + threadData.owner + '/' + threadData.name 159 | , authorLink: 'http://timemapper.okfnlabs.org/' + threadData.owner 160 | , embed: (req.query.embed !== undefined) 161 | , viz: threadData 162 | , vizJSON: JSON.stringify(threadData) 163 | , isOwner: isOwner 164 | }); 165 | }); 166 | } 167 | 168 | exports.dataViewEdit = function(req, res) { 169 | var userId = req.params.userId; 170 | var threadName = req.params.threadName; 171 | var viz = dao.DataView.create({owner: userId, name: threadName}); 172 | viz.fetch(function(error) { 173 | if (error) { 174 | res.send('Not found ' + error.message, 404); 175 | return; 176 | } 177 | var dataview = viz.toTemplateJSON(); 178 | res.render('dataview/edit.html', { 179 | dataview: dataview 180 | , dataviewJson: JSON.stringify(viz.toJSON()) 181 | }); 182 | }); 183 | } 184 | 185 | exports.dataViewEditPost = function(req, res) { 186 | var userId = req.params.userId 187 | , threadName = req.params.threadName 188 | , data = req.body 189 | ; 190 | var viz = dao.DataView.create({owner: userId, name: threadName}); 191 | viz.fetch(function(error) { 192 | var dataViewData = viz.toJSON(); 193 | var vizData = _.extend(dataViewData, { 194 | title: data.title, 195 | resources: [ 196 | _.extend({}, dataViewData.resources[0], { 197 | url: data.url 198 | }) 199 | ], 200 | tmconfig: _.extend({}, dataViewData.tmconfig, data.tmconfig) 201 | }); 202 | // RECREATE as create does casting correctly 203 | newviz = dao.DataView.create(dataViewData); 204 | logic.upsertDataView(newviz, 'update', req.user, function(err, out) { 205 | var out = out.toJSON(); 206 | if (err) { 207 | res.send(err.code, err.message); 208 | } else { 209 | // req.flash('Data View Updated'); 210 | res.redirect(urlFor(out.owner, out.name)); 211 | } 212 | }); 213 | }); 214 | } 215 | 216 | -------------------------------------------------------------------------------- /public/vendor/leaflet.label/leaflet.label.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2012, Smartrak, Jacob Toye 3 | Leaflet.label is an open-source JavaScript library for adding labels to markers and paths on leaflet powered maps. 4 | https://github.com/jacobtoye/Leaflet.label 5 | */ 6 | (function (window, undefined) { 7 | 8 | L.Label = L.Popup.extend({ 9 | options: { 10 | autoPan: false, 11 | className: '', 12 | closePopupOnClick: false, 13 | noHide: false, 14 | offset: new L.Point(12, -15) // 6 (width of the label triangle) + 6 (padding) 15 | }, 16 | 17 | onAdd: function (map) { 18 | this._map = map; 19 | 20 | if (!this._container) { 21 | this._initLayout(); 22 | } 23 | this._updateContent(); 24 | 25 | var animFade = map.options.fadeAnimation; 26 | 27 | if (animFade) { 28 | L.DomUtil.setOpacity(this._container, 0); 29 | } 30 | map._panes.popupPane.appendChild(this._container); 31 | 32 | map.on('viewreset', this._updatePosition, this); 33 | 34 | if (L.Browser.any3d) { 35 | map.on('zoomanim', this._zoomAnimation, this); 36 | } 37 | 38 | this._update(); 39 | 40 | if (animFade) { 41 | L.DomUtil.setOpacity(this._container, 1); 42 | } 43 | }, 44 | 45 | close: function () { 46 | var map = this._map; 47 | 48 | if (map) { 49 | map._label = null; 50 | 51 | map.removeLayer(this); 52 | } 53 | }, 54 | 55 | _initLayout: function () { 56 | this._container = L.DomUtil.create('div', 'leaflet-label ' + this.options.className + ' leaflet-zoom-animated'); 57 | }, 58 | 59 | _updateContent: function () { 60 | if (!this._content) { return; } 61 | 62 | if (typeof this._content === 'string') { 63 | this._container.innerHTML = this._content; 64 | } 65 | }, 66 | 67 | _updateLayout: function () { 68 | // Do nothing 69 | }, 70 | 71 | _updatePosition: function () { 72 | var pos = this._map.latLngToLayerPoint(this._latlng); 73 | 74 | this._setPosition(pos); 75 | }, 76 | 77 | _setPosition: function (pos) { 78 | pos = pos.add(this.options.offset); 79 | 80 | L.DomUtil.setPosition(this._container, pos); 81 | }, 82 | 83 | _zoomAnimation: function (opt) { 84 | var pos = this._map._latLngToNewLayerPoint(this._latlng, opt.zoom, opt.center); 85 | 86 | this._setPosition(pos); 87 | } 88 | }); 89 | 90 | // Add in an option to icon that is used to set where the label anchor is 91 | L.Icon.Default.mergeOptions({ 92 | labelAnchor: new L.Point(9, -20) 93 | }); 94 | 95 | // Have to do this since Leaflet is loaded before this plugin and initializes 96 | // L.Marker.options.icon therefore missing our mixin above. 97 | L.Marker.mergeOptions({ 98 | icon: new L.Icon.Default() 99 | }); 100 | 101 | L.Marker.include({ 102 | showLabel: function () { 103 | if (this._label && this._map) { 104 | this._label.setLatLng(this._latlng); 105 | this._map.showLabel(this._label); 106 | } 107 | 108 | return this; 109 | }, 110 | 111 | hideLabel: function () { 112 | if (this._label) { 113 | this._label.close(); 114 | } 115 | return this; 116 | }, 117 | 118 | bindLabel: function (content, options) { 119 | var anchor = L.point(this.options.icon.options.labelAnchor) || new L.Point(0, 0); 120 | 121 | anchor = anchor.add(L.Label.prototype.options.offset); 122 | 123 | if (options && options.offset) { 124 | anchor = anchor.add(options.offset); 125 | } 126 | 127 | options = L.Util.extend({offset: anchor}, options); 128 | 129 | if (!this._label) { 130 | if (!options.noHide) { 131 | this 132 | .on('mouseover', this.showLabel, this) 133 | .on('mouseout', this.hideLabel, this); 134 | 135 | if (L.Browser.touch) { 136 | this.on('click', this.showLabel, this); 137 | } 138 | } 139 | 140 | this 141 | .on('remove', this.hideLabel, this) 142 | .on('move', this._moveLabel, this); 143 | 144 | this._haslabelHandlers = true; 145 | } 146 | 147 | this._label = new L.Label(options, this) 148 | .setContent(content); 149 | 150 | return this; 151 | }, 152 | 153 | unbindLabel: function () { 154 | if (this._label) { 155 | this.hideLabel(); 156 | 157 | this._label = null; 158 | 159 | if (this._haslabelHandlers) { 160 | this 161 | .off('mouseover', this.showLabel) 162 | .off('mouseout', this.hideLabel) 163 | .off('remove', this.hideLabel) 164 | .off('move', this._moveLabel); 165 | 166 | if (L.Browser.touch) { 167 | this.off('click', this.showLabel); 168 | } 169 | } 170 | 171 | this._haslabelHandlers = false; 172 | } 173 | return this; 174 | }, 175 | 176 | updateLabelContent: function (content) { 177 | if (this._label) { 178 | this._label.setContent(content); 179 | } 180 | }, 181 | 182 | _moveLabel: function (e) { 183 | this._label.setLatLng(e.latlng); 184 | } 185 | }); 186 | 187 | L.Path.include({ 188 | bindLabel: function (content, options) { 189 | if (!this._label || this._label.options !== options) { 190 | this._label = new L.Label(options, this); 191 | } 192 | 193 | this._label.setContent(content); 194 | 195 | if (!this._showLabelAdded) { 196 | this 197 | .on('mouseover', this._showLabel, this) 198 | .on('mousemove', this._moveLabel, this) 199 | .on('mouseout remove', this._hideLabel, this); 200 | 201 | if (L.Browser.touch) { 202 | this.on('click', this._showLabel, this); 203 | } 204 | this._showLabelAdded = true; 205 | } 206 | 207 | return this; 208 | }, 209 | 210 | unbindLabel: function () { 211 | if (this._label) { 212 | this._hideLabel(); 213 | this._label = null; 214 | this._showLabelAdded = false; 215 | this 216 | .off('mouseover', this._showLabel) 217 | .off('mousemove', this._moveLabel) 218 | .off('mouseout remove', this._hideLabel); 219 | } 220 | return this; 221 | }, 222 | 223 | updateLabelContent: function (content) { 224 | if (this._label) { 225 | this._label.setContent(content); 226 | } 227 | }, 228 | 229 | _showLabel: function (e) { 230 | this._label.setLatLng(e.latlng); 231 | this._map.showLabel(this._label); 232 | }, 233 | 234 | _moveLabel: function (e) { 235 | this._label.setLatLng(e.latlng); 236 | }, 237 | 238 | _hideLabel: function () { 239 | this._label.close(); 240 | } 241 | }); 242 | 243 | L.Map.include({ 244 | showLabel: function (label) { 245 | this._label = label; 246 | 247 | return this.addLayer(label); 248 | } 249 | }); 250 | 251 | L.FeatureGroup.include({ 252 | // TODO: remove this when AOP is supported in Leaflet, need this as we cannot put code in removeLayer() 253 | clearLayers: function () { 254 | this.unbindLabel(); 255 | this.eachLayer(this.removeLayer, this); 256 | return this; 257 | }, 258 | 259 | bindLabel: function (content, options) { 260 | return this.invoke('bindLabel', content, options); 261 | }, 262 | 263 | unbindLabel: function () { 264 | return this.invoke('unbindLabel'); 265 | }, 266 | 267 | updateLabelContent: function (content) { 268 | this.invoke('updateLabelContent', content); 269 | } 270 | }); 271 | 272 | 273 | 274 | }(this)); 275 | -------------------------------------------------------------------------------- /views/dataview/create.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block bodyclass %}{{ gettext('create') }}{% endblock %} 4 | 5 | {% block content %} 6 | 11 | 12 |
    13 |

    {{ gettext("It's as easy as 1-2-3!") }}

    14 |
    15 |
    16 |
    17 |

    {{ gettext("1. Create a Spreadsheet") }}

    18 |

    {{ gettext("Add your dates and places to a Google Spreadsheet.") }}

    19 |
    20 |
    21 |
    22 |
    23 |

    {{ gettext("2. Connect and Customize") }}

    24 |

    {{ gettext("Connect your spreadsheet with TimeMapper and customize the results.") }}

    25 |
    26 |
    27 |
    28 |
    29 |

    {{ gettext("3. Publish, Embed and Share") }}

    30 |

    {{ gettext("Publish your TimeMap at your own personal url, then share or embed on your site.") }}

    31 |
    32 |
    33 |
    34 |
    35 | 36 |
    37 | 38 |
    39 | 40 |

    {{ gettext("Let's Get Started") }} …

    41 |
    42 |
    43 |

    44 | {{ gettext("1. Create your Spreadsheet") }} 45 |
    46 | ({{ gettext("if you don't have one already!") }}) 47 |

    48 | 49 |
    50 |
    51 |

    {{ gettext('Get started by copying . For more details or help with problems check out the FAQ below.

    ') }} 52 | 53 |
    54 | {{ gettext("Impatient to try this out but don't have a spreadsheet yet?") }}
    55 | 57 | {{ gettext("Click here to use a pre-prepared example") }} » 58 |
    59 |
    60 |
    61 |
    62 |
    63 | 64 |
    65 |
    66 |
    67 |

    68 | {{ gettext('2. Connect and Customize') }} 69 |

    70 | {% if not currentUser %} 71 |
    72 | {{ gettext("ALERT: you are not signed in so your timemap will be created 'anonymously'.") }} 73 |
    74 |
    75 | {{ gettext('If want to \'own\' your timemap you should sign in (or sign-up) now »') }} 76 |
    77 | {{ gettext('(Sign-up takes a few seconds with your twitter account »)') }} 78 |
    79 |
    80 | {{ gettext('Find out more on anonymous vs logged in') }} - {{ gettext('read FAQ below') }} » 81 |
    82 | {% endif %} 83 | 84 |
    85 | {% include 'dataview/form.html' %} 86 | 87 |

    88 | {{ gettext("3. Let's Publish It!") }} 89 |

    90 |
    91 |
    92 | 93 |
    94 |
    95 |
    96 |
    97 |
    98 |
    99 | 100 |
    101 | 102 |
    103 | 104 |
    105 |

    {{ gettext('FAQ') }}

    106 |

    {{ gettext('Can I make a timemap anonymously?') }}

    107 |

    {{ gettext('Yes! You do not need an account to create a timemap - they can be created anonymously and will have all the same features and shareability of normal timemaps. However, there are some benefits of creating an account and creating your timemap whilst logged in:') }}

    108 | 113 | 114 |

    {{ gettext('If you do want an account, signup is very easy') }} – {{ gettext('it takes just 15 seconds, is very secure, and uses your Twitter account') }} {{ gettext('(no need to think up a new username and password!).') }}

    115 | 116 |

    "{{ gettext('Publish') }}" {{ gettext('Your Spreadsheet') }}

    117 |

    {{ gettext("Go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. This tutorial walks you through.") }}

    118 | 119 | 120 | 121 |

    {{ gettext("What URL do I use to connect my spreadsheet?") }}

    122 |

    {{ gettext("Use the URL you get by clicking your spreadsheet's Share button and copying the Link to share box.") }}

    123 | 124 |

    {{ gettext("Note that although you must also Publish to the web, TimeMapper does not use the URL found in the publication pop-up.") }}

    125 | 126 |

    {{ gettext("What structure must the spreadsheet have?") }}

    127 |

    {{ gettext('TimeMapper recognizes certain columns with specific names. The best overview of these columns is the template, which has rows of instructions and examples.') }}

    128 | 129 |

    {{ gettext('Not all fields are required. The only required fields are Title and Start fields, and even Start can be omitted if you just want a map. Note that you can add any number of other columns beyond those that TimeMapper uses.') }}

    130 | 131 |

    {{ gettext('How do I format dates?') }}

    132 |

    {{ gettext('The preferred date format is ISO 8601 (YYYY-MM-DD), but TimeMapper recognizes most types of date.') }}

    133 | 134 |

    {{ gettext("If a date's month and day are ambiguous (e.g. is 08-03-1798 UK notation for 8 March, or is it US notation for 3 August?), by default, the first number will be interpreted as the month. You can change this by clicking the edit button in the top right corner of your TimeMap's display and selecting between US- and non-US-style dates.") }}

    135 | 136 |

    {{ gettext('What kinds of geodata are supported?') }}

    137 |

    {{ gettext('The Location column accepts two types of geodata: latitude-longitude coordinates or GeoJSON objects.') }}

    138 | 139 |

    {{ gettext('Coordinates must be in the format lat, long (e.g. 37.5, -122). The spreadsheet template includes a formula which automatically looks up coordinates corresponding to human-readable place names in the Place column. This formula is explained in a School of Data blog post.') }}

    140 | 141 |

    {{ gettext('Advanced users who want to go beyond simple coordinates can use GeoJSON feature objects. For an example, see this blog post on adding GeoJSON country boundaries to spreadsheets.') }}

    142 | 143 |
    144 | 145 | {% endblock %} 146 | 147 | {% block extrabody %} 148 | 149 | 150 | 151 | 152 | 153 | 154 | {% endblock %} 155 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=News+Cycle:400,700|Merriweather:400,300,700); 2 | 3 | html { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | font-size: 14px; 9 | font-family: Merriweather, serif; 10 | font-weight: 300; 11 | } 12 | 13 | h1, h2, h3, h4, h5, h6, .brand { 14 | font-family: "News Cycle", Arial, sans-serif; 15 | font-weight: 700; 16 | } 17 | 18 | h3 { 19 | line-height: 32px; 20 | } 21 | 22 | input.input { 23 | box-sizing: border-box; 24 | height: 30px; 25 | width: 100%; 26 | } 27 | 28 | .page-header { 29 | border-bottom: 1px solid #ccc; 30 | padding-bottom: 5px; 31 | } 32 | 33 | .footer { 34 | font-size: 90%; 35 | background-color: #f5f5f5; 36 | padding: 20px 0px; 37 | margin-top: 20px; 38 | border-top: solid 1px #ddd; 39 | } 40 | 41 | i.gdrive { 42 | height: 20px; 43 | width: 20px; 44 | display: inline-block; 45 | vertical-align: top; 46 | background: url(drive20.png); 47 | } 48 | 49 | /* we do not have a LH sidebar */ 50 | .container-fluid > .content { 51 | margin-left: 0; 52 | } 53 | 54 | .okfn-ribbon { 55 | position: absolute; 56 | top: -10px; 57 | right: 10px; 58 | z-index: 10000; 59 | } 60 | 61 | #okf-panel { 62 | z-index: 10000; 63 | top: 0; 64 | position: absolute; 65 | width: 100%; 66 | overflow: hidden; 67 | } 68 | 69 | /* Custom scroll-bars */ 70 | ::-webkit-scrollbar { 71 | width: 10px; 72 | height: 10px; 73 | } 74 | ::-webkit-scrollbar-track { 75 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); 76 | } 77 | ::-webkit-scrollbar-thumb { 78 | background: rgba(150,150,150,0.8); 79 | } 80 | 81 | /********************************************************** 82 | * Navigation 83 | *********************************************************/ 84 | 85 | .navbar { 86 | margin-bottom: 5px; 87 | } 88 | 89 | .navbar-inner { 90 | background: #e0e7eb; 91 | padding-top: 0px; 92 | } 93 | 94 | .navbar .brand { 95 | color: #434C50; 96 | font-weight: 200; 97 | padding-top: 7px; 98 | padding-bottom: 6px; 99 | } 100 | 101 | .black { 102 | color: #333; 103 | } 104 | 105 | .brand-ext { 106 | color: #08C; 107 | } 108 | 109 | .navbar .brand.by { 110 | padding-left: 6px; 111 | } 112 | 113 | .brand.by span { 114 | font-size: 75%; 115 | margin-right: 2px; 116 | } 117 | 118 | .brand img { 119 | margin-top: -3px; 120 | height: 30px; 121 | margin-right: 3px; 122 | } 123 | 124 | .navbar .divider-vertical { 125 | background-color: #acbfca; 126 | border-right: 1px solid #f5f7f8; 127 | width: 1px; 128 | margin: 0; 129 | } 130 | 131 | .navbar .nav > li > a { 132 | color: #434c50; 133 | text-shadow: none; 134 | } 135 | 136 | .navbar .nav > li > a:hover { 137 | color: #222; 138 | } 139 | 140 | .navbar ul.nav.pull-right { 141 | margin-right: 35px; 142 | } 143 | 144 | /************************************************************** 145 | * Front Page 146 | *************************************************************/ 147 | 148 | .home .hero-unit { 149 | margin-top: 45px; 150 | } 151 | 152 | .home .examples img { 153 | width: 100%; 154 | height: 210px; 155 | } 156 | 157 | /************************************************************** 158 | * Data Views 159 | *************************************************************/ 160 | 161 | body.view { 162 | height: 100%; 163 | } 164 | 165 | body.view .navbar { 166 | margin-bottom: 0px; 167 | } 168 | 169 | body.view .navbar ul.pull-right a { 170 | padding: 10px 10px 10px; 171 | } 172 | 173 | body.view li.tweet { 174 | margin-top: 10px; 175 | } 176 | 177 | body.view .content { 178 | background-color: none; 179 | padding: 0px; 180 | margin: 0; 181 | -webkit-box-shadow: none; 182 | -moz-box-shadow: none; 183 | box-shadow: none; 184 | } 185 | 186 | body.view .controls { 187 | display: none; 188 | position: absolute; 189 | left: 0; 190 | z-index: 1000; 191 | padding-top: 2px; 192 | } 193 | 194 | body.view .controls .toolbox { 195 | margin-left: -2px; 196 | } 197 | 198 | body.view .controls .toolbox.hideme input { 199 | display: none; 200 | } 201 | 202 | body.view .footer { 203 | position: absolute; 204 | bottom: 0; 205 | left: 0; 206 | right: 0; 207 | margin: 0; 208 | padding: 8px; 209 | } 210 | 211 | body.view .loading { 212 | position: absolute; 213 | top: 50%; 214 | left: 0; 215 | width: 100%; 216 | text-align: center; 217 | font-size: 45px; 218 | } 219 | 220 | body.view .navbar ul.nav.pull-right { 221 | margin-right: 0; 222 | } 223 | 224 | .data-views { 225 | position: absolute; 226 | top: 42px; 227 | bottom: 41px; 228 | left: 0; 229 | right: 0; 230 | } 231 | 232 | .panes, .panes > div, .map, .map > div, .recline-map, .recline-map .map, .recline-timeline { 233 | height: 100%; 234 | } 235 | 236 | .data-views .recline-map { 237 | margin-left: 0px; 238 | } 239 | 240 | /* correct so we have full width of page */ 241 | .data-views .timeline-pane { 242 | margin: 0; 243 | padding: 0; 244 | width: 66.66%; 245 | float: left; 246 | } 247 | .data-views .map-pane { 248 | margin: 0; 249 | padding: 0; 250 | width: 33.33%; 251 | float: left; 252 | } 253 | 254 | .timeline { 255 | position: relative; 256 | height: 100%; 257 | } 258 | 259 | .vco-storyjs p { 260 | font-size: 13px; 261 | } 262 | 263 | /* get attribution on one line ... */ 264 | .leaflet-container .leaflet-control-attribution { 265 | font-size: 9px; 266 | } 267 | 268 | .leaflet-control-attribution img { 269 | display: none; 270 | } 271 | 272 | /************************************************************** 273 | * Embed 274 | *************************************************************/ 275 | 276 | body.embed .navbar { 277 | display: none; 278 | } 279 | 280 | body.embed .data-views { 281 | top: 0; 282 | } 283 | 284 | /************************************************************** 285 | * Timeline tweaks 286 | *************************************************************/ 287 | 288 | .source { 289 | font-style: italic; 290 | } 291 | 292 | .vco-storyjs a.title-link { 293 | color: #0088cc; 294 | } 295 | 296 | i.title-link { 297 | font-size: 50%; 298 | color: #000; 299 | } 300 | 301 | /* hack to hide the link icon in the forward / back links at the side */ 302 | .nav-container i.title-link { 303 | display: none; 304 | } 305 | 306 | /********************************************************** 307 | * Timeline Only View 308 | *********************************************************/ 309 | 310 | body.viewtype-timeline .map-pane { 311 | display: none; 312 | } 313 | 314 | body.viewtype-timeline .timeline-pane { 315 | width: 100%; 316 | } 317 | 318 | /********************************************************** 319 | * Timeline Only View 320 | *********************************************************/ 321 | 322 | body.viewtype-map .timeline-pane { 323 | display: none; 324 | } 325 | 326 | body.viewtype-map .map-pane { 327 | width: 100%; 328 | height: 100%; 329 | } 330 | 331 | /********************************************************** 332 | * Dashboard View 333 | *********************************************************/ 334 | 335 | .dashboard .menu { 336 | margin: 50px 0px; 337 | } 338 | 339 | /********************************************************** 340 | * Create View 341 | *********************************************************/ 342 | 343 | .create .overview h3 { 344 | margin-top: 0; 345 | padding-top: 0px; 346 | } 347 | 348 | .help-block { 349 | font-size: 90%; 350 | } 351 | 352 | body.create h2, body.create .instructions h3 { 353 | margin-top: 0; 354 | margin-bottom: 20px; 355 | text-align: center; 356 | } 357 | 358 | /********************************************************** 359 | * User View 360 | *********************************************************/ 361 | 362 | .gravatar { 363 | border-radius: 3px; 364 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); 365 | } 366 | 367 | body.account .page-header .gravatar { 368 | margin-top: -9px; 369 | } 370 | 371 | body.account .meta { 372 | font-size: 16px; 373 | } 374 | 375 | body.account .meta ul { 376 | list-style-type: none; 377 | margin-left: 0; 378 | } 379 | 380 | body.account .meta ul.vcard { 381 | margin-top: 8px; 382 | } 383 | 384 | body.account .meta ul.vcard li { 385 | margin-bottom: 5px; 386 | } 387 | 388 | body.account .meta ul.vcard .description { 389 | font-size: 13px; 390 | margin-top: 8px; 391 | } 392 | 393 | .meta ul.stats li { 394 | display: inline-block; 395 | } 396 | 397 | .meta ul.stats li strong { 398 | display: block; 399 | font-size: 42px; 400 | line-height: 50px; 401 | } 402 | 403 | /********************************************************** 404 | * Views - List 405 | *********************************************************/ 406 | 407 | ul.view-summary-list { 408 | padding-left: 0; 409 | list-style-type: none; 410 | margin-left: 0; 411 | margin-bottom: 50px; 412 | } 413 | 414 | ul.view-summary-list li { 415 | border-bottom: solid 1px #ccc; 416 | padding-bottom: 10px; 417 | margin-bottom: 10px; 418 | } 419 | 420 | .view-summary-list h3:first-child { 421 | margin-top: 0; 422 | } 423 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TimeMapper 2 | 3 | Create beautiful timelines and timemaps from Google Spreadsheets. 4 | 5 | Built by members of [Open Knowledge Foundation Labs](http://okfnlabs.org/). 6 | 7 | See it in action at 8 | 9 | # Install 10 | 11 | ## Local Install 12 | 13 | This is a Node web-app built using express. 14 | 15 | Install Node (>=0.8 suggested) and npm then checkout the code: 16 | 17 | git clone https://github.com/okfn/timemapper 18 | 19 | Then install the dependencies: 20 | 21 | cd timemapper 22 | npm install . 23 | # for some vendor modules 24 | git submodule init && git submodule update 25 | 26 | Finally, you may wish to set configuration options such as database name, port 27 | to run on, etc. To do this: 28 | 29 | # copy the settings.json template to settings.json 30 | cp settings.json.tmpl settings.json 31 | # then edit as necessary 32 | 33 | Now you can run the app: 34 | 35 | node run.js 36 | 37 | To view the site, open localhost:3000 in a browser. 38 | 39 | ### Configuration 40 | 41 | The default configuration can be found in `lib/config.js`. You can override 42 | this in a couple of ways: 43 | 44 | 1. Create a `settings.json` with specific values. `settings.json` has the same 45 | form as nconf.defaults object in in `lib/config.js`. 46 | 47 | 2. Set specific environment variables. The ones you can set are those used in 48 | `nconf.defaults`. This useful for deployment on Heroku where environment 49 | variables are default way to configure. 50 | 51 | ## Deploy (to Heroku) 52 | 53 | Standard stuff: 54 | 55 | heroku create timemapper 56 | git push heroku 57 | 58 | You'll also need to set config. Suggest creating a `.env` file: 59 | 60 | TWITTER_KEY=... 61 | ... 62 | 63 | Then push it: 64 | 65 | heroku config:push 66 | 67 | 68 | 69 | # Overview for Developers 70 | 71 | * NodeJS app but very frontend JS oriented 72 | * Most of "presentation" including visualizations are almost entirely in frontend javascript 73 | * Backend storage of "metadata" is onto s3 or local filesystem with storage of 74 | actual data (data for timelines/timemaps etc) into google docs spreadsheets 75 | 76 | ## Backend Storage 77 | 78 | Layout follows frontend urls: 79 | 80 | /{username}/data.json # user info 81 | /{username}/{dataview}/datapackage.json # config for the dataview 82 | /{username}/{dataview}/... other files # (none atm but possibly we store data locally in future) 83 | 84 | ## Data View info 85 | 86 | Stored in datapackage.json following [Data Package spec][dp-spec]. Key points: 87 | 88 | * name, title, licenses etc as per [Data Package][dp-spec] 89 | * info on google doc data source stored in first resources item in format compatible with [Recline][]: 90 | 91 | resources: [{ 92 | backend: 'gdocs', 93 | url: 'gdocs url ...' 94 | }] 95 | 96 | * additional config specific to timemapper in item call tmconfig. We will 97 | be gradually adding values here but at the moment have: 98 | 99 | tmconfig: { 100 | dayfirst: false # are dates dayfirst 101 | startfrom: start # start | end | today 102 | layout: timemap # timemap | map | timeline 103 | timelineJSOptions: # options to pass to timelinejs 104 | } 105 | 106 | [dp-spec]: http://data.okfn.org/standards/data-package 107 | [Recline]: http://okfnlabs.org/recline/ 108 | 109 | ## Translation info 110 | 111 | TimeMappers uses `i18n-abide` with `gettext`. 112 | 113 | It currently supporte loclaes `en-US`, `zh-TW` locales. 114 | 115 | To update po files after modify `views/*.html` 116 | 117 | ``` 118 | $ npm run update-po 119 | ``` 120 | 121 | To re-compile translation json files. 122 | 123 | ``` 124 | $ npm run gen-po-json 125 | ``` 126 | 127 | for more details, please read [Mozilla - Localization in Action][localization-in-action]. 128 | 129 | [localization-in-action]: https://hacks.mozilla.org/2013/04/localization-in-action-part-3-of-3-a-node-js-holiday-season-part-11/ 130 | 131 | # User Stories 132 | 133 | Alice: user, who wants to create timelines, timemaps etc 134 | Bob: visitor (and potential user) 135 | Charlie: Admin of the website 136 | 137 | 138 | ## Register / Login 139 | 140 | [ip] As Alice I want to signup (using Twitter?) so that I have an account and can login 141 | 142 | As Alice I want to login (using Twitter?) so that I am identified to the system and the Vizs I create are owned by me 143 | 144 | As Alice I want to see a terms of service when I signup so that I know what the licensing arrangements are for what I create and do 145 | 146 | ## Create and Edit Views 147 | 148 | As Alice I want to create a timemap Viz quickly from a google spreadsheet ... 149 | - I want to set the title and "slug" for my timemap and have a nice url /alice/{name-of-viz} 150 | - I want to choose a license (or full copyright) - default license applied. Licenses will be open licenses or full copyright. 151 | - I want to create an animated timemap in which the time and map interact ... 152 | - I want to add a description (and attribution) to my Viz (perhaps now or later ...) 153 | 154 | As Alice I want to edit my Viz later after I've created it (e.g. change the title) so that I can correct typos or update info to reflect changes 155 | 156 | As Alice I want to create a timeline Viz quickly from my google spreadsheet so that I can share it with others 157 | 158 | As Alice I want to create a timemap / timeline quickly from a gist so that I can share it with others 159 | - Structure of gist?? 160 | 161 | As Alice I want to create a map quickly from a google spreadsheet ... 162 | 163 | As Alice or Bob I want to embed my Viz in a website elsewhere so that people can see it there 164 | 165 | As Alice I want to watch a short (video) tutorial introducing me to how this works so that I have help getting started 166 | 167 | As Alice or Bob I want to create a Viz without logging in so that I can try out the system without signing up 168 | - Is this necessary if sign up is really easy? 169 | 170 | ## Forking 171 | 172 | I want to "fork" someone elses visualization so that I can modify and extend it 173 | 174 | ## Listing and Admin 175 | 176 | [x] As Alice I want to list the "Vizs (viz?)" I've created 177 | 178 | [ip] As Bob I want to know what I can do with this service before I sign up so that I know whether it is worth doing so 179 | - Some featured timemaps ... 180 | 181 | [x] As Bob I want to see all the Vizs created by Alice so that I can see if there some I like 182 | 183 | - Most recent items ? 184 | 185 | As Bob I want to see recent activity by Alice to get a sense of the cool stuff she has been doing so that I know to look at that stuff first 186 | 187 | As Alice I want to delete a Viz so that it is not available anymore (because I don't want it visible) 188 | 189 | As Alice I want to undo deletiion of a Viz that I accidentally deleted so that it is available again 190 | 191 | As Alice I want to revert to previous versions of my Viz so that I can see what it was like before 192 | 193 | As Alice I want to "hide" a Viz so that it is not visible to others (but is visible to me) 194 | - Is this hidden in the listing or more than that? What about people who already have the url 195 | 196 | As Charlie I want to be able to delete someone's Viz (or account) so that it no longer is available (because they want it down or someone else does etc) 197 | 198 | ## Access Control 199 | 200 | As Alice I want to allow a Viz built on a private spreadsheet in google docs so that I don't have to make that spreadsheet public to create a Viz of it 201 | 202 | As Alice I want to restrict access to some of my Vizs so that only I can see them 203 | 204 | As Alice I want to restrict access to some of my Vizs but allow specific other people to view it so that other people than me can see it 205 | 206 | ## Misc 207 | 208 | As Bob I want to find out about the website and project so that I get a sense of who's behind it / whether its trustworthy / whether there is other cool stuff they do 209 | 210 | As Alice I want to know how many people have viewed my Viz so that I know whether other people are interested 211 | 212 | As Bob (? may need to be logged in) I want to "star" a Viz I come across so that I recognize its value and store it for finding later 213 | 214 | ## Asides 215 | 216 | - config 217 | - layout of the map / timeline (stacked versus side by side) 218 | - date parsing 219 | - 2 types of data source - gists as well as google docs 220 | - data package structure in gists! 221 | 222 | # License, Contributors and History 223 | 224 | This is an open-source project licensed under the MIT License - see `LICENSE` file for details. 225 | 226 | Contributors include: 227 | 228 | * Rufus Pollock (Open Knowledge) 229 | * Dan Wilson - 230 | * Chen Hsin-Yi - 231 | 232 | We also use a whole bunch of fantastic open-source libraries, including: 233 | 234 | * TimelineJS 235 | * ReclineJS 236 | * Leaflet 237 | * Backbone 238 | * Bootstrap 239 | 240 | First version was Microfacts / Weaving History 241 | which ran from 2007-2010. 242 | 243 | -------------------------------------------------------------------------------- /test/app.test.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | path = require('path') 3 | , request = require('supertest') 4 | , express = require('express') 5 | , assert = require('assert') 6 | , dao = require('../lib/dao.js') 7 | , logic = require('../lib/logic.js') 8 | , config = require('../lib/config.js') 9 | , _ = require('underscore') 10 | , base = require('./base'); 11 | ; 12 | 13 | // make sure we are in testing mode 14 | config.set('test:testing', true); 15 | config.set('twitter:key', 'test'); 16 | config.set('twitter:secret', 'test'); 17 | config.set('database:backend', 'fs'); 18 | 19 | var app = require('../app.js').app; 20 | 21 | describe('API', function() { 22 | before(function(done) { 23 | base.resetDb(); 24 | done(); 25 | }); 26 | 27 | it('Account GET', function(done) { 28 | request(app) 29 | .get('/api/account/' + 'tester') 30 | .end(function(err, res) { 31 | // console.log(res); 32 | assert.equal(res.body.email, undefined, 'Email should not be in Account object'); 33 | done(); 34 | }); 35 | }); 36 | it('DataView GET', function(done) { 37 | request(app) 38 | .get('/api/dataview/tester/napoleon') 39 | .expect('Content-Type', /json/) 40 | .end(function(err, res) { 41 | // console.log(res); 42 | assert.equal(res.body.name, 'napoleon'); 43 | assert.equal(res.body.title, 'Battles in the Napoleonic Wars'); 44 | done(); 45 | }); 46 | }); 47 | it('DataView Create Conflict if object with name already exists', function(done) { 48 | request(app) 49 | .post('/api/dataview/') 50 | .send({ 51 | owner: 'tester', 52 | name: 'napoleon' 53 | }) 54 | .expect('Content-Type', /json/) 55 | .expect(409, done) 56 | ; 57 | }); 58 | it('DataView not Authz', function(done) { 59 | var data = { 60 | owner: 'not-tester', 61 | name: 'test-api-create' 62 | }; 63 | request(app) 64 | .post('/api/dataview/') 65 | .send(data) 66 | .expect('Content-Type', /json/) 67 | .expect(401, done) 68 | ; 69 | }); 70 | var dataViewData = { 71 | owner: 'tester', 72 | name: 'test-api-create', 73 | title: 'My Test DataView', 74 | tmconfig: { 75 | // note a string false - this is what we will get via API (POSTs stringify all values!) 76 | dayfirst: 'false', 77 | startfrom: 'first' 78 | } 79 | }; 80 | it('DataView Create and Update OK', function(done) { 81 | request(app) 82 | .post('/api/dataview/') 83 | .send(dataViewData) 84 | .expect('Content-Type', /json/) 85 | .expect(200) 86 | .end(function(err, res) { 87 | assert.deepEqual(res.body, {}, 'Error on API create: ' + JSON.stringify(res.body)); 88 | var obj = dao.DataView.create({ 89 | owner: dataViewData.owner, 90 | name: dataViewData.name 91 | }); 92 | obj.fetch(function(err) { 93 | // console.log(obj); 94 | // console.log(err); 95 | assert(!err, 'New DataView exists'); 96 | assert.equal(obj.get('title'), dataViewData.title); 97 | assert.equal(obj.get('tmconfig').dayfirst, false); 98 | testUpdate(obj, done); 99 | }); 100 | }) 101 | ; 102 | }); 103 | 104 | function testUpdate(obj, done) { 105 | var newData = { 106 | title: 'my new title' 107 | }; 108 | var newobj = _.extend(obj.toJSON(), newData); 109 | request(app) 110 | .post('/api/dataview/tester/test-api-create') 111 | .send(newobj) 112 | .expect('Content-Type', /json/) 113 | .expect(200) 114 | .end(function(err, res) { 115 | assert.deepEqual(res.body, {}, 'Error on API update: ' + JSON.stringify(res.body)); 116 | var obj = dao.DataView.create({ 117 | owner: dataViewData.owner, 118 | name: dataViewData.name 119 | }); 120 | obj.fetch(function(err) { 121 | assert(!err) 122 | assert.equal(obj.get('title'), newData.title); 123 | done(); 124 | }); 125 | }) 126 | ; 127 | } 128 | 129 | after(function(done) { 130 | var obj = dao.DataView.create(dataViewData); 131 | obj.delete(function() { 132 | var dir = path.join(dao.getBackend().root, 133 | path.dirname(obj.offset()) 134 | ); 135 | fs.rmdirSync(dir); 136 | done(); 137 | }); 138 | }); 139 | // it('Account Create': function(done) { 140 | // test.expect(1); 141 | // client.fetch('POST', '/api/account', {id: 'new-test-user'}, function(res) { 142 | // test.equal(200, res.statusCode); 143 | // test.done(); 144 | // }); 145 | // } 146 | // , testAccountUpdate: function(test) { 147 | // test.expect(2); 148 | // client.fetch('PUT', '/api/account/' + base.fixturesData.user.id, {id: 'tester', name: 'my-new-name'}, function(res) { 149 | // test.equal(401, res.statusCode); 150 | // test.deepEqual(res.bodyAsObject, {"error":"Access not allowed","status":401}); 151 | // test.done(); 152 | // }); 153 | // } 154 | }); 155 | 156 | describe('API Delete', function() { 157 | before(function(done) { 158 | base.resetDb(); 159 | done(); 160 | }); 161 | 162 | it('Deletes OK', function(done) { 163 | request(app) 164 | .del('/api/dataview/tester/napoleon') 165 | .expect('Content-Type', /json/) 166 | .expect(200) 167 | .end(function(err, res) { 168 | var obj = dao.DataView.create({ 169 | owner: 'tester', 170 | name: 'napoleon' 171 | }); 172 | obj.fetch(function(err) { 173 | assert(!err); 174 | assert.equal(obj.get('state'), 'deleted'); 175 | done(); 176 | }); 177 | }); 178 | }); 179 | 180 | // TODO: ... 181 | it('Delete not allowed', function() { 182 | }); 183 | }); 184 | 185 | 186 | describe('Site', function() { 187 | before(function(done) { 188 | base.resetDb(); 189 | done(); 190 | }); 191 | after(function(done) { 192 | base.resetDb(); 193 | done(); 194 | }); 195 | 196 | it('DataView Edit OK', function(done) { 197 | request(app) 198 | .get('/tester/napoleon/edit') 199 | .end(function(err, res) { 200 | assert.ok(res.text.indexOf('Edit') != -1); 201 | done(); 202 | }); 203 | }); 204 | it('(Pre)view OK', function(done) { 205 | var url = 'https://docs.google.com/a/okfn.org/spreadsheet/ccc?key=0AqR8dXc6Ji4JdERlNXQ3ekttQk5USmFRaTFMYUNJTkE'; 206 | var title = 'Abc is the title'; 207 | request(app) 208 | .get('/view?url=' + url + '&title=' + title) 209 | .end(function(err, res) { 210 | assert.ok(res.text.indexOf(title) != -1); 211 | done(); 212 | }); 213 | }); 214 | 215 | var dataViewData = { 216 | name: 'test-api-create', 217 | title: 'My Test DataView', 218 | 'tmconfig[dayfirst]': 'false', 219 | 'tmconfig[startfrom]': 'first' 220 | }; 221 | // owner will be set to logged in user 222 | var owner = 'tester' 223 | 224 | it('DataView Create POST OK', function(done) { 225 | request(app) 226 | .post('/create') 227 | .type('form') 228 | .send(dataViewData) 229 | .expect(302) 230 | .end(function(err, res) { 231 | assert(!err, err); 232 | assert.equal(res.header['location'], '/' + owner + '/' + dataViewData.name); 233 | var obj = dao.DataView.create({ 234 | owner: owner, 235 | name: dataViewData.name 236 | }); 237 | obj.fetch(function(err) { 238 | assert(!err, 'New DataView exists'); 239 | assert.equal(obj.get('title'), dataViewData.title); 240 | assert.equal(obj.get('tmconfig').dayfirst, false); 241 | var lic = obj.get('licenses'); 242 | assert.equal(lic[0].type, 'cc-by'); 243 | done(); 244 | }); 245 | }) 246 | ; 247 | }); 248 | 249 | it('DataView Update POST OK', function(done) { 250 | var dataViewData = { 251 | title: 'Updated Data View', 252 | 'tmconfig[viewtype]': 'map', 253 | 'tmconfig[dayfirst]': 'false', 254 | 'tmconfig[startfrom]': 'first' 255 | }; 256 | var owner = 'tester'; 257 | 258 | request(app) 259 | .post('/tester/napoleon/edit') 260 | .type('form') 261 | .send(dataViewData) 262 | .expect(302) 263 | .end(function(err, res) { 264 | assert(!err, err); 265 | assert.equal(res.header['location'], '/tester/napoleon'); 266 | var obj = dao.DataView.create({ 267 | owner: owner, 268 | name: 'napoleon' 269 | }); 270 | obj.fetch(function(err) { 271 | assert(!err, 'DataView exists'); 272 | assert.equal(obj.get('title'), dataViewData.title); 273 | assert.equal(obj.get('tmconfig').dayfirst, false); 274 | assert.equal(obj.get('tmconfig').viewtype, 'map'); 275 | // existing data unchanged 276 | var lic = obj.get('licenses'); 277 | assert.equal(lic[0].type, 'cc-by-sa'); 278 | done(); 279 | }); 280 | }) 281 | ; 282 | }); 283 | }); 284 | 285 | describe('Site - Anonymous Mode', function() { 286 | before(function(done) { 287 | base.resetDb(); 288 | // unset testing mode so that we are not logged in 289 | config.set('test:testing', false); 290 | done(); 291 | }); 292 | after(function(done) { 293 | // set back to testing 294 | config.set('test:testing', true); 295 | done(); 296 | }); 297 | 298 | var dataViewData = { 299 | title: 'My Test DataView', 300 | 'tmconfig[dayfirst]': 'false', 301 | 'tmconfig[startfrom]': 'first' 302 | }; 303 | // owner will be set to logged in user 304 | var owner = 'anon' 305 | it('Create POST OK', function(done) { 306 | request(app) 307 | .post('/create') 308 | .type('form') 309 | .send(dataViewData) 310 | .expect(302) 311 | .end(function(err, res) { 312 | assert(!err, err); 313 | assert.equal(res.header['location'].indexOf('/' + owner), 0); 314 | var name = res.header['location'].split('/')[2] 315 | var obj = dao.DataView.create({ 316 | owner: owner, 317 | name: name 318 | }); 319 | obj.fetch(function(err) { 320 | assert(!err, 'New DataView exists'); 321 | assert.equal(obj.get('title'), dataViewData.title); 322 | assert.equal(obj.get('tmconfig').dayfirst, false); 323 | var lic = obj.get('licenses'); 324 | assert.equal(lic[0].type, 'cc-by'); 325 | done(); 326 | }); 327 | }) 328 | ; 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /lib/dao.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | , path = require('path') 3 | , util = require('./util') 4 | , config = require('./config') 5 | , knox = require('knox') 6 | , async = require('async') 7 | ; 8 | 9 | // ================================= 10 | // Object oriented helpers 11 | 12 | function clone(object) { 13 | function OneShotConstructor(){} 14 | OneShotConstructor.prototype = object; 15 | return new OneShotConstructor(); 16 | } 17 | 18 | function forEachIn(object, action) { 19 | for (var property in object) { 20 | if (object.hasOwnProperty(property)) 21 | action(property, object[property]); 22 | } 23 | } 24 | 25 | // ================================= 26 | // DAO Helpers 27 | 28 | // QueryResult object 29 | // 30 | // Encapsulate result from ElasticSearch queries and providing helper methods 31 | // (like toJSON) 32 | var QueryResult = function(type, data) { 33 | this.type = getDomainObjectClass(type); 34 | this.data = data; 35 | this.total = this.data.hits.total; 36 | this.results = []; 37 | for (i=0;i 0) { 47 | return this.results[0]; 48 | } else { 49 | return null; 50 | } 51 | } 52 | 53 | QueryResult.prototype.toJSON = function() { 54 | var out = { 55 | total: this.total 56 | , results: [] 57 | }; 58 | for (i=0;i'; 20 | $('.embed-modal textarea').val(val); 21 | $('.embed-modal').modal(); 22 | }); 23 | }); 24 | 25 | var TimeMapperView = Backbone.View.extend({ 26 | events: { 27 | 'click .controls .js-show-toolbox': '_onShowToolbox', 28 | 'submit .toolbox form': '_onSearch' 29 | }, 30 | 31 | initialize: function(options) { 32 | var self = this; 33 | this._setupOnHashChange(); 34 | 35 | this.datapackage = options.datapackage; 36 | // fix up for datapackage without right structure 37 | if (!this.datapackage.tmconfig) { 38 | this.datapackage.tmconfig = {}; 39 | } 40 | this.timelineState = _.extend({}, this.datapackage.tmconfig.timeline, { 41 | nonUSDates: this.datapackage.tmconfig.dayfirst, 42 | timelineJSOptions: _.extend({}, this.datapackage.tmconfig.timelineJSOptions, { 43 | "hash_bookmark": true 44 | }) 45 | }); 46 | this._setupTwitter(); 47 | 48 | // now load the data 49 | this.model.fetch().done(function() { 50 | self.model.query({size: self.model.recordCount}) 51 | .done(function() { 52 | self._dataChanges(); 53 | self._setStartPosition(); 54 | self._onDataLoaded(); 55 | }); 56 | }); 57 | }, 58 | 59 | _setStartPosition: function() { 60 | var startAtSlide = 0; 61 | switch (this.datapackage.tmconfig.startfrom) { 62 | case 'start': 63 | // done 64 | break; 65 | case 'end': 66 | startAtSlide = this.model.recordCount - 1; 67 | break; 68 | case 'today': 69 | var dateToday = new Date(); 70 | this.model.records.each(function(rec, i) { 71 | if (rec.get('startParsed') < dateToday) { 72 | startAtSlide = i; 73 | } 74 | }); 75 | break; 76 | } 77 | this.timelineState.timelineJSOptions = _.extend(this.timelineState.timelineJSOptions, { 78 | "start_at_slide": startAtSlide 79 | } 80 | ); 81 | }, 82 | 83 | _onDataLoaded: function() { 84 | $('.js-loading').hide(); 85 | 86 | // Note: We *have* to postpone setup until now as otherwise timeline 87 | // might try to navigate to a non-existent marker 88 | if (this.datapackage.tmconfig.viewtype === 'timeline') { 89 | // timeline only 90 | $('body').addClass('viewtype-timeline'); 91 | // fix height of timeline to be window height minus navbar and footer 92 | $('.timeline-pane').height($(window).height() - 42 - 41); 93 | this._setupTimeline(); 94 | } else if (this.datapackage.tmconfig.viewtype === 'map') { 95 | $('body').addClass('viewtype-map'); 96 | this._setupMap(); 97 | } else { 98 | $('body').addClass('viewtype-timemap'); 99 | this._setupTimeline(); 100 | this._setupMap(); 101 | } 102 | 103 | // Nasty hack. Timeline ignores hashchange events unless is_moving == 104 | // True. However, once it's True, it can never become false again. The 105 | // callback associated with the UPDATE event sets it to True, but is 106 | // otherwise a no-op. 107 | $("div.slider").trigger("UPDATE"); 108 | }, 109 | 110 | _setupTwitter: function(e) { 111 | !function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0];if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src="//platform.twitter.com/widgets.js";fjs.parentNode.insertBefore(js,fjs);}}(document,"script","twitter-wjs"); 112 | }, 113 | 114 | _dataChanges: function() { 115 | var self = this; 116 | this.model.records.each(function(record) { 117 | // normalize date field names 118 | console.log(record) 119 | if (record.get('startdate') || record.get('start date') && !record.get('start')) { 120 | record.set({ 121 | start: record.get('startdate') || record.get('start date'), 122 | end: record.get('enddate') || record.get('end date') 123 | }, {silent: true} 124 | ); 125 | } 126 | var startDate = VMM.Date.parse(normalizeDate(record.get("start"), self.datapackage.tmconfig.dayfirst)); 127 | var data = { 128 | // VMM.Date.parse is the timelinejs date parser 129 | startParsed: startDate, 130 | title: record.get('title') || record.get('headline'), 131 | description: record.get('description') || record.get('text') || '', 132 | url: record.get('url') || record.get('web page'), 133 | media: record.get('image') || record.get('media'), 134 | mediacaption: record.get('caption') || record.get('media caption') || record.get('image caption'), 135 | mediacredit: record.get('image credit') || record.get('media credit'), 136 | }; 137 | if (record.get('size') || record.get('size') === 0) { 138 | data.size = parseFloat(record.get('size')); 139 | } 140 | record.set(data, { silent: true }); 141 | }); 142 | 143 | var starts = this.model.records.pluck('startParsed') 144 | , minDate = _.min(starts) 145 | , maxDate = _.max(starts) 146 | , dateRange = maxDate - minDate 147 | , sizes = this.model.records.pluck('size') 148 | , maxSize = _.max(sizes) 149 | ; 150 | // set opacity - we compute opacity between 0.1 and 1 based on distance from most recent date 151 | var minOpacity = 0.3 152 | , opacityRange = 1.0 - minOpacity 153 | ; 154 | this.model.records.each(function(rec) { 155 | var temporalRangeLocation = (rec.get('startParsed') - minDate) / dateRange; 156 | rec.set({ 157 | temporalRangeLocation: temporalRangeLocation, 158 | opacity: minOpacity + (opacityRange * temporalRangeLocation), 159 | relativeSize: parseFloat(rec.get('size')) / maxSize 160 | }); 161 | }); 162 | 163 | // Timeline will sort the entries by timestamp, and we need the order to be 164 | // the same for the map which runs off the model 165 | this.model.records.comparator = function (a, b) { 166 | return a.get('startParsed') - b.get('startParsed'); 167 | }; 168 | this.model.records.sort(); 169 | }, 170 | 171 | _setupOnHashChange: function() { 172 | var self = this; 173 | // listen for hashchange to update map 174 | $(window).on("hashchange", function () { 175 | var hash = window.location.hash.substring(1); 176 | if (parseInt(hash, 10)) { 177 | var record = self.model.records.at(hash); 178 | if (record && record.marker) { 179 | record.marker.openPopup(); 180 | } 181 | } 182 | }); 183 | }, 184 | 185 | _onShowToolbox: function(e) { 186 | e.preventDefault(); 187 | if (this.$el.find('.toolbox').hasClass('hideme')) { 188 | this.$el.find('.toolbox').removeClass('hideme'); 189 | } else { 190 | this.$el.find('.toolbox').addClass('hideme'); 191 | } 192 | }, 193 | 194 | _onSearch: function(e) { 195 | e.preventDefault(); 196 | var query = this.$el.find('.text-query input').val(); 197 | this.model.query({q: query}); 198 | }, 199 | 200 | _setupTimeline: function() { 201 | this.timeline = new recline.View.Timeline({ 202 | model: this.model, 203 | el: this.$el.find('.timeline'), 204 | state: this.timelineState 205 | }); 206 | 207 | // convert the record to a structure suitable for timeline.js 208 | this.timeline.convertRecord = function(record, fields) { 209 | if (record.get('startParsed') == 'Invalid Date') { 210 | if (typeof console !== "undefined" && console.warn) { 211 | console.warn('Failed to extract date from record'); 212 | console.warn(record.toJSON()); 213 | } 214 | return null; 215 | } 216 | try { 217 | var out = this._convertRecord(record, fields); 218 | } catch (e) { 219 | out = null; 220 | } 221 | if (!out) { 222 | if (typeof console !== "undefined" && console.warn) { 223 | console.warn('Failed to extract timeline entry from record'); 224 | console.warn(record.toJSON()); 225 | } 226 | return null; 227 | } 228 | if (record.get('media')) { 229 | out.asset = { 230 | media: record.get('media'), 231 | caption: record.get('mediacaption'), 232 | credit: record.get('mediacredit'), 233 | thumbnail: record.get('icon') 234 | }; 235 | } 236 | out.headline = record.get('title'); 237 | if (record.get('url')) { 238 | out.headline = '%headline ' 239 | .replace(/%url/g, record.get('url')) 240 | .replace(/%headline/g, out.headline) 241 | ; 242 | } 243 | out.text = record.get('description'); 244 | if (record.get('source') || record.get('sourceurl') || record.get('source url')) { 245 | var s = record.get('source') || record.get('sourceurl') || record.get('source url'); 246 | if (record.get('source url')) { 247 | s = '' + s + ''; 248 | } 249 | out.text += '

    Source: ' + s + '

    '; 250 | } 251 | 252 | return out; 253 | }; 254 | this.timeline.render(); 255 | }, 256 | 257 | _setupMap: function() { 258 | this.map = new recline.View.Map({ 259 | model: this.model 260 | }); 261 | this.$el.find('.map').append(this.map.el); 262 | 263 | // customize with icon column 264 | this.map.infobox = function(record) { 265 | if (record.icon !== undefined) { 266 | return ' ' + record.get('title'); 267 | } 268 | return record.get('title'); 269 | }; 270 | 271 | this.map.geoJsonLayerOptions.pointToLayer = function(feature, latlng) { 272 | var record = this.model.records.get(feature.properties.cid); 273 | var recordAttr = record.toJSON(); 274 | var maxSize = 400; 275 | var radius = parseInt(Math.sqrt(maxSize * recordAttr.relativeSize)); 276 | if (radius) { 277 | var marker = new L.CircleMarker(latlng, { 278 | radius: radius, 279 | fillcolor: '#fe9131', 280 | color: '#fe9131', 281 | opacity: recordAttr.opacity, 282 | fillOpacity: recordAttr.opacity * 0.9 283 | }); 284 | } else { 285 | var marker = new L.Marker(latlng, { 286 | opacity: recordAttr.opacity 287 | }); 288 | } 289 | var label = recordAttr.title + '
    Date: ' + recordAttr.start; 290 | if (recordAttr.size) { 291 | label += '
    Size: ' + recordAttr.size; 292 | } 293 | marker.bindLabel(label); 294 | 295 | // customize with icon column 296 | if (recordAttr.icon !== undefined) { 297 | var eventIcon = L.icon({ 298 | iconUrl: recordAttr.icon, 299 | iconSize: [100, 20], // size of the icon 300 | iconAnchor: [22, 94], // point of the icon which will correspond to marker's location 301 | shadowAnchor: [4, 62], // the same for the shadow 302 | popupAnchor: [-3, -76] // point from which the popup should open relative to the iconAnchor 303 | }); 304 | marker.setIcon(eventIcon); 305 | } 306 | 307 | // this is for cluster case 308 | // this.markers.addLayer(marker); 309 | 310 | // When a marker is clicked, update the fragment id, which will in turn update the timeline 311 | marker.on("click", function (e) { 312 | var i = _.indexOf(record.collection.models, record); 313 | window.location.hash = "#" + i.toString(); 314 | }); 315 | 316 | // Stored so that we can get from record to marker in hashchange callback 317 | record.marker = marker; 318 | 319 | return marker; 320 | }; 321 | this.map.render(); 322 | } 323 | }); 324 | 325 | // convert dates into a format TimelineJS will handle 326 | // TimelineJS does not document this at all so combo of read the code + 327 | // trial and error 328 | // Summary (AFAICt): 329 | // Preferred: [-]yyyy[,mm,dd,hh,mm,ss] 330 | // Supported: mm/dd/yyyy 331 | var normalizeDate = function(date, dayfirst) { 332 | if (!date) { 333 | return ''; 334 | } 335 | var out = $.trim(date); 336 | // HACK: support people who put '2013-08-20 in gdocs (to force gdocs to 337 | // not attempt to parse the date) 338 | if (out.length && out[0] === "'") { 339 | out = out.slice(1); 340 | } 341 | out = out.replace(/(\d)th/g, '$1'); 342 | out = out.replace(/(\d)st/g, '$1'); 343 | out = $.trim(out); 344 | if (out.match(/\d\d\d\d-\d\d-\d\d(T.*)?/)) { 345 | out = out.replace(/-/g, ',').replace('T', ',').replace(':',','); 346 | } 347 | if (out.match(/\d\d-\d\d-\d\d.*/)) { 348 | out = out.replace(/-/g, '/'); 349 | } 350 | if (dayfirst) { 351 | var parts = out.match(/(\d\d)\/(\d\d)\/(\d\d.*)/); 352 | if (parts) { 353 | out = [parts[2], parts[1], parts[3]].join('/'); 354 | } 355 | } 356 | return out; 357 | } 358 | 359 | })(); 360 | -------------------------------------------------------------------------------- /locale/en_US/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "messages": { 3 | "": { 4 | "Project-Id-Version": " PACKAGE VERSION\nPOT-Creation-Date: 2014-05-31 17:46+0000\nPO-Revision-Date: 2014-05-30 21:10+0800\nLast-Translator: 陳信屹 \nLanguage-Team: English\nLanguage: en_US\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nPlural-Forms: nplurals=2; plural=(n != 1);\n" 5 | }, 6 | "It's free and easy to use": [ 7 | null, 8 | "It's free and easy to use" 9 | ], 10 | "Get started now": [ 11 | null, 12 | "Get started now" 13 | ], 14 | "Watch the 1 minute Tutorial": [ 15 | null, 16 | "Watch the 1 minute Tutorial" 17 | ], 18 | "Examples": [ 19 | null, 20 | "Examples" 21 | ], 22 | "How It Works": [ 23 | null, 24 | "How It Works" 25 | ], 26 | "1. Create a Spreadsheet": [ 27 | null, 28 | "1. Create a Spreadsheet" 29 | ], 30 | "Add your dates and places to a Google Spreadsheet.": [ 31 | null, 32 | "Add your dates and places to a Google Spreadsheet." 33 | ], 34 | "2. Connect and Customize": [ 35 | null, 36 | "2. Connect and Customize" 37 | ], 38 | "Connect your spreadsheet with TimeMapper and customize the results.": [ 39 | null, 40 | "Connect your spreadsheet with TimeMapper and customize the results." 41 | ], 42 | "3. Publish, Embed and Share": [ 43 | null, 44 | "3. Publish, Embed and Share" 45 | ], 46 | "Publish your TimeMap at your own personal url, then share or embed on your site.": [ 47 | null, 48 | "Publish your TimeMap at your own personal url, then share or embed on your site." 49 | ], 50 | "Credits": [ 51 | null, 52 | "Credits" 53 | ], 54 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs.": [ 55 | null, 56 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs." 57 | ], 58 | "It is possible thanks to a set of awesome open-source components including TimelineJS, ReclineJS, Leaflet, Backbone and Bootstrap. You can find the full open-source source for TimeMapper on GitHub here": [ 59 | null, 60 | "It is possible thanks to a set of awesome open-source components including TimelineJS, ReclineJS, Leaflet, Backbone and Bootstrap. You can find the full open-source source for TimeMapper on GitHub here" 61 | ], 62 | "Create your": [ 63 | null, 64 | "" 65 | ], 66 | "Let's Get Started": [ 67 | null, 68 | "Get started now" 69 | ], 70 | "1. Create your Spreadsheet": [ 71 | null, 72 | "1. Create a Spreadsheet" 73 | ], 74 | "if you don't have one already!": [ 75 | null, 76 | "" 77 | ], 78 | "Get started by copying . For more details or help with problems check out the FAQ below.

    ": [ 79 | null, 80 | "" 81 | ], 82 | "Impatient to try this out but don't have a spreadsheet yet?": [ 83 | null, 84 | "" 85 | ], 86 | "Click here to use a pre-prepared example": [ 87 | null, 88 | "" 89 | ], 90 | "ALERT: you are not signed in so your timemap will be created 'anonymously'.": [ 91 | null, 92 | "" 93 | ], 94 | "3. Let's Publish It!": [ 95 | null, 96 | "" 97 | ], 98 | "Publish": [ 99 | null, 100 | "" 101 | ], 102 | "FAQ": [ 103 | null, 104 | "" 105 | ], 106 | "Can I make a timemap anonymously?": [ 107 | null, 108 | "" 109 | ], 110 | "If want to 'own' your timemap you should sign in (or sign-up) now »": [ 111 | null, 112 | "" 113 | ], 114 | "(Sign-up takes a few seconds with your twitter account »)": [ 115 | null, 116 | "" 117 | ], 118 | "Yes! You do not need an account to create a timemap - they can be created anonymously and will have all the same features and shareability of normal timemaps. However, there are some benefits of creating an account and creating your timemap whilst logged in:": [ 119 | null, 120 | "" 121 | ], 122 | "You'll get a nice URL for your timemap at /your-username/a-name-you-choose-for-your-timemap": [ 123 | null, 124 | "" 125 | ], 126 | "All of your timemaps will be nicely listed at /your-username": [ 127 | null, 128 | "" 129 | ], 130 | "As you'll be identified as the owner you'll be able to re-configure (or delete) your timemap later": [ 131 | null, 132 | "" 133 | ], 134 | "If you do want an account, signup is very easy": [ 135 | null, 136 | "" 137 | ], 138 | "it takes just 15 seconds, is very secure, and uses your Twitter account": [ 139 | null, 140 | "" 141 | ], 142 | "(no need to think up a new username and password!).": [ 143 | null, 144 | "" 145 | ], 146 | "Go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. This tutorial walks you through.": [ 147 | null, 148 | "" 149 | ], 150 | "What URL do I use to connect my spreadsheet?": [ 151 | null, 152 | "" 153 | ], 154 | "Use the URL you get by clicking your spreadsheet's Share button and copying the Link to share box.": [ 155 | null, 156 | "" 157 | ], 158 | "Note that although you must also Publish to the web, TimeMapper does not use the URL found in the publication pop-up.": [ 159 | null, 160 | "" 161 | ], 162 | "What structure must the spreadsheet have?": [ 163 | null, 164 | "" 165 | ], 166 | "TimeMapper recognizes certain columns with specific names. The best overview of these columns is the template, which has rows of instructions and examples.": [ 167 | null, 168 | "" 169 | ], 170 | "Not all fields are required. The only required fields are Title and Start fields, and even Start can be omitted if you just want a map. Note that you can add any number of other columns beyond those that TimeMapper uses.": [ 171 | null, 172 | "" 173 | ], 174 | "How do I format dates?": [ 175 | null, 176 | "" 177 | ], 178 | "The preferred date format is ISO 8601 (YYYY-MM-DD), but TimeMapper recognizes most types of date.": [ 179 | null, 180 | "" 181 | ], 182 | "If a date's month and day are ambiguous (e.g. is 08-03-1798 UK notation for 8 March, or is it US notation for 3 August?), by default, the first number will be interpreted as the month. You can change this by clicking the edit button in the top right corner of your TimeMap's display and selecting between US- and non-US-style dates.": [ 183 | null, 184 | "" 185 | ], 186 | "What kinds of geodata are supported?": [ 187 | null, 188 | "" 189 | ], 190 | "The Location column accepts two types of geodata: latitude-longitude coordinates or GeoJSON objects.": [ 191 | null, 192 | "" 193 | ], 194 | "Coordinates must be in the format lat, long (e.g. 37.5, -122). The spreadsheet template includes a formula which automatically looks up coordinates corresponding to human-readable place names in the Place column. This formula is explained in a School of Data blog post.": [ 195 | null, 196 | "" 197 | ], 198 | "Advanced users who want to go beyond simple coordinates can use GeoJSON feature objects. For an example, see this blog post on adding GeoJSON country boundaries to spreadsheets.": [ 199 | null, 200 | "" 201 | ], 202 | "It's as easy as 1-2-3!": [ 203 | null, 204 | "" 205 | ], 206 | "Edit - ": [ 207 | null, 208 | "" 209 | ], 210 | "Edit your ": [ 211 | null, 212 | "" 213 | ], 214 | "Dashboard": [ 215 | null, 216 | "" 217 | ], 218 | "Hi there": [ 219 | null, 220 | "" 221 | ], 222 | "Create a new Timeline or TimeMap": [ 223 | null, 224 | "" 225 | ], 226 | "Your Existing TimeMaps": [ 227 | null, 228 | "" 229 | ], 230 | "view": [ 231 | null, 232 | "" 233 | ], 234 | "embed": [ 235 | null, 236 | "" 237 | ], 238 | "Embed": [ 239 | null, 240 | "" 241 | ], 242 | "Edit": [ 243 | null, 244 | "" 245 | ], 246 | "Search data ...": [ 247 | null, 248 | "" 249 | ], 250 | "Embed Instructions": [ 251 | null, 252 | "" 253 | ], 254 | "Copy and paste the following into your web page": [ 255 | null, 256 | "" 257 | ], 258 | "Loading data...": [ 259 | null, 260 | "" 261 | ], 262 | "using": [ 263 | null, 264 | "" 265 | ], 266 | "License": [ 267 | null, 268 | "" 269 | ], 270 | "Source Data": [ 271 | null, 272 | "" 273 | ], 274 | "Data Source": [ 275 | null, 276 | "" 277 | ], 278 | "Select from Your Google Drive": [ 279 | null, 280 | "" 281 | ], 282 | "If nothing happens check you are not blocking popups ...": [ 283 | null, 284 | "" 285 | ], 286 | "Important": [ 287 | null, 288 | "" 289 | ], 290 | "you must \"publish\" your Google Spreadsheet: go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. See": [ 291 | null, 292 | "" 293 | ], 294 | "the FAQ below": [ 295 | null, 296 | "" 297 | ], 298 | "for more details": [ 299 | null, 300 | "" 301 | ], 302 | "Title": [ 303 | null, 304 | "" 305 | ], 306 | "Slug": [ 307 | null, 308 | "" 309 | ], 310 | "The url of your new timemap. This must be different from the name for any of your existing timemaps. Choose wisely as this is hard to change!": [ 311 | null, 312 | "" 313 | ], 314 | "Type of Data View": [ 315 | null, 316 | "" 317 | ], 318 | "Choose the visualization type of your data - TimeMap (Timeline and Map combined), Timeline or Map.": [ 319 | null, 320 | "" 321 | ], 322 | "More Options": [ 323 | null, 324 | "" 325 | ], 326 | "Ambiguous Date Handling": [ 327 | null, 328 | "" 329 | ], 330 | "month first (US style)": [ 331 | null, 332 | "" 333 | ], 334 | "day first (non-US style)": [ 335 | null, 336 | "" 337 | ], 338 | "How to handle ambiguous dates like \"05/08/2012\" in source data (could be read as 5th August or 8th of May).": [ 339 | null, 340 | "" 341 | ], 342 | "If you do not have any dates formatted like this then you can ignore this!": [ 343 | null, 344 | "" 345 | ], 346 | "Start from": [ 347 | null, 348 | "" 349 | ], 350 | "Where on the timeline should the user start.": [ 351 | null, 352 | "" 353 | ], 354 | "TimeMapper - Make Timelines and TimeMaps fast!": [ 355 | null, 356 | "" 357 | ], 358 | "from the Open Knowledge Foundation Labs": [ 359 | null, 360 | "" 361 | ], 362 | "TimeMapper - Make Timelines and TimeMaps fast! - from the Open Knowledge Foundation Labs": [ 363 | null, 364 | "" 365 | ], 366 | "An Open Knowledge Foundation Labs Project": [ 367 | null, 368 | "" 369 | ], 370 | "Contact Us": [ 371 | null, 372 | "" 373 | ], 374 | "Report an Issue": [ 375 | null, 376 | "" 377 | ], 378 | "The TimeMapper is a project of Open Knowledge Foundation Labs": [ 379 | null, 380 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs." 381 | ], 382 | "TimeMapper is open-source": [ 383 | null, 384 | "" 385 | ], 386 | "Source Code": [ 387 | null, 388 | "" 389 | ], 390 | "Copyright": [ 391 | null, 392 | "" 393 | ], 394 | "Find out more on anonymous vs logged in": [ 395 | null, 396 | "" 397 | ], 398 | "read FAQ below": [ 399 | null, 400 | "" 401 | ], 402 | "Title for your View": [ 403 | null, 404 | "" 405 | ], 406 | "The slug needs to be 'url-usable' and so must be lowercase containing only alphanumeric characters and '-'": [ 407 | null, 408 | "" 409 | ], 410 | "Elegant timelines and maps created in seconds": [ 411 | null, 412 | "" 413 | ], 414 | "create": [ 415 | null, 416 | "" 417 | ], 418 | "Your Spreadsheet": [ 419 | null, 420 | "1. Create a Spreadsheet" 421 | ], 422 | "Update": [ 423 | null, 424 | "" 425 | ], 426 | "Or paste the Google Spreadsheet URL directly": [ 427 | null, 428 | "" 429 | ] 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /locale/en_US/messages.js: -------------------------------------------------------------------------------- 1 | ;var json_locale_data = { 2 | "messages": { 3 | "": { 4 | "Project-Id-Version": " PACKAGE VERSION\nPOT-Creation-Date: 2014-05-31 17:46+0000\nPO-Revision-Date: 2014-05-30 21:10+0800\nLast-Translator: 陳信屹 \nLanguage-Team: English\nLanguage: en_US\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nPlural-Forms: nplurals=2; plural=(n != 1);\n" 5 | }, 6 | "It's free and easy to use": [ 7 | null, 8 | "It's free and easy to use" 9 | ], 10 | "Get started now": [ 11 | null, 12 | "Get started now" 13 | ], 14 | "Watch the 1 minute Tutorial": [ 15 | null, 16 | "Watch the 1 minute Tutorial" 17 | ], 18 | "Examples": [ 19 | null, 20 | "Examples" 21 | ], 22 | "How It Works": [ 23 | null, 24 | "How It Works" 25 | ], 26 | "1. Create a Spreadsheet": [ 27 | null, 28 | "1. Create a Spreadsheet" 29 | ], 30 | "Add your dates and places to a Google Spreadsheet.": [ 31 | null, 32 | "Add your dates and places to a Google Spreadsheet." 33 | ], 34 | "2. Connect and Customize": [ 35 | null, 36 | "2. Connect and Customize" 37 | ], 38 | "Connect your spreadsheet with TimeMapper and customize the results.": [ 39 | null, 40 | "Connect your spreadsheet with TimeMapper and customize the results." 41 | ], 42 | "3. Publish, Embed and Share": [ 43 | null, 44 | "3. Publish, Embed and Share" 45 | ], 46 | "Publish your TimeMap at your own personal url, then share or embed on your site.": [ 47 | null, 48 | "Publish your TimeMap at your own personal url, then share or embed on your site." 49 | ], 50 | "Credits": [ 51 | null, 52 | "Credits" 53 | ], 54 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs.": [ 55 | null, 56 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs." 57 | ], 58 | "It is possible thanks to a set of awesome open-source components including TimelineJS, ReclineJS, Leaflet, Backbone and Bootstrap. You can find the full open-source source for TimeMapper on GitHub here": [ 59 | null, 60 | "It is possible thanks to a set of awesome open-source components including TimelineJS, ReclineJS, Leaflet, Backbone and Bootstrap. You can find the full open-source source for TimeMapper on GitHub here" 61 | ], 62 | "Create your": [ 63 | null, 64 | "" 65 | ], 66 | "Let's Get Started": [ 67 | null, 68 | "Get started now" 69 | ], 70 | "1. Create your Spreadsheet": [ 71 | null, 72 | "1. Create a Spreadsheet" 73 | ], 74 | "if you don't have one already!": [ 75 | null, 76 | "" 77 | ], 78 | "Get started by copying . For more details or help with problems check out the FAQ below.

    ": [ 79 | null, 80 | "" 81 | ], 82 | "Impatient to try this out but don't have a spreadsheet yet?": [ 83 | null, 84 | "" 85 | ], 86 | "Click here to use a pre-prepared example": [ 87 | null, 88 | "" 89 | ], 90 | "ALERT: you are not signed in so your timemap will be created 'anonymously'.": [ 91 | null, 92 | "" 93 | ], 94 | "3. Let's Publish It!": [ 95 | null, 96 | "" 97 | ], 98 | "Publish": [ 99 | null, 100 | "" 101 | ], 102 | "FAQ": [ 103 | null, 104 | "" 105 | ], 106 | "Can I make a timemap anonymously?": [ 107 | null, 108 | "" 109 | ], 110 | "If want to 'own' your timemap you should sign in (or sign-up) now »": [ 111 | null, 112 | "" 113 | ], 114 | "(Sign-up takes a few seconds with your twitter account »)": [ 115 | null, 116 | "" 117 | ], 118 | "Yes! You do not need an account to create a timemap - they can be created anonymously and will have all the same features and shareability of normal timemaps. However, there are some benefits of creating an account and creating your timemap whilst logged in:": [ 119 | null, 120 | "" 121 | ], 122 | "You'll get a nice URL for your timemap at /your-username/a-name-you-choose-for-your-timemap": [ 123 | null, 124 | "" 125 | ], 126 | "All of your timemaps will be nicely listed at /your-username": [ 127 | null, 128 | "" 129 | ], 130 | "As you'll be identified as the owner you'll be able to re-configure (or delete) your timemap later": [ 131 | null, 132 | "" 133 | ], 134 | "If you do want an account, signup is very easy": [ 135 | null, 136 | "" 137 | ], 138 | "it takes just 15 seconds, is very secure, and uses your Twitter account": [ 139 | null, 140 | "" 141 | ], 142 | "(no need to think up a new username and password!).": [ 143 | null, 144 | "" 145 | ], 146 | "Go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. This tutorial walks you through.": [ 147 | null, 148 | "" 149 | ], 150 | "What URL do I use to connect my spreadsheet?": [ 151 | null, 152 | "" 153 | ], 154 | "Use the URL you get by clicking your spreadsheet's Share button and copying the Link to share box.": [ 155 | null, 156 | "" 157 | ], 158 | "Note that although you must also Publish to the web, TimeMapper does not use the URL found in the publication pop-up.": [ 159 | null, 160 | "" 161 | ], 162 | "What structure must the spreadsheet have?": [ 163 | null, 164 | "" 165 | ], 166 | "TimeMapper recognizes certain columns with specific names. The best overview of these columns is the template, which has rows of instructions and examples.": [ 167 | null, 168 | "" 169 | ], 170 | "Not all fields are required. The only required fields are Title and Start fields, and even Start can be omitted if you just want a map. Note that you can add any number of other columns beyond those that TimeMapper uses.": [ 171 | null, 172 | "" 173 | ], 174 | "How do I format dates?": [ 175 | null, 176 | "" 177 | ], 178 | "The preferred date format is ISO 8601 (YYYY-MM-DD), but TimeMapper recognizes most types of date.": [ 179 | null, 180 | "" 181 | ], 182 | "If a date's month and day are ambiguous (e.g. is 08-03-1798 UK notation for 8 March, or is it US notation for 3 August?), by default, the first number will be interpreted as the month. You can change this by clicking the edit button in the top right corner of your TimeMap's display and selecting between US- and non-US-style dates.": [ 183 | null, 184 | "" 185 | ], 186 | "What kinds of geodata are supported?": [ 187 | null, 188 | "" 189 | ], 190 | "The Location column accepts two types of geodata: latitude-longitude coordinates or GeoJSON objects.": [ 191 | null, 192 | "" 193 | ], 194 | "Coordinates must be in the format lat, long (e.g. 37.5, -122). The spreadsheet template includes a formula which automatically looks up coordinates corresponding to human-readable place names in the Place column. This formula is explained in a School of Data blog post.": [ 195 | null, 196 | "" 197 | ], 198 | "Advanced users who want to go beyond simple coordinates can use GeoJSON feature objects. For an example, see this blog post on adding GeoJSON country boundaries to spreadsheets.": [ 199 | null, 200 | "" 201 | ], 202 | "It's as easy as 1-2-3!": [ 203 | null, 204 | "" 205 | ], 206 | "Edit - ": [ 207 | null, 208 | "" 209 | ], 210 | "Edit your ": [ 211 | null, 212 | "" 213 | ], 214 | "Dashboard": [ 215 | null, 216 | "" 217 | ], 218 | "Hi there": [ 219 | null, 220 | "" 221 | ], 222 | "Create a new Timeline or TimeMap": [ 223 | null, 224 | "" 225 | ], 226 | "Your Existing TimeMaps": [ 227 | null, 228 | "" 229 | ], 230 | "view": [ 231 | null, 232 | "" 233 | ], 234 | "embed": [ 235 | null, 236 | "" 237 | ], 238 | "Embed": [ 239 | null, 240 | "" 241 | ], 242 | "Edit": [ 243 | null, 244 | "" 245 | ], 246 | "Search data ...": [ 247 | null, 248 | "" 249 | ], 250 | "Embed Instructions": [ 251 | null, 252 | "" 253 | ], 254 | "Copy and paste the following into your web page": [ 255 | null, 256 | "" 257 | ], 258 | "Loading data...": [ 259 | null, 260 | "" 261 | ], 262 | "using": [ 263 | null, 264 | "" 265 | ], 266 | "License": [ 267 | null, 268 | "" 269 | ], 270 | "Source Data": [ 271 | null, 272 | "" 273 | ], 274 | "Data Source": [ 275 | null, 276 | "" 277 | ], 278 | "Select from Your Google Drive": [ 279 | null, 280 | "" 281 | ], 282 | "If nothing happens check you are not blocking popups ...": [ 283 | null, 284 | "" 285 | ], 286 | "Important": [ 287 | null, 288 | "" 289 | ], 290 | "you must \"publish\" your Google Spreadsheet: go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. See": [ 291 | null, 292 | "" 293 | ], 294 | "the FAQ below": [ 295 | null, 296 | "" 297 | ], 298 | "for more details": [ 299 | null, 300 | "" 301 | ], 302 | "Title": [ 303 | null, 304 | "" 305 | ], 306 | "Slug": [ 307 | null, 308 | "" 309 | ], 310 | "The url of your new timemap. This must be different from the name for any of your existing timemaps. Choose wisely as this is hard to change!": [ 311 | null, 312 | "" 313 | ], 314 | "Type of Data View": [ 315 | null, 316 | "" 317 | ], 318 | "Choose the visualization type of your data - TimeMap (Timeline and Map combined), Timeline or Map.": [ 319 | null, 320 | "" 321 | ], 322 | "More Options": [ 323 | null, 324 | "" 325 | ], 326 | "Ambiguous Date Handling": [ 327 | null, 328 | "" 329 | ], 330 | "month first (US style)": [ 331 | null, 332 | "" 333 | ], 334 | "day first (non-US style)": [ 335 | null, 336 | "" 337 | ], 338 | "How to handle ambiguous dates like \"05/08/2012\" in source data (could be read as 5th August or 8th of May).": [ 339 | null, 340 | "" 341 | ], 342 | "If you do not have any dates formatted like this then you can ignore this!": [ 343 | null, 344 | "" 345 | ], 346 | "Start from": [ 347 | null, 348 | "" 349 | ], 350 | "Where on the timeline should the user start.": [ 351 | null, 352 | "" 353 | ], 354 | "TimeMapper - Make Timelines and TimeMaps fast!": [ 355 | null, 356 | "" 357 | ], 358 | "from the Open Knowledge Foundation Labs": [ 359 | null, 360 | "" 361 | ], 362 | "TimeMapper - Make Timelines and TimeMaps fast! - from the Open Knowledge Foundation Labs": [ 363 | null, 364 | "" 365 | ], 366 | "An Open Knowledge Foundation Labs Project": [ 367 | null, 368 | "" 369 | ], 370 | "Contact Us": [ 371 | null, 372 | "" 373 | ], 374 | "Report an Issue": [ 375 | null, 376 | "" 377 | ], 378 | "The TimeMapper is a project of Open Knowledge Foundation Labs": [ 379 | null, 380 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs." 381 | ], 382 | "TimeMapper is open-source": [ 383 | null, 384 | "" 385 | ], 386 | "Source Code": [ 387 | null, 388 | "" 389 | ], 390 | "Copyright": [ 391 | null, 392 | "" 393 | ], 394 | "Find out more on anonymous vs logged in": [ 395 | null, 396 | "" 397 | ], 398 | "read FAQ below": [ 399 | null, 400 | "" 401 | ], 402 | "Title for your View": [ 403 | null, 404 | "" 405 | ], 406 | "The slug needs to be 'url-usable' and so must be lowercase containing only alphanumeric characters and '-'": [ 407 | null, 408 | "" 409 | ], 410 | "Elegant timelines and maps created in seconds": [ 411 | null, 412 | "" 413 | ], 414 | "create": [ 415 | null, 416 | "" 417 | ], 418 | "Your Spreadsheet": [ 419 | null, 420 | "1. Create a Spreadsheet" 421 | ], 422 | "Update": [ 423 | null, 424 | "" 425 | ], 426 | "Or paste the Google Spreadsheet URL directly": [ 427 | null, 428 | "" 429 | ] 430 | } 431 | } 432 | ; 433 | -------------------------------------------------------------------------------- /locale/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "messages": { 3 | "": { 4 | "Project-Id-Version": " PACKAGE VERSION\nPOT-Creation-Date: 2014-05-31 17:46+0000\nPO-Revision-Date: 2014-05-30 21:10+0800\nLast-Translator: 陳信屹 \nLanguage-Team: Chinese (traditional)\nLanguage: zh_TW\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n" 5 | }, 6 | "It's free and easy to use": [ 7 | null, 8 | "簡單且容易使用" 9 | ], 10 | "Get started now": [ 11 | null, 12 | "現在就開始使用" 13 | ], 14 | "Watch the 1 minute Tutorial": [ 15 | null, 16 | "觀看一分鐘教學影片" 17 | ], 18 | "Examples": [ 19 | null, 20 | "範例" 21 | ], 22 | "How It Works": [ 23 | null, 24 | "如何使用" 25 | ], 26 | "1. Create a Spreadsheet": [ 27 | null, 28 | "1. 建立一份列表" 29 | ], 30 | "Add your dates and places to a Google Spreadsheet.": [ 31 | null, 32 | "在Google試算表加上時間跟地址。" 33 | ], 34 | "2. Connect and Customize": [ 35 | null, 36 | "2. 綁定跟客製化" 37 | ], 38 | "Connect your spreadsheet with TimeMapper and customize the results.": [ 39 | null, 40 | "將TimeMapper跟你的Google試算表綁在一起並且客製化結果。" 41 | ], 42 | "3. Publish, Embed and Share": [ 43 | null, 44 | "3. 公佈,嵌入,以及分享" 45 | ], 46 | "Publish your TimeMap at your own personal url, then share or embed on your site.": [ 47 | null, 48 | "用你的專屬網址公佈你的TimeMap,然後分享或嵌入在你的網站上。" 49 | ], 50 | "Credits": [ 51 | null, 52 | "鳴謝" 53 | ], 54 | "TimeMapper is an open-source project of Open Knowledge Foundation Labs.": [ 55 | null, 56 | "TimeMapper是Open Knowledge Foundation Labs的開源碼專案。" 57 | ], 58 | "It is possible thanks to a set of awesome open-source components including TimelineJS, ReclineJS, Leaflet, Backbone and Bootstrap. You can find the full open-source source for TimeMapper on GitHub here": [ 59 | null, 60 | "" 61 | ], 62 | "Create your": [ 63 | null, 64 | "建立你的" 65 | ], 66 | "Let's Get Started": [ 67 | null, 68 | "現在就開始使用" 69 | ], 70 | "1. Create your Spreadsheet": [ 71 | null, 72 | "1. 建立一份試算表" 73 | ], 74 | "if you don't have one already!": [ 75 | null, 76 | "如果你還沒有!" 77 | ], 78 | "Get started by copying . For more details or help with problems check out the FAQ below.

    ": [ 79 | null, 80 | "" 81 | ], 82 | "Impatient to try this out but don't have a spreadsheet yet?": [ 83 | null, 84 | "受不了想立刻試用但還有沒試算表?" 85 | ], 86 | "Click here to use a pre-prepared example": [ 87 | null, 88 | "點擊這裡使用我們事先準備的範例" 89 | ], 90 | "ALERT: you are not signed in so your timemap will be created 'anonymously'.": [ 91 | null, 92 | "警告:你尚未登入,所以你的timemap會以匿名方式建立。" 93 | ], 94 | "3. Let's Publish It!": [ 95 | null, 96 | "3. 發佈它" 97 | ], 98 | "Publish": [ 99 | null, 100 | "發佈" 101 | ], 102 | "FAQ": [ 103 | null, 104 | "常見問題" 105 | ], 106 | "Can I make a timemap anonymously?": [ 107 | null, 108 | "我能匿名使用嗎?" 109 | ], 110 | "If want to 'own' your timemap you should sign in (or sign-up) now »": [ 111 | null, 112 | "如果想'擁有'你的timemap,你需要現在登入 (或註冊) »" 113 | ], 114 | "(Sign-up takes a few seconds with your twitter account »)": [ 115 | null, 116 | "(使用你的Twiiter帳號 twitter account »只要幾秒鐘)" 117 | ], 118 | "Yes! You do not need an account to create a timemap - they can be created anonymously and will have all the same features and shareability of normal timemaps. However, there are some benefits of creating an account and creating your timemap whilst logged in:": [ 119 | null, 120 | "是的!建立timemap不需要帳號 - 它們可以被匿名建立並且跟一般的timemap擁有一樣的功能也能分享。 無論如何,建立一個帳號以及在登入狀態下建立timemap還是有些好處。" 121 | ], 122 | "You'll get a nice URL for your timemap at /your-username/a-name-you-choose-for-your-timemap": [ 123 | null, 124 | "你的timemap會在/your-username/a-name-you-choose-for-your-timemap有專屬的URL" 125 | ], 126 | "All of your timemaps will be nicely listed at /your-username": [ 127 | null, 128 | "你所有的timemaps會列在/your-username" 129 | ], 130 | "As you'll be identified as the owner you'll be able to re-configure (or delete) your timemap later": [ 131 | null, 132 | "當你能被辯認為timemap的擁有者時,之後你將能夠重新設定(或是刪除) 你的timemap" 133 | ], 134 | "If you do want an account, signup is very easy": [ 135 | null, 136 | "你果你真的想要一個帳號,註冊非常簡單" 137 | ], 138 | "it takes just 15 seconds, is very secure, and uses your Twitter account": [ 139 | null, 140 | "使用你的Twitter帳號,只需要15秒,非常安全" 141 | ], 142 | "(no need to think up a new username and password!).": [ 143 | null, 144 | "(不需要想新的使用者名稱跟密碼!)" 145 | ], 146 | "Go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. This tutorial walks you through.": [ 147 | null, 148 | "到你的試算表的檔案選單,然後'公佈到網路上',然後點'開始公佈'. 這份教學會帶你走過一遍。" 149 | ], 150 | "What URL do I use to connect my spreadsheet?": [ 151 | null, 152 | "哪種網址才是用來綁定我的試算表" 153 | ], 154 | "Use the URL you get by clicking your spreadsheet's Share button and copying the Link to share box.": [ 155 | null, 156 | "點擊你的試算表分享按鈕然後複製分享連結" 157 | ], 158 | "Note that although you must also Publish to the web, TimeMapper does not use the URL found in the publication pop-up.": [ 159 | null, 160 | "儘管你也必須發佈到網路TimMapper並沒有用當你發佈後出現的彈跳視窗顯示的網址" 161 | ], 162 | "What structure must the spreadsheet have?": [ 163 | null, 164 | "試算表要有怎樣的結構?" 165 | ], 166 | "TimeMapper recognizes certain columns with specific names. The best overview of these columns is the template, which has rows of instructions and examples.": [ 167 | null, 168 | "" 169 | ], 170 | "Not all fields are required. The only required fields are Title and Start fields, and even Start can be omitted if you just want a map. Note that you can add any number of other columns beyond those that TimeMapper uses.": [ 171 | null, 172 | "不是所有的欄位都需要。必備的欄位是Title還有Start。如果只是建立地圖,Start可以不需要。 記得在TimeMmapper使用的欄位之外,你可以加任意數量的其他欄位" 173 | ], 174 | "How do I format dates?": [ 175 | null, 176 | "日期要怎麼寫?" 177 | ], 178 | "The preferred date format is ISO 8601 (YYYY-MM-DD), but TimeMapper recognizes most types of date.": [ 179 | null, 180 | "日期格式是ISO 8601 (YYYY-MM-DD),但TimeMapper能辨認更多日期形式。" 181 | ], 182 | "If a date's month and day are ambiguous (e.g. is 08-03-1798 UK notation for 8 March, or is it US notation for 3 August?), by default, the first number will be interpreted as the month. You can change this by clicking the edit button in the top right corner of your TimeMap's display and selecting between US- and non-US-style dates.": [ 183 | null, 184 | "如果日期的月份跟日子不精確 (e.g (e.g. 08-03-1798 是指英國標示法的三月八號還是指得是美國標示法的八月三號。預設上,第一個數字將會被當成月份,要改變它,你可以點擊在你的TimeMap的左上角的edit按鈕並且在美國格式跟非美國格式之間切換。" 185 | ], 186 | "What kinds of geodata are supported?": [ 187 | null, 188 | "支援哪幾種 geodata?" 189 | ], 190 | "The Location column accepts two types of geodata: latitude-longitude coordinates or GeoJSON objects.": [ 191 | null, 192 | "Location 欄位接受兩種 geodata: 經緯度或是GeoJSON物件" 193 | ], 194 | "Coordinates must be in the format lat, long (e.g. 37.5, -122). The spreadsheet template includes a formula which automatically looks up coordinates corresponding to human-readable place names in the Place column. This formula is explained in a School of Data blog post.": [ 195 | null, 196 | "坐標必須是lat, long (e.g. 37.5, -122)的格式。 includes a formula which automatically looks up coordinates corresponding to human-readable place names in the Place column. This formula is explained in a School of Data blog post." 197 | ], 198 | "Advanced users who want to go beyond simple coordinates can use GeoJSON feature objects. For an example, see this blog post on adding GeoJSON country boundaries to spreadsheets.": [ 199 | null, 200 | "進階使用者可以使用GeoJSON物件。舉例來說,請見這份Blog文章 - 在試算表上加GeoJSON 國家邊界。" 201 | ], 202 | "It's as easy as 1-2-3!": [ 203 | null, 204 | "1-2-3 超簡單" 205 | ], 206 | "Edit - ": [ 207 | null, 208 | "修改 - " 209 | ], 210 | "Edit your ": [ 211 | null, 212 | "修改你的 " 213 | ], 214 | "Dashboard": [ 215 | null, 216 | "" 217 | ], 218 | "Hi there": [ 219 | null, 220 | "你好" 221 | ], 222 | "Create a new Timeline or TimeMap": [ 223 | null, 224 | "建立新的時間軸或時間地圖" 225 | ], 226 | "Your Existing TimeMaps": [ 227 | null, 228 | "你目前的時間地圖" 229 | ], 230 | "view": [ 231 | null, 232 | "檢視" 233 | ], 234 | "embed": [ 235 | null, 236 | "嵌入" 237 | ], 238 | "Embed": [ 239 | null, 240 | "嵌入" 241 | ], 242 | "Edit": [ 243 | null, 244 | "修改" 245 | ], 246 | "Search data ...": [ 247 | null, 248 | "搜尋資料中..." 249 | ], 250 | "Embed Instructions": [ 251 | null, 252 | "嵌入方式" 253 | ], 254 | "Copy and paste the following into your web page": [ 255 | null, 256 | "將下面複製貼上到你的網頁" 257 | ], 258 | "Loading data...": [ 259 | null, 260 | "讀取資料中..." 261 | ], 262 | "using": [ 263 | null, 264 | "使用" 265 | ], 266 | "License": [ 267 | null, 268 | "授權條款" 269 | ], 270 | "Source Data": [ 271 | null, 272 | "來源資料" 273 | ], 274 | "Data Source": [ 275 | null, 276 | "資料來源" 277 | ], 278 | "Select from Your Google Drive": [ 279 | null, 280 | "從你的 Google Drive 選擇" 281 | ], 282 | "If nothing happens check you are not blocking popups ...": [ 283 | null, 284 | "如果什麼事都沒發生,檢查你是否有擋彈跳視窗 ..." 285 | ], 286 | "Important": [ 287 | null, 288 | "重要" 289 | ], 290 | "you must \"publish\" your Google Spreadsheet: go to File Menu in your spreadsheet, then 'Publish to the Web', then click 'Start Publishing'. See": [ 291 | null, 292 | "到你的試算表的檔案選單,然後'公佈到網路上',然後點'開始公佈'. 這份教學會帶你走過一遍。" 293 | ], 294 | "the FAQ below": [ 295 | null, 296 | "常見問題如下" 297 | ], 298 | "for more details": [ 299 | null, 300 | "更多細節" 301 | ], 302 | "Title": [ 303 | null, 304 | "標題" 305 | ], 306 | "Slug": [ 307 | null, 308 | "" 309 | ], 310 | "The url of your new timemap. This must be different from the name for any of your existing timemaps. Choose wisely as this is hard to change!": [ 311 | null, 312 | "你的新timemap網址。 這必須跟你現有的timemap不同。 謹慎思考名字,因為這很難修改!" 313 | ], 314 | "Type of Data View": [ 315 | null, 316 | "資料顯示方式" 317 | ], 318 | "Choose the visualization type of your data - TimeMap (Timeline and Map combined), Timeline or Map.": [ 319 | null, 320 | "選擇你資料的視覺化模式 - TimeMap (時間軸與地圖整合),時間軸或地圖" 321 | ], 322 | "More Options": [ 323 | null, 324 | "更多選項" 325 | ], 326 | "Ambiguous Date Handling": [ 327 | null, 328 | "歧義時間處理" 329 | ], 330 | "month first (US style)": [ 331 | null, 332 | "月份優先 (美國格式)" 333 | ], 334 | "day first (non-US style)": [ 335 | null, 336 | "日子優先 (美國格式)" 337 | ], 338 | "How to handle ambiguous dates like \"05/08/2012\" in source data (could be read as 5th August or 8th of May).": [ 339 | null, 340 | "如何處理在來源資料裡像\"05/08/2012\"(可能念做八月五號或是五月八號)的日期。" 341 | ], 342 | "If you do not have any dates formatted like this then you can ignore this!": [ 343 | null, 344 | "如果你的日期格式沒一個長得像這樣,你可以忽略這件事" 345 | ], 346 | "Start from": [ 347 | null, 348 | "從何時開始" 349 | ], 350 | "Where on the timeline should the user start.": [ 351 | null, 352 | "為時間軸的起點" 353 | ], 354 | "TimeMapper - Make Timelines and TimeMaps fast!": [ 355 | null, 356 | "建立新的時間軸或時間地圖" 357 | ], 358 | "from the Open Knowledge Foundation Labs": [ 359 | null, 360 | "由Open Knowledge Foundation Labs 開發" 361 | ], 362 | "TimeMapper - Make Timelines and TimeMaps fast! - from the Open Knowledge Foundation Labs": [ 363 | null, 364 | "TimeMapper - 快速建立時間軸跟時間地圖! - 由Open Knowledge Foundation Labs 開發" 365 | ], 366 | "An Open Knowledge Foundation Labs Project": [ 367 | null, 368 | "一個 Open Knowledge Foundation Labs 專案" 369 | ], 370 | "Contact Us": [ 371 | null, 372 | "聯絡我們" 373 | ], 374 | "Report an Issue": [ 375 | null, 376 | "回報問題" 377 | ], 378 | "The TimeMapper is a project of Open Knowledge Foundation Labs": [ 379 | null, 380 | "TimeMapper是Open Knowledge Foundation Labs的開源碼專案。" 381 | ], 382 | "TimeMapper is open-source": [ 383 | null, 384 | "TimeMapper是開放源碼" 385 | ], 386 | "Source Code": [ 387 | null, 388 | "來源資料" 389 | ], 390 | "Copyright": [ 391 | null, 392 | "版權所有" 393 | ], 394 | "Find out more on anonymous vs logged in": [ 395 | null, 396 | "了解更多匿名使用跟登入後使用的差別" 397 | ], 398 | "read FAQ below": [ 399 | null, 400 | "常見問題如下" 401 | ], 402 | "Title for your View": [ 403 | null, 404 | "你的標題" 405 | ], 406 | "The slug needs to be 'url-usable' and so must be lowercase containing only alphanumeric characters and '-'": [ 407 | null, 408 | "slug 必須是網址合法字元,所以只能是小寫英文字母跟'-'" 409 | ], 410 | "Elegant timelines and maps created in seconds": [ 411 | null, 412 | "幾秒內就建立美觀的時間軸跟地圖" 413 | ], 414 | "create": [ 415 | null, 416 | "建立" 417 | ], 418 | "Your Spreadsheet": [ 419 | null, 420 | "1. 建立一份試算表" 421 | ], 422 | "Update": [ 423 | null, 424 | "更新" 425 | ], 426 | "Or paste the Google Spreadsheet URL directly": [ 427 | null, 428 | "或者直接貼上Google試算表的網址" 429 | ] 430 | } 431 | } 432 | --------------------------------------------------------------------------------