Travel app syncbase integration
Syncbase for messages and trip plans
SyncGroups for devices with a single user's blessing
Adding es6-shim and updating makefile to use v23 standard node version
Change-Id: I0b1ad88ff74bf607826241521174defc6231ac14
diff --git a/.jshintrc b/.jshintrc
index 16f23d1..69274a9 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -24,6 +24,9 @@
"node": true,
"globals": {
- "window": true
+ "window": true,
+ "Map": true,
+ "Promise": true,
+ "Set": true
}
}
diff --git a/Makefile b/Makefile
index f8cee03..7bcaa39 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
PATH := node_modules/.bin:$(PATH)
-PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin:$(V23_ROOT)/release/go/bin
+PATH := $(V23_ROOT)/third_party/cout/node/bin:$(V23_ROOT)/release/go/bin:$(PATH)
.DEFAULT_GOAL := all
diff --git a/README.md b/README.md
index ab650b9..b0bad54 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,27 @@
make bootstrap
+or
+
+ make boostrap port=<syncbase port>
+
+Related targets:
+
+ make creds
+ make syncbase [port=<syncbase port>]
+
To run a local dev server use:
make start
-If you would like to change the host and or port that is used:
+If you would like to change the port that is used:
make start port=<port>
+
+To connect to a syncbase instance other than the default, navigate to:
+
+ localhost:<server port>
+
+or
+
+ localhost:<server port>/?syncbase=<syncbase port>
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 3ef6412..6318d52 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -149,6 +149,7 @@
},
places: {
+ PlacesService: function(){},
SearchBox: SearchBox,
mockPlaceResult: {
geometry: {}
diff --git a/mocks/vanadium-wrapper.js b/mocks/vanadium-wrapper.js
index 446f03e..fa9eade 100644
--- a/mocks/vanadium-wrapper.js
+++ b/mocks/vanadium-wrapper.js
@@ -2,10 +2,10 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-var $ = require('../src/util/jquery');
+var Deferred = require('vanadium/src/lib/deferred');
module.exports = {
init: function(){
- return $.Deferred().promise();
+ return new Deferred().promise;
}
};
\ No newline at end of file
diff --git a/package.json b/package.json
index 5045df4..65b6895 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,13 @@
"tape": "^4.0.0"
},
"dependencies": {
+ "date-format": "^0.0.2",
"es6-promisify": "^2.0.0",
+ "es6-shim": "^0.33.0",
"global": "^4.3.0",
"jquery": "^2.1.4",
+ "lodash": "^3.10.1",
+ "query-string": "^2.4.0",
"raf": "^3.1.0",
"uuid": "^2.0.1"
}
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index 9f4319f..a68d411 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -38,6 +38,7 @@
publics: {
clear: function() {
this.marker.setMap(null);
+ this.onClear();
},
pushClient: function(client, color) {
@@ -76,6 +77,15 @@
return this.topClient().client;
},
+ hasClient: function(client) {
+ for (var i = 0; i < this.clients.length; i++) {
+ if (client === this.clients[0].client) {
+ return true;
+ }
+ }
+ return false;
+ },
+
setColor: function(color) {
this.topClient().color = color;
this.updateIcon();
@@ -136,7 +146,7 @@
}
},
- events: [ 'onClick' ],
+ events: [ 'onClick', 'onClear' ],
constants: [ 'marker', 'place' ],
/**
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
index 7323794..dc3b989 100644
--- a/src/components/destination-search.js
+++ b/src/components/destination-search.js
@@ -73,6 +73,10 @@
setPlaceholder: function(placeholder) {
this.$searchBox.attr('placeholder', placeholder);
+ },
+
+ getValue: function() {
+ return this.$searchBox.prop('value');
}
},
@@ -115,6 +119,12 @@
$(newBox).focus();
}
}
+ },
+
+ inputKey: function(e) {
+ if (e.which === 13) {
+ this.onSubmit(this.getValue());
+ }
}
},
@@ -135,7 +145,16 @@
*/
'onSearch',
- 'onDeselect'
+ 'onDeselect',
+
+ /**
+ * Event fired when the enter key is pressed. This is distinct from the
+ * onSearch event, which is fired when valid location properties are chosen,
+ * which can happen without onSubmit in the case of an autocomplete.
+ *
+ * @param value the current control text.
+ */
+ 'onSubmit'
],
constants: ['$'],
@@ -154,7 +173,7 @@
$searchBox.focus(this.onFocus);
$searchBox.on('input', function() {
self.setPlace(null);
- });
+ }).keypress(this.inputKey);
this.$ = $('<div>')
.addClass('destination autocomplete')
diff --git a/src/components/map.js b/src/components/map-widget.js
similarity index 78%
rename from src/components/map.js
rename to src/components/map-widget.js
index 24ab5db..0143586 100644
--- a/src/components/map.js
+++ b/src/components/map-widget.js
@@ -2,10 +2,11 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+require('es6-shim');
+
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
-var Destinations = require('../destinations');
var Place = require('../place');
var DestinationInfo = require('./destination-info');
var DestinationMarker = require('./destination-marker');
@@ -15,7 +16,7 @@
//named destination marker clients
var SEARCH_CLIENT = 'search';
-var Map = defineClass({
+var MapWidget = defineClass({
publics: {
getBounds: function() {
return this.map.getBounds();
@@ -28,31 +29,18 @@
});
},
- addDestination: function(index) {
- var self = this;
+ bindDestinations: function(destinations) {
+ if (this.destinations) {
+ this.destinations.onAdd.remove(this.handleDestinationAdd);
+ this.destinations.onRemove.remove(this.handleDestinationRemove);
+ }
- var destination = this.destinations.add(index);
+ this.destinations = destinations;
- destination.onPlaceChange.add(function(place) {
- self.handleDestinationPlaceChange(destination, place);
- });
- destination.onDeselect.add(function() {
- self.handleDestinationDeselect(destination);
- });
- destination.onSelect.add(function() {
- self.handleDestinationSelect(destination);
- });
-
- return destination;
- },
-
- getDestination: function(index) {
- return this.destinations.get(index);
- },
-
- removeDestination: function(index) {
- //TODO(rosswang): clear any rendered legs
- return this.destinations.remove(index);
+ if (destinations) {
+ destinations.onAdd.add(this.handleDestinationAdd);
+ destinations.onRemove.add(this.handleDestinationRemove);
+ }
},
getSelectedDestination: function() {
@@ -123,6 +111,10 @@
this.locationSelectionEnabled = false;
},
+ createPlacesService: function() {
+ return new this.maps.places.PlacesService(this.map);
+ },
+
showSearchResults: function(results) {
var self = this;
@@ -139,12 +131,11 @@
* click and a normal search so that we don't overwrite the search box
* text for the autocomplete click.*/
dest.setPlace(new Place(results[0]));
- self.createDestinationMarker(dest);
} else if (results.length > 0) {
$.each(results, function(i, result) {
var place = new Place(result);
- var marker = self.createMarker(place, SEARCH_CLIENT,
+ var marker = self.getOrCreateMarker(place, SEARCH_CLIENT,
DestinationMarker.color.RED);
self.searchMarkers.push(marker);
@@ -152,7 +143,6 @@
var dest = self.selectedDestination;
if (dest) {
dest.setPlace(place);
- self.associateDestinationMarker(dest, marker);
}
}));
});
@@ -161,42 +151,95 @@
},
privates: {
- createMarker: function(place, client, color) {
+ handleDestinationAdd: function(destination) {
var self = this;
- var marker = new DestinationMarker(this.maps, this.map, place,
- client, color);
+ this.destMeta.set(destination, {});
- if (place.hasDetails()) {
- marker.onClick.add(function() {
- self.showDestinationInfo(marker);
- }, true);
+ destination.onPlaceChange.add(function(place) {
+ self.handleDestinationPlaceChange(destination, place);
+ });
+ destination.onDeselect.add(function() {
+ self.handleDestinationDeselect(destination);
+ });
+ destination.onSelect.add(function() {
+ self.handleDestinationSelect(destination);
+ });
+
+ if (destination.hasNext()) {
+ this.updateLeg(destination.getNext());
+ }
+
+ return destination;
+ },
+
+ handleDestinationRemove: function(destination) {
+ var meta = this.destMeta.get(destination);
+
+ if (meta.unbindMarker) {
+ meta.unbindMarker();
+ }
+
+ if (meta.leg) {
+ meta.leg.clear();
+ }
+
+ var next = this.destinations.get(destination.getIndex());
+ if (next) {
+ this.updateLeg(next);
+ }
+
+ destination.deselect();
+
+ this.destMeta.delete(destination);
+ },
+
+ getOrCreateMarker: function(place, client, color, mergePredicate) {
+ var self = this;
+
+ var key = place.toKey();
+
+ var marker = this.markers[key];
+ if (marker) {
+ if (!mergePredicate || mergePredicate(marker)) {
+ marker.pushClient(client, color);
+ } else {
+ marker = null;
+ }
+ } else {
+ marker = new DestinationMarker(this.maps, this.map, place,
+ client, color);
+
+ if (place.hasDetails()) {
+ marker.onClick.add(function() {
+ self.showDestinationInfo(marker);
+ }, true);
+ }
+
+ this.markers[key] = marker;
+ marker.onClear.add(function() {
+ delete self.markers[key];
+ });
}
return marker;
},
- createDestinationMarker: function(destination) {
- var marker = this.createMarker(destination.getPlace(), destination,
- this.getAppropriateDestinationMarkerColor(destination));
-
- this.bindDestinationMarker(destination, marker);
-
- return marker;
- },
-
- associateDestinationMarker: function(destination, marker) {
- if (!marker.onClick.has(destination.select)) {
- marker.pushClient(destination,
- this.getAppropriateDestinationMarkerColor(destination));
-
- this.bindDestinationMarker(destination, marker);
- }
- },
-
- bindDestinationMarker: function(destination, marker) {
+ bindDestinationMarker: function(destination) {
var self = this;
+ var place = destination.getPlace();
+
+ var marker = this.getOrCreateMarker(place, destination,
+ this.getAppropriateDestinationMarkerColor(destination),
+ function(marker) {
+ return !marker.hasClient(destination);
+ });
+
+ if (!marker) {
+ return;
+ }
+
marker.onClick.add(destination.select);
function handleSelection() {
marker.setColor(self.getAppropriateDestinationMarkerColor(destination));
@@ -211,13 +254,26 @@
destination.onOrdinalChange.add(handleOrdinalChange);
handleOrdinalChange();
- function handlePlaceChange() {
+ var meta = this.destMeta.get(destination);
+
+ function unbind() {
marker.removeClient(destination);
marker.onClick.remove(destination.select);
destination.onSelect.remove(handleSelection);
destination.onDeselect.remove(handleSelection);
destination.onOrdinalChange.remove(handleOrdinalChange);
destination.onPlaceChange.remove(handlePlaceChange);
+ if (meta.unbindMarker === unbind) {
+ delete meta.unbindMarker;
+ }
+ }
+
+ meta.unbindMarker = unbind;
+
+ function handlePlaceChange(newPlace) {
+ if ((place && place.toKey()) !== (newPlace && newPlace.toKey())) {
+ unbind();
+ }
}
destination.onPlaceChange.add(handlePlaceChange);
@@ -240,6 +296,10 @@
},
handleDestinationPlaceChange: function(destination, place) {
+ if (place) {
+ this.bindDestinationMarker(destination);
+ }
+
if (destination.getPrevious()) {
this.updateLeg(destination);
}
@@ -280,19 +340,26 @@
var a = destination.getPrevious().getPlace();
var b = destination.getPlace();
- var leg = destination.leg;
+ var meta = this.destMeta.get(destination);
+
+ var leg = meta.leg;
if (leg) {
if (leg.async) {
leg.async.reject();
}
- // setMap(null) seems to be the best way to clear the nav route
- leg.renderer.setMap(null);
+ leg.clear();
} else {
var renderer = new maps.DirectionsRenderer({
preserveViewport: true,
suppressMarkers: true
});
- destination.leg = leg = { renderer: renderer };
+ meta.leg = leg = {
+ renderer: renderer,
+ clear: function() {
+ // setMap(null) seems to be the best way to clear the nav route
+ renderer.setMap(null);
+ }
+ };
}
if (a && b) {
@@ -317,11 +384,6 @@
leg.async.done(function(result) {
leg.renderer.setDirections(result);
leg.renderer.setMap(map);
-
- self.ensureGeomsVisible(result.routes[0]['overview_path'].map(
- function(point) {
- return { location: point };
- }));
});
}
},
@@ -344,7 +406,6 @@
if (status === maps.GeocoderStatus.OK &&
origin && !origin.hasPlace()) {
origin.setPlace(new Place(results[0]));
- self.createDestinationMarker(origin);
}
});
});
@@ -399,7 +460,6 @@
function(results, status) {
if (status === maps.GeocoderStatus.OK) {
dest.setPlace(new Place(results[0]));
- self.createDestinationMarker(dest);
/* If we've just picked a location like this, we probably don't
* care about search results anymore. */
@@ -438,11 +498,12 @@
this.navigator = opts.navigator || global.navigator;
this.geocoder = new maps.Geocoder();
this.directionsService = new maps.DirectionsService();
- this.destinations = new Destinations();
this.$ = $('<div>').addClass('map-canvas');
this.searchMarkers = [];
+ this.markers = {};
+ this.destMeta = new Map();
this.initialConfig = {
center: new maps.LatLng(37.4184, -122.0880), //Googleplex
@@ -463,4 +524,4 @@
}
});
-module.exports = Map;
+module.exports = MapWidget;
diff --git a/src/components/message.js b/src/components/message.js
index a2e5062..e68ce89 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+var format = require('date-format');
+
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
@@ -48,7 +50,31 @@
},
setText: function(text) {
- this.$.text(text);
+ this.$text.text(text);
+ },
+
+ setTimestamp: function(timestamp) {
+ var fmt;
+ if (timestamp === null || timestamp === undefined) {
+ fmt = '';
+ } else {
+ fmt = format('yyyy.MM.dd.hh.mm.ss', new Date(timestamp));
+ }
+ this.$timestamp.text(fmt);
+ if (fmt) {
+ this.$label.removeClass('no-timestamp');
+ } else {
+ this.$label.addClass('no-timestamp');
+ }
+ },
+
+ setSender: function(sender) {
+ this.$sender.text(sender);
+ if (sender) {
+ this.$label.removeClass('no-sender');
+ } else {
+ this.$label.addClass('no-sender');
+ }
},
set: function(message) {
@@ -64,6 +90,8 @@
var self = this;
this.setType(message.type);
+ this.setSender(message.sender);
+ this.setTimestamp(message.timestamp);
this.setText(message.text);
if (message.promise) {
@@ -87,7 +115,12 @@
},
init: function(initial) {
- this.$ = $('<li>');
+ this.$ = $('<li>')
+ .append(
+ this.$label = $('<span>').addClass('label').append(
+ this.$sender = $('<span>').addClass('username'),
+ this.$timestamp = $('<span>').addClass('timestamp')),
+ this.$text = $('<span>').addClass('text'));
if (initial) {
this.set(initial);
}
diff --git a/src/components/messages.js b/src/components/messages.js
index 27d49a5..12b1087 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -13,7 +13,9 @@
TTL: 9000,
FADE: 1000,
SLIDE_UP: 300,
- OPEN_CLOSE: 400
+ OPEN_CLOSE: 400,
+
+ OLD: 30000
},
publics: {
@@ -81,17 +83,58 @@
}
});
}
+
+ this.$content.focus();
}
},
push: function(messageData) {
var self = this;
+ $.each(arguments, function() {
+ self.pushOne(this);
+ });
+ },
+
+ setUsername: function(username) {
+ this.username = username;
+ this.$username.text(username);
+ },
+
+ toggle: function() {
+ /* If this were pure CSS, we could just toggle, but we need to do some
+ * JS housekeeping. */
+ if (this.isOpen()) {
+ this.close();
+ } else if (this.isClosed()) {
+ this.open();
+ }
+ }
+ },
+
+ privates: {
+ inputKey: function(e) {
+ if (e.which === 13) {
+ var message = Message.info(this.$content.prop('value'));
+ message.sender = this.username;
+ this.$content.prop('value', '');
+ this.onMessage(message);
+ }
+ },
+
+ pushOne: function(messageData) {
+ var self = this;
var messageObject = new Message(messageData);
this.$messages.append(messageObject.$);
+ var isOld = messageData.timestamp !== undefined &&
+ messageData.timestamp !== null &&
+ Date.now() - messageData.timestamp >= Messages.OLD;
+
if (this.isOpen()) {
this.$messages.scrollTop(this.$messages.prop('scrollHeight'));
+ } else if (isOld) {
+ messageObject.$.addClass('history');
} else {
/*
* Implementation notes: slideDown won't work properly (won't be able to
@@ -115,47 +158,49 @@
.slideDown(this.SLIDE_DOWN);
}
- messageObject.onLowerPriority.add(function() {
- messageObject.$.addClass('history');
+ if (!isOld) {
+ messageObject.onLowerPriority.add(function() {
+ messageObject.$.addClass('history');
- if (self.isClosed()) {
- messageObject.$
- .addClass('animating')
- .show()
- .delay(Messages.TTL)
- .animate({ opacity: 0 }, Messages.FADE)
- .slideUp(Messages.SLIDE_UP, function() {
- messageObject.$
- .removeClass('animating')
- .attr('style', null);
- });
- }
- });
- },
-
- toggle: function() {
- /* If this were pure CSS, we could just toggle, but we need to do some
- * JS housekeeping. */
- if (this.isOpen()) {
- this.close();
- } else if (this.isClosed()) {
- this.open();
+ if (self.isClosed()) {
+ messageObject.$
+ .addClass('animating')
+ .show()
+ .delay(Messages.TTL)
+ .animate({ opacity: 0 }, Messages.FADE)
+ .slideUp(Messages.SLIDE_UP, function() {
+ messageObject.$
+ .removeClass('animating')
+ .attr('style', null);
+ });
+ }
+ });
}
}
},
constants: ['$'],
+ events: [ 'onMessage' ],
init: function() {
- this.$handle = $('<div>')
+ var $handle = $('<div>')
.addClass('handle no-select')
.click(this.toggle);
this.$messages = $('<ul>');
+ var $send = $('<div>')
+ .addClass('send')
+ .append(this.$username = $('<div>')
+ .addClass('username label'),
+ $('<div>').append(
+ this.$content = $('<input>')
+ .attr('type', 'text')
+ .keypress(this.inputKey)));
+
this.$ = $('<div>')
.addClass('messages headlines')
- .append(this.$handle, this.$messages);
+ .append($handle, this.$messages, $send);
}
});
diff --git a/src/components/timeline.js b/src/components/timeline.js
index 24df0ab..10da699 100644
--- a/src/components/timeline.js
+++ b/src/components/timeline.js
@@ -18,12 +18,17 @@
this.addButton.enable();
},
- append: function() {
+ add: function(i) {
var controls = this.controls;
var destinationSearch = new DestinationSearch(this.maps);
- this.$destContainer.append(destinationSearch.$);
- controls.push(destinationSearch);
+ if (i === undefined || i === controls.length) {
+ this.$destContainer.append(destinationSearch.$);
+ controls.push(destinationSearch);
+ } else {
+ destinationSearch.$.insertBefore(this.$destContainer.children()[i]);
+ controls.splice(i, 0, destinationSearch);
+ }
return destinationSearch;
},
@@ -39,11 +44,17 @@
},
remove: function(i) {
+ var removed;
if (i >= 0) {
- this.controls.splice(i, 1)[0].$.remove();
+ removed = this.controls.splice(i, 1)[0];
} else if (i < 0) {
- this.controls.splice(this.controls.length + i, 1)[0].$.remove();
+ removed = this.controls.splice(this.controls.length + i, 1)[0];
}
+
+ if (removed) {
+ removed.$.remove();
+ }
+ return removed;
}
},
diff --git a/src/debug.js b/src/debug.js
index 12da771..8e47959 100644
--- a/src/debug.js
+++ b/src/debug.js
@@ -7,7 +7,15 @@
/**
* Global variable exports for console debug.
*/
-module.exports = function(app) {
+function debug(app) {
global.travel = app;
global.$ = $;
-};
\ No newline at end of file
+}
+
+debug.log = function(message) {
+ if (console.debug) {
+ console.debug(message);
+ }
+};
+
+module.exports = debug;
diff --git a/src/destination.js b/src/destination.js
index 25c609c..624149f 100644
--- a/src/destination.js
+++ b/src/destination.js
@@ -58,6 +58,10 @@
getPrevious: function() {
return this.hasPrevious()? this.list.get(this.index - 1) : null;
+ },
+
+ remove: function() {
+ this.list.remove(this.index);
}
},
@@ -88,6 +92,15 @@
'onDeselect'
],
+ /**
+ * @param list the containing `Destinations` instance.
+ * @param index the index within the parent list
+ * @param callbacks an object that will be assigned members for utility
+ * callbacks that the caller can use:
+ * <ul>
+ * <li>ordinalChange - fires this destination's `onOrdinalChange` event.
+ * </ul>
+ */
init: function(list, index, callbacks) {
this.list = list;
this.selected = false;
diff --git a/src/destinations.js b/src/destinations.js
index 2679a4e..4ae4415 100644
--- a/src/destinations.js
+++ b/src/destinations.js
@@ -9,7 +9,7 @@
var Destinations = defineClass({
publics: {
add: function(index) {
- index = index || this.destinations.length;
+ index = index !== undefined? index : this.destinations.length;
var isLast = index === this.destinations.length;
@@ -21,8 +21,6 @@
destination: destination
});
- this.onAdd(destination);
-
if (isLast && index > 0) {
//old last is no longer last
this.destinations[index - 1].callbacks.ordinalChange(index - 1);
@@ -31,6 +29,8 @@
this.destinations[i].callbacks.ordinalChange(i);
}
+ this.onAdd(destination);
+
return destination;
},
@@ -66,8 +66,6 @@
var removed = this.destinations.splice(i, 1)[0];
if (removed) {
- this.onRemove(removed);
-
if (i === this.destinations.length && i > 0) {
//new last
this.destinations[i - 1].callbacks.ordinalChange(i - 1);
@@ -76,6 +74,8 @@
this.destinations[j].callbacks.ordinalChange(j);
}
+ this.onRemove(removed.destination);
+
return removed.destination;
}
},
diff --git a/src/place.js b/src/place.js
index d961d08..5096a9d 100644
--- a/src/place.js
+++ b/src/place.js
@@ -2,9 +2,31 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+var Deferred = require('vanadium/src/lib/deferred');
+
var defineClass = require('./util/define-class');
var Place = defineClass({
+ statics: {
+ fromObject: function(dependencies, obj) {
+ var async = new Deferred();
+
+ if (obj.placeId) {
+ dependencies.placesService.getDetails(obj, function(place, status) {
+ if (status === dependencies.maps.places.PlacesServiceStatus.OK) {
+ async.resolve(new Place(place));
+ } else {
+ async.reject(status);
+ }
+ });
+ } else {
+ async.reject('Deserialization not supported.'); //TODO(rosswang)
+ }
+
+ return async.promise;
+ }
+ },
+
publics: {
getDetails: function() {
return this.details;
@@ -124,6 +146,31 @@
})();
return lines[0] === name? lines.slice(1) : lines;
+ },
+
+ /**
+ * Returns a plain object that can be used to reconstruct the place. This
+ * object really shouldn't be mutated.
+ */
+ toObject: function() {
+ if (this.placeObj.placeId) {
+ return {
+ placeId: this.placeObj.placeId
+ };
+ } else {
+ return {
+ location: {
+ lat: this.placeObj.location.lat(),
+ lng: this.placeObj.location.lng()
+ },
+ query: this.placeObj.query
+ };
+ }
+ },
+
+ toKey: function() {
+ return this.placeObj.placeId ||
+ (this.placeObj.query || '') + this.placeObj.location.toString();
}
},
diff --git a/src/static/index.css b/src/static/index.css
index 5b69c32..db4d8ef 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -169,6 +169,67 @@
color: red;
}
+.label {
+ color: blue;
+ margin-right: .4em;
+}
+
+.messages.headlines .label {
+ background-color: rgba(255, 255, 255, .8);
+ border-radius: 3px;
+ padding: .1em .3em .1em .4em;
+}
+
+.label:after {
+ content: ':'
+}
+
+.label.no-timestamp.no-sender {
+ display: none;
+}
+
+.username {
+ font-weight: bold;
+}
+
+.timestamp:before {
+ content: ' (';
+}
+
+.timestamp:after {
+ content: ')';
+}
+
+.no-sender .timestamp:before {
+ content: initial;
+}
+
+.no-sender .timestamp:after {
+ content: initial;
+}
+
+.send {
+ background-color: silver;
+}
+
+.send div {
+ overflow: hidden;
+}
+
+.send input {
+ width: 100%;
+}
+
+.messages.headlines .send {
+ display: none;
+}
+
+.send .label {
+ background-color: initial;
+ float: left;
+ margin: .4em .5em .1em .5em;
+}
+
.mini-search {
overflow: hidden;
vertical-align: middle;
diff --git a/src/strings.js b/src/strings.js
index bdd3a06..1a94323 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -5,6 +5,9 @@
function getStrings(locale) {
return {
'Add destination': 'Add destination',
+ add: function(object) {
+ return 'Add ' + object.toLowerCase();
+ },
change: function(object) {
return 'Change ' + object.toLowerCase();
},
diff --git a/src/travel.js b/src/travel.js
index 953f7f3..5b1c30c 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -2,17 +2,21 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-var $ = require('./util/jquery');
var raf = require('raf');
+var vanadium = require('vanadium');
+
+var $ = require('./util/jquery');
var defineClass = require('./util/define-class');
var AddButton = require('./components/add-button');
var DestinationSearch = require('./components/destination-search');
-var Identity = require('./identity');
-var Map = require('./components/map');
+var MapWidget = require('./components/map-widget');
var Messages = require('./components/messages');
var Message = require('./components/message');
var Timeline = require('./components/timeline');
+
+var Destinations = require('./destinations');
+var Identity = require('./identity');
var TravelSync = require('./travelsync');
var vanadiumWrapperDefault = require('./vanadium-wrapper');
@@ -67,14 +71,37 @@
control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
}
+function makeMountNames(id) {
+ // TODO: first-class app-wide rather than siloed by account
+ var parts = ['/ns.dev.v.io:8101', 'users', id.username, 'travel'];
+ var names = {
+ user: vanadium.naming.join(parts)
+ };
+
+ parts.push(id.deviceName);
+ names.device = vanadium.naming.join(parts);
+
+ return names;
+}
+
var Travel = defineClass({
publics: {
- addDestination: function() {
+ error: function (err) {
+ this.messages.push(Message.error(err));
+ },
+
+ info: function (info, promise) {
+ var messageData = Message.info(info);
+ messageData.promise = promise;
+ this.messages.push(messageData);
+ }
+ },
+
+ privates: {
+ handleDestinationAdd: function(destination) {
var map = this.map;
- var destination = map.addDestination();
- var control = this.timeline.append();
-
+ var control = this.timeline.add(destination.getIndex());
bindControlToDestination(control, destination);
control.setSearchBounds(map.getBounds());
@@ -97,12 +124,14 @@
map.showSearchResults(results);
});
- this.timeline.disableAdd();
- var oldLast = this.timeline.get(-2);
- if (oldLast) {
- this.unbindLastDestinationSearchEvents(oldLast);
+ if (!destination.hasNext()) {
+ this.timeline.disableAdd();
+ var oldLast = this.timeline.get(-2);
+ if (oldLast) {
+ this.unbindLastDestinationSearchEvents(oldLast);
+ }
+ this.bindLastDestinationSearchEvents(control);
}
- this.bindLastDestinationSearchEvents(control);
this.bindMiniFeedback(destination);
@@ -112,29 +141,47 @@
};
},
- error: function (err) {
- this.messages.push(Message.error(err));
+ handleDestinationRemove: function(destination) {
+ var index = destination.getIndex();
+ this.unbindLastDestinationSearchEvents(this.timeline.remove(index));
+
+ if (index >= this.destinations.count()) {
+ var lastControl = this.timeline.get(-1);
+ if (lastControl) {
+ this.bindLastDestinationSearchEvents(lastControl);
+ this.handleLastPlaceChange(lastControl.getPlace());
+ }
+ }
+ //TODO(rosswang): reselect?
},
- info: function (info, promise) {
- var messageData = Message.info(info);
- messageData.promise = promise;
- this.messages.push(messageData);
- }
- },
+ handleTimelineDestinationAdd: function() {
+ this.destinations.add();
+ this.timeline.get(-1).focus();
+ },
- privates: {
- /**
- * Handles destination addition via the mini-UI.
- */
- addDestinationMini: function() {
+ handleMiniDestinationAdd: function() {
this.miniDestinationSearch.clear();
this.map.closeActiveInfoWindow();
- var destination = this.addDestination().destination;
+ var selectedDest = this.map.getSelectedDestination();
+ var index = selectedDest?
+ selectedDest.getIndex() + 1 : this.destinations.count();
+
+ var destination = this.destinations.get(index);
+ if (!destination || destination.hasPlace()) {
+ destination = this.destinations.add(index);
+ }
+
destination.select();
this.miniDestinationSearch.focus();
- this.miniDestinationSearch.setPlaceholder(strings['Add destination']);
+ this.miniDestinationSearch.setPlaceholder(
+ destination.hasNext()?
+ /* Actually, the terminal case where descriptionOpenEnded would differ
+ * from description is always handled by the latter branch, but
+ * semantically we would want the open-ended description here. */
+ strings.add(describeDestination.descriptionOpenEnded(destination)) :
+ strings['Add destination']);
},
bindMiniFeedback: function(destination) {
@@ -147,6 +194,8 @@
initMiniFeedback: function() {
var self = this;
+ var selectedDestination;
+
//context: destination
function handlePlaceChange(place) {
self.miniDestinationSearch.setPlace(place);
@@ -154,18 +203,23 @@
strings.change(describeDestination.description(this)));
}
- //context: destination.
+ //context: destination
function handleSelect() {
+ selectedDestination = this;
handlePlaceChange.call(this, this.getPlace());
this.onPlaceChange.add(handlePlaceChange);
}
+ //context: destination
function handleDeselect() {
this.onPlaceChange.remove(handlePlaceChange);
- if (self.miniDestinationSearch.getPlace()) {
- self.miniDestinationSearch.clear();
+ if (selectedDestination === this) {
+ selectedDestination = null;
+ if (self.miniDestinationSearch.getPlace()) {
+ self.miniDestinationSearch.clear();
+ }
+ self.miniDestinationSearch.setPlaceholder(strings['Search']);
}
- self.miniDestinationSearch.setPlaceholder(strings['Search']);
}
this.miniFeedback = {
@@ -218,25 +272,18 @@
},
handleLastPlaceDeselected: function() {
- var self = this;
/* Wait until next frame to allow selection/focus to update; we don't want
* to remove a box that has just received focus. */
- raf(function() {
- var lastControl = self.timeline.get(-1);
- var oldLast = lastControl;
+ raf(this.trimUnusedDestinations);
+ },
- while (!lastControl.getPlace() && !lastControl.isSelected() &&
- self.timeline.get().length > 1) {
- self.timeline.remove(-1);
- self.map.removeDestination(-1);
- lastControl = self.timeline.get(-1);
- }
-
- if (oldLast !== lastControl) {
- self.bindLastDestinationSearchEvents(lastControl);
- self.handleLastPlaceChange(lastControl.getPlace());
- }
- });
+ trimUnusedDestinations: function() {
+ for (var lastControl = this.timeline.get(-1);
+ !lastControl.getPlace() && !lastControl.isSelected() &&
+ this.destinations.count() > 1;
+ lastControl = this.timeline.get(-1)) {
+ this.destinations.remove(-1);
+ }
},
/**
@@ -267,24 +314,45 @@
opts = opts || {};
var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
- var map = this.map = new Map(opts);
+ var destinations = this.destinations = new Destinations();
+ destinations.onAdd.add(this.handleDestinationAdd);
+ destinations.onRemove.add(this.handleDestinationRemove);
+
+ var map = this.map = new MapWidget(opts);
var maps = map.maps;
+ map.bindDestinations(destinations);
var messages = this.messages = new Messages();
var timeline = this.timeline = new Timeline(maps);
- var sync = this.sync = new TravelSync();
-
var error = this.error;
-
- this.info(strings['Connecting...'], vanadiumWrapper.init(opts.vanadium)
+ var vanadiumStartup = vanadiumWrapper.init(opts.vanadium)
.then(function(wrapper) {
+ wrapper.onError.add(error);
wrapper.onCrash.add(error);
var identity = new Identity(wrapper.getAccountName());
- identity.mountName = makeMountName(identity);
- return sync.start(identity.mountName, wrapper);
- }).then(function() {
+ identity.mountNames = makeMountNames(identity);
+ messages.setUsername(identity.username);
+
+ return {
+ identity: identity,
+ vanadiumWrapper: wrapper
+ };
+ });
+
+ var sync = this.sync = new TravelSync(vanadiumStartup, {
+ maps: maps,
+ placesService: map.createPlacesService()
+ });
+ sync.bindDestinations(destinations);
+
+ this.info(strings['Connecting...'], sync.startup
+ .then(function() {
+ /* Fit whatever's in the map via timeout to simplify the coding a
+ * little. Otherwise we'd need to hook into the asynchronous place
+ * vivification and routing. */
+ setTimeout(map.fitAll, 2250);
return strings['Connected to all services.'];
}));
@@ -298,15 +366,22 @@
error(message);
});
- timeline.onAddClick.add(function() {
- self.addDestination().control.focus();
+ sync.onError.add(error);
+ sync.onMessages.add(function(messages) {
+ self.messages.push.apply(self.messages, messages);
});
+ messages.onMessage.add(function(message) {
+ sync.message(message);
+ });
+
+ timeline.onAddClick.add(this.handleTimelineDestinationAdd);
+
var miniAddButton = this.miniAddButton = new AddButton();
var miniDestinationSearch = this.miniDestinationSearch =
new DestinationSearch(maps);
- miniAddButton.onClick.add(this.addDestinationMini);
+ miniAddButton.onClick.add(this.handleMiniDestinationAdd);
miniDestinationSearch.setPlaceholder(strings['Search']);
miniDestinationSearch.setSearchBounds(map.getBounds());
@@ -330,6 +405,17 @@
}
});
+ miniDestinationSearch.onSubmit.add(function(value) {
+ if (!value) {
+ var selected = self.map.getSelectedDestination();
+ if (selected) {
+ selected.remove();
+ }
+
+ self.map.clearSearchMarkers();
+ }
+ });
+
var $miniPanel = this.$minPanel = $('<div>')
.addClass('mini-search')
.append(miniAddButton.$,
@@ -359,14 +445,9 @@
this.initMiniFeedback();
- this.addDestination();
+ destinations.add();
miniDestinationSearch.focus();
}
});
-function makeMountName(id) {
- // TODO: first-class app-wide rather than siloed by account
- return 'users/' + id.username + '/travel/' + id.deviceName;
-}
-
module.exports = Travel;
diff --git a/src/travelsync.js b/src/travelsync.js
index 3f458b3..baf55e7 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -2,33 +2,53 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-//TODO(rosswang): either expect ES6, use our own polyfill, or export this in V23
-var Promise = require('vanadium/src/lib/promise');
+require('es6-shim');
+
+var _ = require('lodash');
+var queryString = require('query-string');
+var uuid = require('uuid');
+var vanadium = require('vanadium');
var $ = require('./util/jquery');
var defineClass = require('./util/define-class');
+var debug = require('./debug');
+var Place = require('./place');
+
var vdlTravel = require('../ifc');
-var TravelSync = defineClass({
- publics: {
- start: function(mountName, v) {
- var self = this;
- var startSyncbase = v.syncbase('/localhost:4001/syncbase').then(
- function(syncbase) {
- self.syncbase = syncbase;
- syncbase.onError.add(self.onError);
- syncbase.onUpdate.add(self.processUpdates);
- });
+var DESTINATION_SCHEMA = [ 'place' ];
- return Promise.all([
- v.server(mountName, this.server),
- startSyncbase
- ]);
+var TravelSync = defineClass({
+ /* Schema note: although we don't support merging destination list structure
+ * changes, we use indirection in the destination list so that we don't have
+ * to move multiple keys on random insertion or deletion and can still support
+ * parallel destination edits. */
+ publics: {
+ bindDestinations: function(destinations) {
+ if (this.destinations) {
+ this.destinations.onAdd.remove(this.handleDestinationAdd);
+ this.destinations.onRemove.remove(this.handleDestinationRemove);
+ }
+
+ this.destinations = destinations;
+
+ if (destinations) {
+ destinations.onAdd.add(this.handleDestinationAdd);
+ destinations.onRemove.add(this.handleDestinationRemove);
+ }
},
message: function(messageContent) {
+ var id = uuid.v4();
+ var payload = $.extend({
+ timestamp: Date.now()
+ }, messageContent);
+ var value = this.marshal(payload);
+ this.startup.then(function(services) {
+ return services.syncbase.put(['messages', id], value);
+ }).catch(this.onError);
},
pushTrip: function() {
@@ -39,6 +59,196 @@
},
privates: {
+ destinationRecord: defineClass.innerClass({
+ publics: {
+ isValid: function() {
+ return this.id !== undefined;
+ },
+
+ invalidate: function() {
+ delete this.id;
+ },
+
+ getId: function() {
+ return this.id;
+ },
+
+ setId: function(id) {
+ this.id = id;
+ },
+
+ /**
+ * @param placeData the plain object representation of a `Place`.
+ * @param changedCallback a function called if the place is actually
+ * changed, with the params newPlace, oldPlace, as the new and old
+ * plain object places, respectively.
+ */
+ setPlaceData: function(placeData, changedCallback) {
+ var old = this.data.place;
+ if (!_.isEqual(old, placeData) && (old || placeData)) {
+ this.data.place = placeData;
+
+ this.cancelPlaceAsync();
+
+ if (changedCallback) {
+ changedCallback.call(this.ifc, placeData, old);
+ }
+ }
+ },
+
+ put: function(dao) {
+ var outer = this.outer;
+ var self = this;
+
+ if (this.isValid()) {
+ var key = ['destinations', this.id];
+ var writes = [];
+
+ $.each(DESTINATION_SCHEMA, function() {
+ key[2] = this;
+ var value = self.data[this];
+ writes.push(value?
+ dao.put(key, outer.marshal(value)) : dao.delete(key));
+ });
+ return Promise.all(writes);
+ } else {
+ return Promise.resolve();
+ }
+ },
+
+ delete: function(dao) {
+ if (this.isValid()) {
+ return dao.delete(['destinations', this.id]);
+ } else {
+ return Promise.resolve();
+ }
+ },
+ },
+
+ events: {
+ /**
+ * Utility event to allow asynchronous update processes to cancel if
+ * they do not finish by the time the place has been updated again.
+ */
+ cancelPlaceAsync: 'once'
+ },
+
+ init: function(place, generateId) {
+ if (generateId) {
+ this.id = uuid.v4();
+ }
+
+ this.data = {
+ place: place && place.toObject()
+ };
+ }
+ }),
+
+ batch: function(fn) {
+ this.startup.then(function(services) {
+ return services.syncbase.batch(fn);
+ }).catch(this.onError);
+ },
+
+ nonBatched: function(fn) {
+ var self = this; //not really necessary but semantically correct
+ var fnArgs = Array.prototype.slice.call(arguments, 1);
+ this.startup.then(function(services) {
+ fnArgs.splice(0, 0, services.syncbase);
+ return fn.apply(self, fnArgs);
+ }).catch(this.onError);
+ },
+
+ handleDestinationAdd: function (destination) {
+ var self = this;
+
+ var index = destination.getIndex();
+ var record = this.destRecords[index];
+
+ if (!record || record.isValid()) {
+ var place = destination.getPlace();
+
+ record = this.destinationRecord(place, true);
+
+ debug.log('Adding destination ' + index + ':' + record.getId());
+
+ this.destRecords.splice(index, 0, record);
+
+ if (this.hasUpstream) {
+ this.batch(function(ops) {
+ return Promise.all([
+ self.putDestinationIds(ops),
+ record.put(ops)
+ ]);
+ });
+ }
+ }
+
+ destination.onPlaceChange.add(this.handleDestinationPlaceChange);
+ },
+
+ handleDestinationRemove: function(destination) {
+ var self = this;
+
+ var index = destination.getIndex();
+ var removed = this.destRecords.splice(index, 1)[0];
+ if (this.hasUpstream && removed.isValid()) {
+ debug.log('Removing destination ' + index + ':' + removed.getId());
+ this.batch(function(ops) {
+ return Promise.all([
+ self.putDestinationIds(ops),
+ removed.delete(ops)
+ ]);
+ });
+ }
+ },
+
+ updateDestinationPlace: function(destination) {
+ var self = this;
+
+ var index = destination.getIndex();
+ var record = this.destRecords[index];
+ var place = destination.getPlace();
+ var placeData = place && place.toObject();
+
+ if (record && record.isValid()) {
+ record.setPlaceData(placeData, function(placeData, oldPlace) {
+ if (self.hasUpstream) {
+ debug.log('Updating destination ' + index + ':' + this.getId() +
+ '.place = ' + JSON.stringify(oldPlace) + ' => ' +
+ JSON.stringify(placeData));
+
+ self.nonBatched(this.put);
+ }
+ });
+ }
+ },
+
+ pushDestinations: function() {
+ var self = this;
+
+ this.batch(function(ops) {
+ var asyncs = self.destRecords.map(function(record) {
+ return record.put(ops);
+ });
+ asyncs.push(self.putDestinationIds(ops));
+ return Promise.all(asyncs);
+ });
+ },
+
+ /* A note on these operations: SyncBase client operations occur
+ * asynchronously, in response to events that can rapidly change state. As
+ * such, each write operation must first check to ensure the record it's
+ * updating for is still valid (has a defined id).
+ */
+
+ putDestinationIds: function(dao) {
+ var ids = this.destRecords
+ .filter(function(r) { return r.isValid(); })
+ .map(function(r) { return r.getId(); });
+ return dao.put(['destinations'], this.marshal(ids));
+ },
+
marshal: function(x) {
return JSON.stringify(x);
},
@@ -47,13 +257,26 @@
return JSON.parse(x);
},
- processUpdates: function(data) {
+ truncateDestinations: function(targetLength) {
+ if (this.destinations.count() > targetLength) {
+ debug.log('Truncating destinations to ' + targetLength);
+ }
+
+ while (this.destinations.count() > targetLength) {
+ var last = this.destinations.count() - 1;
+ this.destRecords[last].invalidate();
+ this.destinations.remove(last);
+ }
+ },
+
+ processMessages: function(messageData) {
var self = this;
- if (data.messages) {
+
+ if (messageData) {
/* Dispatch new messages in time order, though don't put them before
* local messages. */
var newMessages = [];
- $.each(data.messages, function(id, serializedMessage) {
+ $.each(messageData, function(id, serializedMessage) {
if (!self.messages[id]) {
var message = self.unmarshal(serializedMessage);
newMessages.push(message);
@@ -66,45 +289,197 @@
0;
});
- self.onMessages(newMessages);
+ this.onMessages(newMessages);
}
+ },
+
+ processDestinations: function(destinationsData) {
+ var self = this;
+
+ if (!destinationsData) {
+ if (this.hasUpstream) {
+ this.truncateDestinations(0);
+ } else {
+ //first push with no remote data; push local data as authority
+ this.pushDestinations();
+ }
+
+ } else {
+ var ids;
+ try {
+ ids = this.unmarshal(destinationsData._ || destinationsData);
+ } catch(e) {
+ this.onError(e);
+ //assume it's corrupt and overwrite
+ this.pushDestinations();
+ return;
+ }
+
+ $.each(ids, function(i, id) {
+ /* Don't bother reordering existing destinations by ID; instead, just
+ * overwrite everything. TODO(rosswang): optimize to reorder. */
+ var record = self.destRecords[i];
+ var destination = self.destinations.get(i);
+
+ if (!record) {
+ /* Add the record invalid so that the destination add handler leaves
+ * population to this handler. */
+ record = self.destRecords[i] = self.destinationRecord();
+ destination = self.destinations.add(i);
+ }
+
+ if (record.getId() !== id) {
+ record.setId(id);
+ debug.log('Pulling destination ' + i + ':' + id);
+ }
+
+ var destinationData = destinationsData[id];
+ var newPlace = destinationData &&
+ self.unmarshal(destinationData.place);
+
+ record.setPlaceData(newPlace, function(newPlace, oldPlace) {
+ debug.log('Pulled update for destination ' + i + ':' + id +
+ '.place = ' + JSON.stringify(oldPlace) + ' => ' +
+ JSON.stringify(newPlace));
+
+ if (newPlace) {
+ var cancelled = false;
+ record.cancelPlaceAsync.add(function() {
+ cancelled = true;
+ });
+
+ Place.fromObject(self.mapsDeps, newPlace)
+ .catch(function(err) {
+ //assume it's corrupt and overwrite
+ if (!cancelled) {
+ self.updateDestinationPlace(destination);
+ throw err;
+ }
+ })
+ .then(function(place) {
+ if (!cancelled) {
+ destination.setPlace(place);
+ }
+ }).catch(function(err) {
+ self.onError(err);
+ });
+ } else {
+ destination.setPlace(null);
+ }
+ });
+ });
+
+ if (this.destRecords.length > ids.length) {
+ this.truncateDestinations(ids.length);
+ }
+ }
+
+ this.hasUpstream = true;
+ },
+
+ processUpdates: function(data) {
+ this.processMessages(data.messages);
+ this.processDestinations(data.destinations);
+ },
+
+ start: function(args) {
+ var self = this;
+
+ var vanadiumWrapper = args.vanadiumWrapper;
+ var identity = args.identity;
+
+ var sbName = queryString.parse(location.search).syncbase || 4000;
+ if ($.isNumeric(sbName)) {
+ sbName = '/localhost:' + sbName;
+ }
+
+ var startSyncbase = vanadiumWrapper
+ .syncbase(sbName)
+ .then(function(syncbase) {
+ syncbase.onError.add(self.onError);
+ syncbase.onUpdate.add(self.processUpdates);
+
+ /* TODO(rosswang): Once Vanadium supports global sync-group admin
+ * creation, remove this. For now, use the first local SyncBase
+ * instance to administrate. */
+ var sgAdmin = vanadium.naming.join(
+ identity.mountNames.user, 'sgadmin');
+ return vanadiumWrapper.mount(sgAdmin, sbName,
+ vanadiumWrapper.multiMount.FAIL)
+ .then(function() {
+ var sg = syncbase.syncGroup(sgAdmin, 'trip');
+
+ var spec = sg.buildSpec(
+ [''],
+ [vanadium.naming.join(identity.mountNames.user, 'sgmt')]
+ );
+
+ /* TODO(rosswang): Right now, duplicate SyncBase creates on
+ * different SyncBase instances results in siloed SyncGroups.
+ * Revisit this logic once it merges properly. */
+ return sg.joinOrCreate(spec);
+ })
+ .then(function() {
+ return syncbase;
+ });
+ });
+
+ return Promise.all([
+ vanadiumWrapper.server(
+ vanadium.naming.join(identity.mountNames.device, 'rpc'), this.server),
+ startSyncbase
+ ]).then(function(values) {
+ return {
+ server: values[0],
+ syncbase: values[1]
+ };
+ });
}
},
+ constants: [ 'startup' ],
events: {
+ /**
+ * @param newSize
+ */
+ onTruncateDestinations: '',
+
+ /**
+ * @param i
+ * @param place
+ */
+ onPlaceChange: '',
+
onError: 'memory',
+
/**
* @param messages array of {content, timestamp} pair objects.
*/
onMessages: '',
- onPlanUpdate: '',
+
onStatusUpdate: ''
},
- init: function() {
- this.tripPlan = [];
+ /**
+ * @param promise a promise that produces { mountName, vanadiumWrapper }.
+ * @mapsDependencies an object with the following keys:
+ * maps
+ * placesService
+ */
+ init: function(promise, mapsDependencies) {
+ var self = this;
+
+ this.mapsDeps = mapsDependencies;
+
this.tripStatus = {};
this.messages = {};
+ this.destRecords = [];
this.server = new vdlTravel.TravelSync();
+ this.startup = promise.then(this.start);
- var travelSync = this;
- this.server.get = function(ctx, serverCall) {
- return {
- Plan: travelSync.tripPlan,
- Status: travelSync.tripStatus
- };
- };
-
- this.server.updatePlan = function(ctx, serverCall, plan, message) {
- travelSync.tripPlan = plan;
- travelSync.onPlanUpdate(plan);
- travelSync.onMessage(message);
- };
-
- this.server.updateStatus = function(ctx, serverCall, status) {
- travelSync.tripStatus = status;
- travelSync.onStatusUpdate(status);
+ this.handleDestinationPlaceChange = function() {
+ self.updateDestinationPlace(this);
};
}
});
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index d6f52c4..ba2a3a0 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -7,10 +7,21 @@
var SyncbaseWrapper = require('./syncbase-wrapper');
+var NAME_TTL = 5000;
+var NAME_REFRESH = 2500;
+
var VanadiumWrapper = defineClass({
- init: function(runtime) {
- this.runtime = runtime;
- runtime.on('crash', this.onCrash);
+ statics: {
+ multiMount: {
+ ADD: 0,
+ REPLACE: 1,
+ /**
+ * TODO(rosswang): This mode is not perfect/not entirely supported and is
+ * a hack to allow somewhat deterministic syncbase admin mounting before
+ * mount tables can spin up their own instances.
+ */
+ FAIL: 2
+ }
},
publics: {
@@ -18,6 +29,54 @@
return this.runtime.accountName;
},
+ mount: function(name, server, multiMount) {
+ var self = this;
+
+ multiMount = multiMount || this.multiMount.ADD;
+
+ function refreshName() {
+ var p;
+
+ var context = self.runtime.getContext();
+ var namespace = self.runtime.namespace();
+
+ function mount(replaceMount) {
+ return namespace.mount(context, name, server, NAME_TTL, replaceMount);
+ }
+
+ if (multiMount === self.multiMount.FAIL) {
+ /* TODO(rosswang): of course this isn't perfect; this is a hack to be
+ * removed once we no longer need to mount an admin syncbase
+ * instance. */
+
+
+ p = namespace.resolve(context, name)
+ .then(function(addresses) {
+ if (addresses[0] === server) {
+ return mount(true);
+ }
+ }, function(err) {
+ if (err.id === 'v.io/v23/naming.nameDoesntExist') {
+ return mount(true);
+ } else {
+ throw err;
+ }
+ });
+ } else {
+ p = mount(multiMount === self.multiMount.REPLACE);
+ }
+
+ p.catch(self.onError);
+
+ /* TODO(rosswang): should refresh intervals start here after initiation
+ * or after ack? */
+ setTimeout(refreshName, NAME_REFRESH);
+
+ return p;
+ }
+ return refreshName();
+ },
+
/**
* @param endpoint Vanadium name
* @returns a promise resolving to a client or rejecting with an error.
@@ -45,11 +104,19 @@
},
events: {
- onCrash: 'memory'
+ onCrash: 'memory',
+ onError: 'memory'
+ },
+
+ init: function(runtime) {
+ this.runtime = runtime;
+ runtime.on('crash', this.onCrash);
}
});
module.exports = {
+ multiMount: VanadiumWrapper.multiMount,
+
/**
* @param vanadium optional vanadium override
* @returns a promise resolving to a VanadiumWrapper or rejecting with an
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 5fd994f..0c2c3e7 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -2,11 +2,16 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
+require('es6-shim');
+
var promisify = require('es6-promisify');
var syncbase = require('syncbase');
+var vanadium = require('vanadium');
var defineClass = require('../util/define-class');
+var debug = require('../debug');
+
/**
* Create app, db, and table structure in Syncbase.
*/
@@ -35,9 +40,16 @@
.catch(nonfatals);
}
+function joinKey(key) {
+ return key.join('.');
+}
+
/**
* Translate Syncbase hierarchical keys to object structure for easier
* processing. '.' is chosen as the separator; '/' is reserved in Syncbase.
+ *
+ * It might be ideal to have the separator configurable, but certain separators
+ * need regex escaping.
*/
function recursiveSet(root, key, value) {
var matches = /\.?([^\.]*)(.*)/.exec(key);
@@ -48,13 +60,23 @@
var child = root[member];
if (!child) {
child = root[member] = {};
+ } else if (typeof child !== 'object') {
+ child = root[member] = { _: child };
}
+
recursiveSet(child, remaining, value);
} else {
- root[member] = value;
+ var obj = root[member];
+ if (obj) {
+ obj._ = value;
+ } else {
+ root[member] = value;
+ }
}
}
+var SG_MEMBER_INFO = new syncbase.nosql.SyncGroupMemberInfo();
+
var SyncbaseWrapper = defineClass({
statics: {
start: function(context, mountName) {
@@ -69,28 +91,253 @@
},
publics: {
+ /**
+ * @param seq a function executing the batch operations, receiving as its
+ * `this` context and first parameter the batch operation methods
+ * (put, delete), each of which returns a promise. The callback must return
+ * the overarching promise.
+ */
+ batch: function(fn){
+ var self = this;
+ var opts = new syncbase.nosql.BatchOptions();
+
+ return this.manageWrite(this.runInBatch(this.context, this.db, opts,
+ function(db, cb) {
+ var t = db.table('t');
+ var putToSyncbase = promisify(t.put.bind(t));
+ var deleteFromSyncbase = promisify(t.delete.bind(t));
+
+ var ops = {
+ put: function(k, v) {
+ return self.standardPut(putToSyncbase, k, v);
+ },
+ delete: function(k) {
+ return self.standardDelete(deleteFromSyncbase, k);
+ }
+ };
+
+ fn.call(ops, ops).then(function(result) {
+ return cb(null, result);
+ }, function(err) {
+ return cb(err);
+ });
+ }));
+ },
+
+ /**
+ * @param k array of key elements
+ * @param v serialized value
+ */
+ put: function(k, v) {
+ return this.manageWrite(this.standardPut(this.putToSyncbase, k, v));
+ },
+
+ delete: function(k) {
+ return this.manageWrite(this.standardDelete(this.deleteFromSyncbase, k));
+ },
+
+ /**
+ * Since I/O is asynchronous, sparse, and fast, let's avoid concurrency/
+ * merging with the local syncbase instance by only starting a refresh if
+ * no writes are in progress and the refresh finishes before any new writes
+ * have started. Client watch should help make this better. In any case if
+ * this becomes starved, we can be smarter by being sensitive to keys being
+ * updated at any given time.
+ *
+ * We can also get around this problem by restructuring the data flow to
+ * be unidirectional with the local Syncbase as the authority, though that
+ * introduces (hopefully negligible) latency and complicates forked response
+ * on user input for the same data.
+ *
+ * @returns a void promise for this refresh
+ */
refresh: function() {
var self = this;
- var isHeader = true;
- var query = 'select k, v from t';
- var newData = {};
- this.db.exec(this.context, query, function(err) {
- if (err) {
- self.onError(err);
- } else {
- self.data = newData;
- self.onUpdate(newData);
- }
- }).on('data', function(row) {
- if (isHeader) {
- isHeader = false;
- } else {
- recursiveSet(newData, row[0], row[1]);
- }
- }).on('error', function(err) {
- self.onError(err);
+ var current = this.pull.current;
+ if (!current) {
+ current = this.pull.current = this.pull().then(function(v) {
+ self.pull.current = null;
+ return v;
+ }, function(err) {
+ self.pull.current = null;
+ throw err;
+ });
+ }
+
+ return current;
+ },
+
+ syncGroup: function(sgAdmin, name) {
+ var self = this;
+
+ name = vanadium.naming.join(sgAdmin, '$sync', name);
+ var sg = this.db.syncGroup(name);
+
+ //syncgroup-promisified
+ var sgp;
+
+ function chainable(cb) {
+ return function(err) {
+ cb(err, sgp);
+ };
+ }
+
+ var create = promisify(function(spec, cb) {
+ debug.log('Syncbase: create syncgroup ' + name);
+ sg.create(self.context, spec, SG_MEMBER_INFO, chainable(cb));
});
+
+ var join = promisify(function(cb) {
+ debug.log('Syncbase: join syncgroup ' + name);
+ sg.join(self.context, SG_MEMBER_INFO, chainable(cb));
+ });
+
+ var setSpec = promisify(function(spec, cb) {
+ sg.setSpec(self.context, spec, '', chainable(cb));
+ });
+
+ //be explicit about arg lists because promisify is sensitive to extra args
+ sgp = {
+ buildSpec: function(prefixes, mountTables) {
+ return new syncbase.nosql.SyncGroupSpec({
+ perms: new Map([
+ ['Admin', {in: ['...']}],
+ ['Read', {in: ['...']}],
+ ['Write', {in: ['...']}],
+ ['Resolve', {in: ['...']}],
+ ['Debug', {in: ['...']}]
+ ]),
+ prefixes: prefixes.map(function(p) { return 't:' + p; }),
+ mountTables: mountTables
+ });
+ },
+
+ create: function(spec) { return create(spec); },
+ join: function() { return join(); },
+ setSpec: function(spec) { return setSpec(spec); },
+
+ createOrJoin: function(spec) {
+ return sgp.create(spec)
+ .catch(function(err) {
+ if (err.id === 'v.io/v23/verror.Exist') {
+ debug.log('Syncbase: syncgroup ' + name + ' already exists.');
+ return sgp.join()
+ .then(function() {
+ return sgp.setSpec(spec);
+ });
+ } else {
+ throw err;
+ }
+ });
+ },
+
+ joinOrCreate: function(spec) {
+ return sgp.join()
+ .then(function() {
+ return sgp.setSpec(spec);
+ }, function(err) {
+ if (err.id === 'v.io/v23/verror.NoExist') {
+ debug.log('Syncbase: syncgroup ' + name + ' does not exist.');
+ return sgp.createOrJoin(spec);
+ } else {
+ throw err;
+ }
+ });
+ }
+ };
+
+ return sgp;
+ }
+ },
+
+ privates: {
+ manageWrite: function(promise) {
+ var writes = this.writes;
+
+ this.dirty = true;
+ writes.add(promise);
+
+ return promise.then(function(v) {
+ writes.delete(promise);
+ return v;
+ }, function(err) {
+ writes.delete(promise);
+ throw err;
+ });
+ },
+
+ standardPut: function(fn, k, v) {
+ k = joinKey(k);
+ debug.log('Syncbase: put ' + k + ' = ' + v);
+ return fn(this.context, k, v);
+ },
+
+ standardDelete: function(fn, k) {
+ k = joinKey(k);
+ debug.log('Syncbase: delete ' + k);
+ return fn(this.context, syncbase.nosql.rowrange.prefix(k));
+ },
+
+ /**
+ * @see refresh
+ */
+ pull: function() {
+ var self = this;
+
+ if (this.writes.size) {
+ debug.log('Syncbase: deferring refresh due to writes in progress');
+ return Promise.all(this.writes)
+ .then(this.pull, this.pull);
+
+ } else {
+ this.dirty = false;
+
+ return new Promise(function(resolve, reject) {
+ var newData = {};
+ var abort = false;
+
+ var isHeader = true;
+
+ self.db.exec(self.context, 'select k, v from t', function(err) {
+ if (err) {
+ reject(err);
+ } else if (abort) {
+ //no-op; promise has already been resolved.
+ } else if (self.dirty) {
+ debug.log('Syncbase: aborting refresh due to writes');
+ resolve(self.pull()); //try/wait for idle again
+ } else {
+ self.onUpdate(newData);
+ resolve();
+ }
+ }).on('data', function(row) {
+ if (isHeader) {
+ isHeader = false;
+ return;
+ }
+
+ if (abort) {
+ //no-op
+ } else if (self.dirty) {
+ abort = true;
+ debug.log('Syncbase: aborting refresh due to writes');
+ resolve(self.pull()); //try/wait for idle again
+ /* It would be nice to abort this stream for real, but we can't.
+ * Leave this handler attached but no-oping to drain the stream.
+ */
+ } else {
+ recursiveSet(newData, row[0], row[1]);
+ }
+ }).on('error', reject);
+ }).catch(function(err) {
+ if (err.id === 'v.io/v23/verror.Internal') {
+ console.error(err);
+ } else {
+ throw err;
+ }
+ });
+ }
}
},
@@ -103,15 +350,26 @@
var self = this;
this.context = context;
this.db = db;
- this.data = {};
+ this.t = db.table('t');
+
+ this.writes = new Set();
+
+ this.runInBatch = promisify(syncbase.nosql.runInBatch);
+ this.putToSyncbase = promisify(this.t.put.bind(this.t));
+ this.deleteFromSyncbase = promisify(this.t.delete.bind(this.t));
// Start the watch loop to periodically poll for changes from sync.
// TODO(rosswang): Remove this once we have client watch.
this.watchLoop = function() {
- self.refresh();
+ if (!self.pull.current) {
+ self.refresh().catch(self.onError);
+ }
setTimeout(self.watchLoop, 500);
};
- process.nextTick(self.watchLoop);
+ // TODO(rosswang): Right now sync fails if the initial db has a conflict, so
+ // for now add a delay so that sync happens before we start db actions
+ //process.nextTick(self.watchLoop);
+ setTimeout(self.watchLoop, 2000);
}
});
diff --git a/test/components/map.js b/test/components/map-widget.js
similarity index 84%
rename from test/components/map.js
rename to test/components/map-widget.js
index 340e935..a5beae1 100644
--- a/test/components/map.js
+++ b/test/components/map-widget.js
@@ -4,14 +4,14 @@
var test = require('tape');
-var Map = require('../../src/components/map');
+var MapWidget = require('../../src/components/map-widget');
var mockMaps = require('../../mocks/google-maps');
test('instantiation', function(t) {
t.doesNotThrow(function() {
//instantiation smoke test
/* jshint -W031 */
- new Map({
+ new MapWidget({
maps: mockMaps
});
/* jshint +W031 */
diff --git a/test/travelsync.js b/test/travelsync.js
index f86b753..7721166 100644
--- a/test/travelsync.js
+++ b/test/travelsync.js
@@ -4,9 +4,11 @@
var test = require('tape');
+var Deferred = require('vanadium/src/lib/deferred');
+
var TravelSync = require('../src/travelsync');
test('init', function(t) {
- t.ok(new TravelSync(), 'initializes');
+ t.ok(new TravelSync(new Deferred().promise), 'initializes');
t.end();
});
\ No newline at end of file
diff --git a/tools/start_services.sh b/tools/start_services.sh
index 6432e60..1247ac5 100644
--- a/tools/start_services.sh
+++ b/tools/start_services.sh
@@ -26,12 +26,10 @@
}
main() {
local -r TMP=tmp
- local -r PORT=${PORT-4000}
+ local -r PORT=${port-4000}
local -r MOUNTTABLED_ADDR=":$((PORT+1))"
- local -r SYNCBASED_ADDR=":$((PORT+2))"
+ local -r SYNCBASED_ADDR=":$((PORT))"
mkdir -p $TMP
- # TODO(rosswang): Run mounttabled and syncbased each with its own blessing
- # extension.
${V23_ROOT}/release/go/bin/mounttabled \
--v23.tcp.address=${MOUNTTABLED_ADDR} \
--v23.credentials=${TMP}/creds &