PageRenderTime 56ms CodeModel.GetById 26ms RepoModel.GetById 0ms app.codeStats 1ms

/pancake-web/pancake/web/static/js/models/stackmodel.js

https://bitbucket.org/mozillapancake/pancake
JavaScript | 631 lines | 361 code | 115 blank | 155 comment | 35 complexity | 331fd008dfd0207fd3252fc3cf747a28 MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception, LGPL-2.1, MIT, Apache-2.0
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. define([
  5. 'config',
  6. 'underscore',
  7. 'lib/objecttools',
  8. 'backbone',
  9. 'models/placecollection',
  10. 'models/thumbnails',
  11. 'models/basemodel',
  12. 'models/eras',
  13. 'lib/promise',
  14. 'lib/bridge',
  15. 'lib/resthelper',
  16. 'lib/lazily',
  17. 'lib/template',
  18. 'logger',
  19. 'lib/errors'
  20. ], function (
  21. config,
  22. util,
  23. objectTools,
  24. Backbone,
  25. PlaceCollection,
  26. thumbnails,
  27. BaseModel,
  28. eras,
  29. Promise,
  30. Bridge,
  31. RestHelper,
  32. lazily,
  33. template,
  34. logger,
  35. errors
  36. ) {
  37. var bridge = new Bridge();
  38. var flagError = function(msg){
  39. return function(err) {
  40. logger.error.apply(logger, [msg, err]);
  41. };
  42. };
  43. // Use the model constructor from PlaceCollection.
  44. var SiteModel = PlaceCollection.prototype.model,
  45. __SiteModel = SiteModel.prototype;
  46. var latticeUrl = config.latticeUrl + '{query}';
  47. var tokens = {
  48. latticeRoot: config.latticeRoot,
  49. username: config.username,
  50. service: 'stack'
  51. };
  52. var v2 = { v: 2 };
  53. // A private helper function for creating and adding and search places after
  54. // `success` events. Used below in `StackModel.prototype.processPlace` and
  55. // `StackModel.prototype.processSearchPlace`.
  56. //
  57. // Invoke, passing the `this` context of the current object:
  58. //
  59. // configPlace.call(this, attr);
  60. //
  61. // Attr should be an already-parsed object.
  62. var configPlace = function (attr) {
  63. var siteModel = this.places().get(attr.place_id) || new SiteModel();
  64. siteModel.set(attr, { silent: true });
  65. // If the siteModel is new (hasn't been added to the collection yet),
  66. // add it.
  67. if (!siteModel.collection) this.addPlace(siteModel);
  68. return siteModel;
  69. };
  70. var __BaseModel = BaseModel.prototype;
  71. var StackModel = BaseModel.extend({
  72. idAttribute: 'stack_id',
  73. initialize: function(attrs, options){
  74. if (options) util.extend(this, options);
  75. var urls = this.urls = new RestHelper();
  76. urls.method(
  77. 'fetch',
  78. config.latticeUrl + '/active{query}',
  79. // At this point, we probably don't have an ID to work with.
  80. util.extend({
  81. // Look for stack_id every time the method is invoked -- this way
  82. // we don't need a stack_id when we initialize, only when we fetch.
  83. method: util.bind(this.get, this, 'stack_id')
  84. }, tokens),
  85. v2
  86. );
  87. // Configure a link method for urls.
  88. urls.method('link', latticeUrl, util.extend({
  89. method: 'link'
  90. }, tokens), v2);
  91. // Configure a search method for urls.
  92. urls.method('search', latticeUrl, util.extend({
  93. method: 'search'
  94. }, tokens), v2);
  95. urls.method('social', latticeUrl, util.extend({
  96. method: 'social'
  97. }, tokens), v2);
  98. urls.method('direct', latticeUrl, util.extend({
  99. method: 'direct'
  100. }, tokens), v2);
  101. // /lattice/:username/session/active/:session_id/close
  102. urls.remove = function(urlTokens, query){
  103. urlTokens = urlTokens || {};
  104. util.extend(urlTokens, tokens, {
  105. service: 'session/active',
  106. method: 'close'
  107. });
  108. // {latticeRoot}/{username}/{service}/{method} -> {latticeRoot}/{username}/{service}/{session_id}{method}
  109. var urlTemplate = config.latticeUrl.replace('{method}', '{session_id}/{method}');
  110. return template(urlTemplate)(urlTokens, util.defaults(query || {}, v2));
  111. };
  112. // Bind our AJAX post-processing handler to AJAX `success` event.
  113. this.bind('success', this.onSuccess, this);
  114. },
  115. // Bound to `success` event in `initialize`. Success fires whenever
  116. // `process()` method is called. `onSuccess` contains various routines
  117. // for processing tangetial info. Typically, you should check for a
  118. // property's existence on the response object, and then forward
  119. // relevant details to a function tasked with processing that information.
  120. onSuccess: function (resp, options) {
  121. // FYI, order of operation currently DOES matter here, since we're
  122. // using `Date.now()` during place creation to sub in for
  123. // `accessed` property, which is not returned during creation.
  124. if (resp.search_place) this.processSearchPlace(resp);
  125. if (resp.profile_place) this.processProfilePlace(resp);
  126. if (resp.place) this.processPlace(resp);
  127. },
  128. set: function (attrs, options) {
  129. // Parse out places, pass to `places()`.
  130. if (attrs.places) {
  131. // We trust that `StackModel.parse` has transformed places into a JSON
  132. // representation that is compatible with `this.places`.
  133. this.places().add(attrs.places);
  134. // Remove places reference from object.
  135. delete attrs.places;
  136. }
  137. return __BaseModel.set.call(this, attrs, options);
  138. },
  139. url: function (tokens, query) {
  140. return this.urls.fetch(tokens, query);
  141. },
  142. // Issue a warp request to Lattice for this stack.
  143. // At this point, this method is simply a wrapper around `place.save()`.
  144. warp: function (place_id) {
  145. var place = this.fetchPlace(place_id);
  146. return Promise.whenPromise(place, function (place) {
  147. // Issue a warp request.
  148. // TODO: determine how we can tell new sites apart from previously
  149. // existing ones.
  150. place.save();
  151. }, flagError("stackModel.fetchPlace resulted in an error"));
  152. },
  153. visit: function(){
  154. // SF:WORK IN PROGRESS:
  155. // register a visit to this stack.
  156. // this is a PUT (update) on the collection that this stack is a part of.
  157. var url = this.collection.url();
  158. $.ajax({
  159. url: url,
  160. type: "PUT",
  161. success: function(resp){
  162. alert("visit registration success\nTODO: stash the session id we were provided, and trigger an event for reaction elsewhere");
  163. },
  164. data: {
  165. place_id: "FIXME: place_id needed",
  166. stack_id: this.id
  167. },
  168. error: flagError("Error response from visit PUT request")
  169. });
  170. },
  171. // Create a hard link to a place within this stack.
  172. // Issues a `POST lattice/:username/stack/link`.
  173. // Automatically adds the resulting SiteModel to
  174. // this StackModel's PlaceCollection.
  175. // Returns a Promise.
  176. link: function (attr, options) {
  177. var required = [
  178. 'place_url',
  179. 'place_title'
  180. ];
  181. options = options || {};
  182. var linkPromise = new Promise();
  183. // Check for required keys.
  184. var missing = objectTools.missing(attr, required);
  185. if (missing.length) throw new errors.KeysMissingError(null, missing);
  186. // Decorate SiteModel with `session_id` and `stack_id`.
  187. // `this.addPlace` also does decoration, but we need to issue a fetch
  188. // before we get the chance to addPlace. Since fetch requires these
  189. // properties, let's go ahead and add them now.
  190. util.defaults(attr, {
  191. session_id: this.get('session_id'),
  192. stack_id: this.get('stack_id')
  193. });
  194. // Create a new siteModel from the attributes provided.
  195. var siteModel = new SiteModel(attr);
  196. // Configure options.
  197. // Get the URL we want to hit. Updated from options.
  198. options.url = this.urls.link(options.tokens, options.query);
  199. // Create a composed handler for resolving promise.
  200. options.success = util.compose(
  201. util.bind(linkPromise.resolve, linkPromise),
  202. util.bind(siteModel.set, siteModel)
  203. );
  204. // Create a bound error handler for rejecting promise.
  205. options.error = util.bind(linkPromise.reject, linkPromise);
  206. // Get the currently active place ID. Do an intelligent fetch of places.
  207. // I.E, won't fetch if it finds the place already in the collection.
  208. Promise.when(
  209. this.places().fetchPlace(this.get('place_id')),
  210. util.bind(function (activePlace) {
  211. // Now that we have the active place, get the up-to-date
  212. // PlaceCollection.
  213. var placeCollection = this.places();
  214. // Get the index of the currently active place -- we want to
  215. // insert after.
  216. var currentAt = placeCollection.indexOf(activePlace);
  217. // When the siteModel comes back with an ID, select it.
  218. Promise.when(linkPromise, util.bind(function (siteModel) {
  219. // Insert new siteModel at appropriate location in `PlaceCollection`.
  220. // When it comes back, it will be decorated with a
  221. // `session_id` and a `stack_id`, making it ready for the
  222. // `link` request.
  223. siteModel = this.addPlace(siteModel, { at: 0 });
  224. placeCollection.selectPlace(siteModel.id);
  225. // Retrieve the thumbnails job that comes back from Lattice.
  226. var thumbnails_job = siteModel.get('thumbnails_job');
  227. // Fake a parse event to get thumbnail job to trigger.
  228. placeCollection.trigger('parse', placeCollection, {
  229. thumbnails_job: thumbnails_job
  230. });
  231. }, this));
  232. // Sync with server via XHR. Make sure the link request is issued as a
  233. // PUT. Our current API thinks of adding places to a stack as PUTing a
  234. // link to an existing stack, rather than POSTing a new place, if you
  235. // know what I mean.
  236. (this.sync || Backbone.sync).call(this, 'update', siteModel, options);
  237. }, this),
  238. function () {
  239. throw new errors.IdDoesNotExistError('Place was not found in stack');
  240. }
  241. );
  242. return linkPromise;
  243. },
  244. // Make a stack search request. Creates a new stack from the following:
  245. //
  246. // * `place_url`
  247. // * `place_title`
  248. // * `search_terms`
  249. // * `search_url`
  250. search: function (attrs, options) {
  251. options = options || {};
  252. var required = [
  253. 'place_url',
  254. 'place_title',
  255. // These will likely get passed in when invoking this method.
  256. 'search_terms',
  257. 'search_url'
  258. ];
  259. var promise = new Promise();
  260. // Set any new properties from attrs.
  261. if (attrs) this.set(attrs);
  262. // Filter down list of required to just those that are missing.
  263. var missing = util.reject(required, this.has, this);
  264. // Check for required attributes. Make some noise if anything
  265. // is missing.
  266. if (missing.length) throw new errors.KeysMissingError(null, missing);
  267. // Create a handler for building new stackmodel and resolving promise.
  268. options.success = util.bind(function (resp) {
  269. var model = this.process(resp, options);
  270. promise.resolve(model);
  271. }, this);
  272. // Create a bound error handler for rejecting promise.
  273. options.error = function(err){
  274. promise.reject(err, Boolean("dont throw on error"));
  275. };
  276. // URL sent to XHR should be calculated from `urls.search`.
  277. // Update `urls.search` with current search terms.
  278. options.url = this.urls.search(null, {
  279. q: this.get('search_terms') // leave the 'search' resthelper to do the url encoding of values
  280. });
  281. // Sync with our server via XHR. Make sure the search request is
  282. // issued as a POST. Our current API thinks of adding stacks as
  283. // "creating" a search under a particular term.
  284. (this.sync || Backbone.sync).call(this, 'create', this, options);
  285. return promise;
  286. },
  287. social: function (attrs, options) {
  288. options = options || {};
  289. var promisedStack = new Promise();
  290. var required = [
  291. 'service_url',
  292. 'friend_url',
  293. 'friend_name',
  294. 'place_title',
  295. 'place_url'
  296. ];
  297. // Set any new properties from attrs.
  298. if (attrs) this.set(attrs);
  299. // Filter down list of required to just those that are missing.
  300. var missing = util.reject(required, this.has, this);
  301. if (missing.length) throw new errors.KeysMissingError(null, missing);
  302. promisedStack.then(options.success, options.error);
  303. options.success = util.bind(function (resp) {
  304. var stackModel = this.process(resp, { silent: true }); // get back the stack model, but *dont* trigger events
  305. promisedStack.resolve(stackModel);
  306. }, this);
  307. options.error = util.bind(promisedStack.reject, promisedStack);
  308. // URL sent to XHR should be calculated from `urls.search`.
  309. // Update `urls.search` with current search terms.
  310. options.url = this.urls.social();
  311. // Sync with our server via XHR. Make sure the search request is
  312. // issued as a POST. Our current API thinks of adding stacks as
  313. // "creating" a search under a particular term.
  314. (this.sync || Backbone.sync).call(this, 'create', this, options);
  315. return promisedStack;
  316. },
  317. direct: function (attrs, options) {
  318. options = options || {};
  319. var promisedStack = new Promise();
  320. var required = [
  321. 'url',
  322. 'title'
  323. ];
  324. // Set any new properties from attrs.
  325. if (attrs) this.set(attrs);
  326. // Filter down list of required to just those that are missing.
  327. var missing = util.reject(required, this.has, this);
  328. if (missing.length) throw new errors.KeysMissingError(null, missing);
  329. promisedStack.then(options.success, options.error);
  330. options.success = util.bind(function (resp) {
  331. var stackModel = this.process(resp);
  332. promisedStack.resolve(stackModel);
  333. }, this);
  334. options.error = util.bind(promisedStack.reject, promisedStack);
  335. // URL sent to XHR should be calculated from `urls.search`.
  336. // Update `urls.search` with current search terms.
  337. options.url = this.urls.direct();
  338. // Sync with our server via XHR. Make sure the search request is
  339. // issued as a POST. Our current API thinks of adding stacks as
  340. // "creating" a search under a particular term.
  341. (this.sync || Backbone.sync).call(this, 'create', this, options);
  342. return promisedStack;
  343. },
  344. remove: function(options){
  345. options = options || {};
  346. var promise = new Promise();
  347. var cid = this.cid;
  348. var model = this;
  349. var collection = this.collection;
  350. promise.then(options.success || function(){}, options.error || function(){});
  351. options.success = util.bind(function (resp) {
  352. // is the model automatically removed from the collection?
  353. collection.remove([model]);
  354. promise.resolve(true);
  355. }, this);
  356. options.error = function(){
  357. var model = collection.getByCid(cid);
  358. if(model) {
  359. logger.log("Remove stack failed, " + model.get('stack_title') + " still exists in collection");
  360. }
  361. promise.reject(false);
  362. };
  363. // URL sent to XHR should be calculated from `urls.remove`.
  364. options.url = this.urls.remove({ session_id: model.get('session_id') });
  365. // Issue the POST to our server via XHR
  366. (this.sync || Backbone.sync).call(this, 'create', this, options);
  367. return promise;
  368. },
  369. // Places
  370. // -------------------------------------------------------------------------
  371. // Add a place to a `PlaceCollection`.
  372. // Decorates the place on the way in with `session_id` and `stack_id`
  373. // using `decoratePlace` method.
  374. addPlace: function (siteModel, options){
  375. options = options || {};
  376. var placesCollection = this.places();
  377. // Spin up a thumbnails job if necessary.
  378. if (options.thumbnails_job) thumbnails.parse.call(placesCollection, {
  379. thumbnails_job: options.thumbnails_job
  380. });
  381. // Add the siteModel to the array of models.
  382. placesCollection.add(siteModel, options);
  383. // Return the modified siteModel.
  384. return siteModel;
  385. },
  386. fetchPlaces: function(){
  387. var stackModel = this;
  388. var promisedFetch = this.places().fetch({
  389. diff: true
  390. });
  391. var setLoaded = function () {
  392. stackModel.loaded = true;
  393. };
  394. promisedFetch.then(setLoaded, setLoaded);
  395. return promisedFetch;
  396. },
  397. // Fetch an individual place by id. Will refetch places from server
  398. // if ID is not found in current collection. Returns a Promise or value.
  399. //
  400. // TODO: this method is deprecated -- prefer
  401. // `PlaceCollection.prototype.fetchPlace`. Keeping it here for older code
  402. // paths.
  403. fetchPlace: function (place_id) {
  404. return this.places().fetchPlace(place_id);
  405. },
  406. // Create a PlacesCollection if we don't have one already, memoize it.
  407. places: BaseModel.subcollection('places', function () {
  408. return new PlaceCollection(undefined, { stack: this });
  409. }),
  410. // A special-case `PlaceCollection` that contains places which are matched
  411. // in search results and that sort of thing. Expect the places in this
  412. // collection to be frequently changing and frequently empty.
  413. //
  414. // Matches currently do not come back from any single stack API. They only
  415. // come back from stack collection APIs and are manually added to the stack
  416. // instance.
  417. matches: BaseModel.subcollection('matches', function () {
  418. return new PlaceCollection([], { stack: this });
  419. }),
  420. // Processing methods
  421. // -------------------------------------------------------------------------
  422. // This function is intended to consume *unfiltered* results from the API.
  423. processPlace: function (resp) {
  424. // TODO: if we ever get back > 1 place, we should turn this into a map
  425. // operation.
  426. var place = resp.place;
  427. configPlace.call(this, {
  428. place_id: place.id,
  429. place_title: place.title,
  430. place_url: place.url,
  431. thumbnail_key: place.thumbnail_key,
  432. session_id: resp.session_id,
  433. stack_id: resp.stack.id,
  434. // Accessed time is not handed back from the server for stack searches.
  435. // Fake it with a date object so that we get proper sorting in the
  436. // drawer. This should be roughly accurate, since the server-side hands
  437. // back a Unix Timestamp for accessed.
  438. accessed: Date.now()
  439. });
  440. this.places().selectPlace(place.id);
  441. },
  442. processSearchPlace: function (resp) {
  443. var searchPlace = resp.search_place;
  444. var searchUrl = template(config.searchResults);
  445. configPlace.call(this, {
  446. place_id: searchPlace.id,
  447. place_title: 'Search: ' + resp.stack.title,
  448. place_url: searchUrl({ query: resp.stack.title, provider: 'bing' }),
  449. session_id: resp.session_id,
  450. stack_id: resp.stack.id,
  451. thumbnail_key: searchPlace.thumbnail_key,
  452. accessed: Date.now()
  453. });
  454. },
  455. // Handles creating a place from `resp.profile_place`.
  456. processProfilePlace: function (resp) {
  457. var place = resp.profile_place;
  458. configPlace.call(this, {
  459. place_id: place.id,
  460. place_title: resp.stack.title,
  461. // Get the place_url from the `friend_url` on this `StackModel`.
  462. // `friend_url` is a required field for issuing a `social` request,
  463. // so it should be safe to assume it's available on the instance.
  464. //
  465. // NOTE: this will not work in the `views` iframe-based app, because
  466. // of iframe access blocks on <http://twitter.com>
  467. // and <http://facebook.com>.
  468. place_url: this.get('friend_url'),
  469. session_id: resp.session_id,
  470. stack_id: resp.stack.id,
  471. thumbnail_key: place.thumbnail_key,
  472. accessed: Date.now()
  473. });
  474. },
  475. // PSA: **AVOID `this`** in `StackModel.parse`. If we use `this`, we can not
  476. // map over `StackModel.parse` in `StackCollection.parse`.
  477. parse: function (resp) {
  478. var stack = resp.stack,
  479. place = resp.place,
  480. places = resp.places,
  481. page_count,
  482. model = {};
  483. if(stack) {
  484. util.extend(model, {
  485. stack_id: stack.id,
  486. stack_title: stack.title,
  487. // Subtype is used to customize the icon shown for the stack.
  488. subtype: stack.subtype
  489. });
  490. // Include page count if it has been returned.
  491. if (stack.page_count) {
  492. model.page_count = stack.page_count;
  493. }
  494. }
  495. if(resp.session_id) {
  496. model.session_id = resp.session_id;
  497. }
  498. if (place) {
  499. var placeModel = {
  500. place_id: place.id,
  501. place_title: place.title,
  502. place_url: place.url
  503. };
  504. util.extend(model, placeModel, {
  505. places: [
  506. // If this item was included, it means it is the active place for
  507. // the stack -- so it only makes sense to highlight it.
  508. util.extend({ selected: true }, placeModel)
  509. ]
  510. });
  511. }
  512. return model;
  513. }
  514. });
  515. return StackModel;
  516. });