Skip to content
Browse files

initial draft of a 0.5.0 branch

  • Loading branch information...
1 parent e8476af commit cf3215139acc86a54e94f039a0dbf627b49a5e14 @jashkenas jashkenas committed
Showing with 170 additions and 105 deletions.
  1. +92 −48 backbone.js
  2. +1 −1 examples/todos/todos.js
  3. +27 −17 index.html
  4. +1 −1 package.json
  5. +21 −11 test/collection.js
  6. +27 −26 test/{controller.js → router.js}
  7. +1 −1 test/test.html
View
140 backbone.js
@@ -1,4 +1,4 @@
-// Backbone.js 0.3.3
+// Backbone.js 0.5.0-pre
// (c) 2010 Jeremy Ashkenas, DocumentCloud Inc.
// Backbone may be freely distributed under the MIT license.
// For all details and documentation:
@@ -25,7 +25,7 @@
}
// Current version of the library. Keep in sync with `package.json`.
- Backbone.VERSION = '0.3.3';
+ Backbone.VERSION = '0.5.0-pre';
// Require Underscore, if we're on the server, and it's not already present.
var _ = root._;
@@ -416,7 +416,7 @@
}
_.bindAll(this, '_onModelEvent', '_removeReference');
this._reset();
- if (models) this.refresh(models, {silent: true});
+ if (models) this.reset(models, {silent: true});
this.initialize(models, options);
};
@@ -485,7 +485,7 @@
options || (options = {});
if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
this.models = this.sortBy(this.comparator);
- if (!options.silent) this.trigger('refresh', this, options);
+ if (!options.silent) this.trigger('reset', this, options);
return this;
},
@@ -497,13 +497,13 @@
// When you have more items than you want to add or remove individually,
// you can refresh the entire set with a new list of models, without firing
// any `added` or `removed` events. Fires `refresh` when finished.
- refresh : function(models, options) {
+ reset : function(models, options) {
models || (models = []);
options || (options = {});
this.each(this._removeReference);
this._reset();
this.add(models, {silent: true});
- if (!options.silent) this.trigger('refresh', this, options);
+ if (!options.silent) this.trigger('reset', this, options);
return this;
},
@@ -515,7 +515,7 @@
var collection = this;
var success = options.success;
options.success = function(resp, status, xhr) {
- collection[options.add ? 'add' : 'refresh'](collection.parse(resp, xhr), options);
+ collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options);
if (success) success(collection, resp);
};
options.error = wrapError(options.error, collection, options);
@@ -578,7 +578,8 @@
if (!model.collection) {
model.collection = this;
}
- var index = this.comparator ? this.sortedIndex(model, this.comparator) : this.length;
+ var index = this.comparator ? this.sortedIndex(model, this.comparator) :
+ options.at != null ? options.at : this.length;
this.models.splice(index, 0, model);
model.bind('all', this._onModelEvent);
this.length++;
@@ -640,12 +641,12 @@
};
});
- // Backbone.Controller
+ // Backbone.Router
// -------------------
- // Controllers map faux-URLs to actions, and fire events when routes are
+ // Routers map faux-URLs to actions, and fire events when routes are
// matched. Creating a new one sets its `routes` hash, if not set statically.
- Backbone.Controller = function(options) {
+ Backbone.Router = function(options) {
options || (options = {});
if (options.routes) this.routes = options.routes;
this._bindRoutes();
@@ -658,8 +659,8 @@
var splatParam = /\*([\w\d]+)/g;
var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g;
- // Set up all inheritable **Backbone.Controller** properties and methods.
- _.extend(Backbone.Controller.prototype, Backbone.Events, {
+ // Set up all inheritable **Backbone.Router** properties and methods.
+ _.extend(Backbone.Router.prototype, Backbone.Events, {
// Initialize is an empty function by default. Override it with your own
// initialization logic.
@@ -674,8 +675,8 @@
route : function(route, name, callback) {
Backbone.history || (Backbone.history = new Backbone.History);
if (!_.isRegExp(route)) route = this._routeToRegExp(route);
- Backbone.history.route(route, _.bind(function(hash) {
- var args = this._extractParameters(route, hash);
+ Backbone.history.route(route, _.bind(function(fragment) {
+ var args = this._extractParameters(route, fragment);
callback.apply(this, args);
this.trigger.apply(this, ['route:' + name].concat(args));
}, this));
@@ -683,8 +684,16 @@
// Simple proxy to `Backbone.history` to save a fragment into the history,
// without triggering routes.
- saveLocation : function(hash) {
- Backbone.history.saveLocation(hash);
+ saveLocation : function(fragment) {
+ Backbone.history.saveLocation(fragment);
+ },
+
+ // Simple proxy to `Backbone.history` to both save a fragment into the
+ // history and to then load the route at that fragment. Used in place
+ // of settings `window.location.hash` when using `window.history.pushState`.
+ loadUrl : function(fragment) {
+ Backbone.history.saveLocation(fragment);
+ Backbone.history.loadUrl();
},
// Bind all defined routes to `Backbone.history`. We have to reverse the
@@ -712,8 +721,8 @@
// Given a route, and a URL fragment that it matches, return the array of
// extracted parameters.
- _extractParameters : function(route, hash) {
- return route.exec(hash).slice(1);
+ _extractParameters : function(route, fragment) {
+ return route.exec(fragment).slice(1);
}
});
@@ -721,7 +730,7 @@
// Backbone.History
// ----------------
- // Handles cross-browser history management, based on URL hashes. If the
+ // Handles cross-browser history management, based on URL fragments. If the
// browser does not support `onhashchange`, falls back to polling.
Backbone.History = function() {
this.handlers = [];
@@ -745,32 +754,54 @@
interval: 50,
// Get the cross-browser normalized URL fragment.
- getHash : function(loc) {
- return (loc || window.location).hash.replace(hashStrip, '');
+ getFragment : function(fragment, forcePushState) {
+ if (!fragment) {
+ if (this._hasPushState || forcePushState) {
+ fragment = window.location.pathname;
+ var search = window.location.search;
+ if (search) fragment += search;
+ if (fragment.indexOf(this.options.root) == 0) fragment = fragment.substr(this.options.root.length);
+ } else {
+ fragment = window.location.hash;
+ }
+ }
+ return fragment.replace(hashStrip, '');
},
// Start the hash change handling, returning `true` if the current URL matches
// an existing route, and `false` otherwise.
- start : function() {
+ start : function(options) {
if (historyStarted) throw new Error("Backbone.history has already been started");
- var hash = this.getHash();
+ this.options = _.extend({}, {root: '/'}, options);
+ this._wantsPushState = !!this.options.pushState;
+ this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState);
+ var fragment = this.getFragment();
var docMode = document.documentMode;
var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7));
if (oldIE) {
this.iframe = $('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow;
- this.saveLocation(hash);
+ this.saveLocation(fragment);
}
- if ('onhashchange' in window && !oldIE) {
+ if (this._hasPushState) {
+ $(window).bind('popstate', this.checkUrl);
+ } else if ('onhashchange' in window && !oldIE) {
$(window).bind('hashchange', this.checkUrl);
} else {
setInterval(this.checkUrl, this.interval);
}
- this.hash = hash;
+ this.fragment = fragment;
historyStarted = true;
- return this.loadUrl();
+ var started = this.loadUrl() || this.loadUrl(window.location.hash);
+
+ if (this._wantsPushState && !this._hasPushState && window.location.pathname != this.options.root) {
+ this.fragment = this.getFragment(null, true);
+ window.location.href = this.options.root + '#' + this.fragment;
+ } else {
+ return started;
+ }
},
- // Add a route to be tested when the hash changes. Routes added later may
+ // Add a route to be tested when the fragment changes. Routes added later may
// override previous routes.
route : function(route, callback) {
this.handlers.unshift({route : route, callback : callback});
@@ -778,23 +809,28 @@
// Checks the current URL to see if it has changed, and if it has,
// calls `loadUrl`, normalizing across the hidden iframe.
- checkUrl : function() {
- var hash = this.getHash();
- if (hash == this.hash && this.iframe) hash = this.getHash(this.iframe.location);
- if (hash == this.hash || hash == decodeURIComponent(this.hash)) return false;
- if (this.iframe) this.saveLocation(hash);
- this.hash = hash;
- this.loadUrl();
+ checkUrl : function(e) {
+ var current = this.getFragment();
+ if (current == this.fragment && this.iframe) {
+ current = this.getFragment(this.iframe.location);
+ }
+ if (current == this.fragment ||
+ current == decodeURIComponent(this.fragment)) return false;
+
+ if (this.iframe) {
+ window.location.hash = this.iframe.location.hash = current;
+ }
+ this.loadUrl() || this.loadUrl(window.location.hash);
},
// Attempt to load the current URL fragment. If a route succeeds with a
// match, returns `true`. If no defined routes matches the fragment,
// returns `false`.
- loadUrl : function() {
- var hash = this.hash;
+ loadUrl : function(fragment) {
+ fragment = this.fragment = this.getFragment(fragment);
var matched = _.any(this.handlers, function(handler) {
- if (handler.route.test(hash)) {
- handler.callback(hash);
+ if (handler.route.test(fragment)) {
+ handler.callback(fragment);
return true;
}
});
@@ -804,13 +840,21 @@
// Save a fragment into the hash history. You are responsible for properly
// URL-encoding the fragment in advance. This does not trigger
// a `hashchange` event.
- saveLocation : function(hash) {
- hash = (hash || '').replace(hashStrip, '');
- if (this.hash == hash) return;
- window.location.hash = this.hash = hash;
- if (this.iframe && (hash != this.getHash(this.iframe.location))) {
- this.iframe.document.open().close();
- this.iframe.location.hash = hash;
+ saveLocation : function(fragment) {
+ fragment = (fragment || '').replace(hashStrip, '');
+ if (this.fragment == fragment || this.fragment == decodeURIComponent(fragment)) return;
+
+ if (this._hasPushState) {
+ var loc = window.location;
+ if (fragment.indexOf(this.options.root) != 0) fragment = this.options.root + fragment;
+ this.fragment = fragment;
+ window.history.pushState({}, document.title, loc.protocol + '//' + loc.host + fragment);
+ } else {
+ window.location.hash = this.fragment = fragment;
+ if (this.iframe && (fragment != this.getFragment(this.iframe.location))) {
+ this.iframe.document.open().close();
+ this.iframe.location.hash = fragment;
+ }
}
}
@@ -950,7 +994,7 @@
// Set up inheritance for the model, collection, and view.
Backbone.Model.extend = Backbone.Collection.extend =
- Backbone.Controller.extend = Backbone.View.extend = extend;
+ Backbone.Router.extend = Backbone.View.extend = extend;
// Map from CRUD to HTTP for our default `Backbone.sync` implementation.
var methodMap = {
View
2 examples/todos/todos.js
@@ -187,7 +187,7 @@ $(function(){
this.input = this.$("#new-todo");
Todos.bind('add', this.addOne);
- Todos.bind('refresh', this.addAll);
+ Todos.bind('reset', this.addAll);
Todos.bind('all', this.render);
Todos.fetch();
View
44 index.html
@@ -220,7 +220,7 @@
<li>– <a href="#Collection-url">url</a></li>
<li>– <a href="#Collection-parse">parse</a></li>
<li>– <a href="#Collection-fetch">fetch</a></li>
- <li>– <a href="#Collection-refresh">refresh</a></li>
+ <li>– <a href="#Collection-reset">reset</a></li>
<li>– <a href="#Collection-create">create</a></li>
</ul>
@@ -1027,7 +1027,7 @@ <h2 id="Collection">Backbone.Collection</h2>
Override this property to specify the model class that the collection
contains. If defined, you can pass raw attributes objects (and arrays) to
<a href="#Collection-add">add</a>, <a href="#Collection-create">create</a>,
- and <a href="#Collection-refresh">refresh</a>, and the attributes will be
+ and <a href="#Collection-reset">reset</a>, and the attributes will be
converted into a model of the proper type.
</p>
@@ -1140,6 +1140,8 @@ <h2 id="Collection">Backbone.Collection</h2>
event, which you can pass <tt>{silent: true}</tt> to suppress. If a
<a href="#Collection-model">model</a> property is defined, you may also pass
raw attributes objects, and have them be vivified as instances of the model.
+ Pass <tt>{at: index}</tt> to splice the model into the collection at the
+ specified <tt>index</tt>.
</p>
<pre class="runnable">
@@ -1240,7 +1242,7 @@ <h2 id="Collection">Backbone.Collection</h2>
Force a collection to re-sort itself. You don't need to call this under
normal circumstances, as a collection with a <a href="#Collection-comparator">comparator</a> function
will maintain itself in proper sort order at all times. Calling <b>sort</b>
- triggers the collection's <tt>"refresh"</tt> event, unless silenced by passing
+ triggers the collection's <tt>"reset"</tt> event, unless silenced by passing
<tt>{silent: true}</tt>
</p>
@@ -1314,7 +1316,7 @@ <h2 id="Collection">Backbone.Collection</h2>
<tt>success</tt> and <tt>error</tt>
callbacks which will be passed <tt>(collection, response)</tt> as arguments.
When the model data returns from the server, the collection will
- <a href="#Collection-refresh">refresh</a>.
+ <a href="#Collection-reset">reset</a>.
Delegates to <a href="#Sync">Backbone.sync</a>
under the covers, for custom persistence strategies.
The server handler for <b>fetch</b> requests should return a JSON array of
@@ -1347,24 +1349,25 @@ <h2 id="Collection">Backbone.Collection</h2>
toggled open and closed.
</p>
- <p id="Collection-refresh">
- <b class="header">refresh</b><code>collection.refresh(models, [options])</code>
+ <p id="Collection-reset">
+ <b class="header">reset</b><code>collection.reset(models, [options])</code>
<br />
Adding and removing models one at a time is all well and good, but sometimes
you have so many models to change that you'd rather just update the collection
- in bulk. Use <b>refresh</b> to replace a collection with a new list
- of models (or attribute hashes), triggering a single <tt>"refresh"</tt> event
- at the end. Pass <tt>{silent: true}</tt> to suppress the <tt>"refresh"</tt> event.
+ in bulk. Use <b>reset</b> to replace a collection with a new list
+ of models (or attribute hashes), triggering a single <tt>"reset"</tt> event
+ at the end. Pass <tt>{silent: true}</tt> to suppress the <tt>"reset"</tt> event.
+ Using reset with no arguments is useful as a way to empty the collection.
</p>
<p>
- Here's an example using <b>refresh</b> to bootstrap a collection during initial page load,
+ Here's an example using <b>reset</b> to bootstrap a collection during initial page load,
in a Rails application.
</p>
<pre>
&lt;script&gt;
- Accounts.refresh(&lt;%= @accounts.to_json %&gt;);
+ Accounts.reset(&lt;%= @accounts.to_json %&gt;);
&lt;/script&gt;
</pre>
@@ -2145,7 +2148,7 @@ <h2 id="faq">F.A.Q.</h2>
<ul>
<li><b>"add"</b> (model, collection) &mdash; when a model is added to a collection. </li>
<li><b>"remove"</b> (model, collection) &mdash; when a model is removed from a collection. </li>
- <li><b>"refresh"</b> (collection) &mdash; when the collection's entire contents have been replaced. </li>
+ <li><b>"reset"</b> (collection) &mdash; when the collection's entire contents have been replaced. </li>
<li><b>"change"</b> (model, collection) &mdash; when a model's attributes have changed. </li>
<li><b>"change:[attribute]"</b> (model, collection) &mdash; when a specific attribute has been updated. </li>
<li><b>"destrooy"</b> (model, collection) &mdash; when a model is <a href="#Model-destroy">destroyed</a>. </li>
@@ -2171,7 +2174,7 @@ <h2 id="faq">F.A.Q.</h2>
initialize: function() {
this.messages = new Messages;
this.messages.url = '/mailbox/' + this.id + '/messages';
- this.messages.bind("refresh", this.updateCounts);
+ this.messages.bind("reset", this.updateCounts);
},
...
@@ -2192,7 +2195,7 @@ <h2 id="faq">F.A.Q.</h2>
you know you're going to need, in order to render the page. Instead of
firing an extra AJAX request to <a href="#Collection-fetch">fetch</a> them,
a nicer pattern is to have their data already bootstrapped into the page.
- You can then use <a href="#Collection-refresh">refresh</a> to populate your
+ You can then use <a href="#Collection-reset">reset</a> to populate your
collections with the initial data. At DocumentCloud, in the
<a href="http://en.wikipedia.org/wiki/ERuby">ERB</a> template for the
workspace, we do something along these lines:
@@ -2200,8 +2203,8 @@ <h2 id="faq">F.A.Q.</h2>
<pre>
&lt;script&gt;
- Accounts.refresh(&lt;%= @accounts.to_json %&gt;);
- Projects.refresh(&lt;%= @projects.to_json(:collaborators => true) %&gt;);
+ Accounts.reset(&lt;%= @accounts.to_json %&gt;);
+ Projects.reset(&lt;%= @projects.to_json(:collaborators => true) %&gt;);
&lt;/script&gt;
</pre>
@@ -2270,7 +2273,7 @@ <h2 id="faq">F.A.Q.</h2>
_.bindAll(this, "addMessage", "removeMessage", "render");
var messages = this.collection;
- messages.bind("refresh", this.render);
+ messages.bind("reset", this.render);
messages.bind("add", this.addMessage);
messages.bind("remove", this.removeMessage);
}
@@ -2305,6 +2308,13 @@ <h2 id="faq">F.A.Q.</h2>
<h2 id="changelog">Change Log</h2>
<p>
+ <b class="header">0.4.0</b> &mdash; <small><i>FUTURE DATE, 2011</i></small><br />
+ <tt>Collection.refresh</tt> renamed to <tt>Collection.reset</tt> to emphasize
+ its ability to both refresh the collection with new models, as well as empty
+ out the collection when used with no parameters.
+ </p>
+
+ <p>
<b class="header">0.3.3</b> &mdash; <small><i>Dec 1, 2010</i></small><br />
Backbone.js now supports <a href="http://zeptojs.com">Zepto</a>, alongside
jQuery, as a framework for DOM manipulation and Ajax support.
View
2 package.json
@@ -10,5 +10,5 @@
},
"lib" : ".",
"main" : "backbone.js",
- "version" : "0.3.3"
+ "version" : "0.5.0-pre"
}
View
32 test/collection.js
@@ -83,6 +83,16 @@ $(document).ready(function() {
equals(otherCol.length, 1);
equals(secondAdded, null);
ok(opts.amazing);
+
+ var f = new Backbone.Model({id: 20, label : 'f'});
+ var g = new Backbone.Model({id: 21, label : 'g'});
+ var h = new Backbone.Model({id: 22, label : 'h'});
+ var atCol = new Backbone.Collection([f, g, h]);
+ equals(atCol.length, 3);
+ atCol.add(e, {at: 1});
+ equals(atCol.length, 4);
+ equals(atCol.at(1), e);
+ equals(atCol.last(), h);
});
test("Collection: add model to multiple collections", function() {
@@ -131,7 +141,7 @@ $(document).ready(function() {
emcees.bind('change', function(){ counter++; });
dj.set({name : 'Kool'});
equals(counter, 1);
- emcees.refresh([]);
+ emcees.reset([]);
equals(dj.collection, undefined);
dj.set({name : 'Shadow'});
equals(counter, 1);
@@ -283,20 +293,20 @@ $(document).ready(function() {
[0, 4]);
});
- test("Collection: refresh", function() {
- var refreshed = 0;
+ test("Collection: reset", function() {
+ var resetCount = 0;
var models = col.models;
- col.bind('refresh', function() { refreshed += 1; });
- col.refresh([]);
- equals(refreshed, 1);
+ col.bind('reset', function() { resetCount += 1; });
+ col.reset([]);
+ equals(resetCount, 1);
equals(col.length, 0);
equals(col.last(), null);
- col.refresh(models);
- equals(refreshed, 2);
+ col.reset(models);
+ equals(resetCount, 2);
equals(col.length, 4);
equals(col.last(), a);
- col.refresh(_.map(models, function(m){ return m.attributes; }));
- equals(refreshed, 3);
+ col.reset(_.map(models, function(m){ return m.attributes; }));
+ equals(resetCount, 3);
equals(col.length, 4);
ok(col.last() !== a);
ok(_.isEqual(col.last().attributes, a.attributes));
@@ -304,7 +314,7 @@ $(document).ready(function() {
test("Collection: trigger custom events on models", function() {
var fired = null;
- a.bind("custom", function() { fired = true });
+ a.bind("custom", function() { fired = true; });
a.trigger("custom");
equals(fired, true);
});
View
53 test/controller.js → test/router.js
@@ -1,8 +1,8 @@
$(document).ready(function() {
- module("Backbone.Controller");
+ module("Backbone.Router");
- var Controller = Backbone.Controller.extend({
+ var Router = Backbone.Router.extend({
routes: {
"search/:query": "search",
@@ -43,74 +43,75 @@ $(document).ready(function() {
});
- var controller = new Controller({testing: 101});
+ Backbone.history = null;
+ var router = new Router({testing: 101});
Backbone.history.interval = 9;
- Backbone.history.start();
+ Backbone.history.start({pushState: false});
- test("Controller: initialize", function() {
- equals(controller.testing, 101);
+ test("Router: initialize", function() {
+ equals(router.testing, 101);
});
- asyncTest("Controller: routes (simple)", 2, function() {
+ asyncTest("Router: routes (simple)", 2, function() {
window.location.hash = 'search/news';
setTimeout(function() {
- equals(controller.query, 'news');
- equals(controller.page, undefined);
+ equals(router.query, 'news');
+ equals(router.page, undefined);
start();
}, 10);
});
- asyncTest("Controller: routes (two part)", 2, function() {
+ asyncTest("Router: routes (two part)", 2, function() {
window.location.hash = 'search/nyc/p10';
setTimeout(function() {
- equals(controller.query, 'nyc');
- equals(controller.page, '10');
+ equals(router.query, 'nyc');
+ equals(router.page, '10');
start();
}, 10);
});
- asyncTest("Controller: routes (splats)", function() {
+ asyncTest("Router: routes (splats)", function() {
window.location.hash = 'splat/long-list/of/splatted_99args/end';
setTimeout(function() {
- equals(controller.args, 'long-list/of/splatted_99args');
+ equals(router.args, 'long-list/of/splatted_99args');
start();
}, 10);
});
- asyncTest("Controller: routes (complex)", 3, function() {
+ asyncTest("Router: routes (complex)", 3, function() {
window.location.hash = 'one/two/three/complex-part/four/five/six/seven';
setTimeout(function() {
- equals(controller.first, 'one/two/three');
- equals(controller.part, 'part');
- equals(controller.rest, 'four/five/six/seven');
+ equals(router.first, 'one/two/three');
+ equals(router.part, 'part');
+ equals(router.rest, 'four/five/six/seven');
start();
}, 10);
});
- asyncTest("Controller: routes (query)", 2, function() {
+ asyncTest("Router: routes (query)", 2, function() {
window.location.hash = 'mandel?a=b&c=d';
setTimeout(function() {
- equals(controller.entity, 'mandel');
- equals(controller.queryArgs, 'a=b&c=d');
+ equals(router.entity, 'mandel');
+ equals(router.queryArgs, 'a=b&c=d');
start();
}, 10);
});
- asyncTest("Controller: routes (anything)", 1, function() {
+ asyncTest("Router: routes (anything)", 1, function() {
window.location.hash = 'doesnt-match-a-route';
setTimeout(function() {
- equals(controller.anything, 'doesnt-match-a-route');
+ equals(router.anything, 'doesnt-match-a-route');
start();
window.location.hash = '';
}, 10);
});
- asyncTest("Controller: routes (hashbang)", 2, function() {
+ asyncTest("Router: routes (hashbang)", 2, function() {
window.location.hash = '!search/news';
setTimeout(function() {
- equals(controller.query, 'news');
- equals(controller.page, undefined);
+ equals(router.query, 'news');
+ equals(router.page, undefined);
start();
}, 10);
});
View
2 test/test.html
@@ -14,7 +14,7 @@
<script type="text/javascript" src="events.js"></script>
<script type="text/javascript" src="model.js"></script>
<script type="text/javascript" src="collection.js"></script>
- <script type="text/javascript" src="controller.js"></script>
+ <script type="text/javascript" src="router.js"></script>
<script type="text/javascript" src="view.js"></script>
<script type="text/javascript" src="sync.js"></script>
<script type="text/javascript" src="speed.js"></script>

0 comments on commit cf32151

Please sign in to comment.
Something went wrong with that request. Please try again.