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

/krum/webui/static/js/matching.js

https://bitbucket.org/satook/krum_server
JavaScript | 425 lines | 346 code | 32 blank | 47 comment | 42 complexity | 72aa89aa204081ba29d1a2e5d301e4bc MD5 | raw file
  1. function cmpAsNum(a,b) {
  2. if (a === b) {
  3. return 0;
  4. }
  5. var l = parseInt(a, 10);
  6. var r = parseInt(b, 10);
  7. l = l === NaN ? a : l;
  8. r = r === NaN ? b : r;
  9. return l < r ? -1 : 1;
  10. }
  11. function cmpEps(a,b) {
  12. return cmpAsNum(a.episode, b.episode);
  13. }
  14. // The content model
  15. var Content = Backbone.Model.extend({
  16. urlRoot: "/api/1/content",
  17. defaults: function() {
  18. return {
  19. id: null,
  20. original_path: "Unknown",
  21. metadata_id: null,
  22. original_hash: null,
  23. };
  24. },
  25. initialize: function() {
  26. if (!this.get("original_path")) {
  27. this.set({"original_path": this.defaults.original_path});
  28. }
  29. },
  30. clear: function() {
  31. this.destroy();
  32. },
  33. });
  34. var ContentList = Backbone.Collection.extend({
  35. model: Content,
  36. url: "/api/1/content",
  37. });
  38. unmatchedContent = new ContentList();
  39. $(function() {
  40. // The select item template
  41. var ContentSelectRowView = Backbone.View.extend({
  42. tagName: 'tr',
  43. template: Handlebars.compile($('#tmpl_selectitem').html()),
  44. events: {
  45. },
  46. initialize: function() {
  47. this.$el.attr('data-id', this.model.id)
  48. },
  49. render: function() {
  50. this.$el.html(this.template(this.model.attributes));
  51. return this;
  52. },
  53. });
  54. var MetaSearch = Backbone.View.extend({
  55. tagName: 'input',
  56. events: {
  57. },
  58. initialize: function() {
  59. this.mediaType = this.options.mediaType || 1; // defaults to movies
  60. // TODO: allow for other options of the autocomplete search (limits/in_library/etc)
  61. var self = this;
  62. this.$el.autocomplete({
  63. minLength: 3,
  64. source: function(req, rsp) {
  65. req.media_type = self.mediaType
  66. $.getJSON('/api/1/autocomplete?limit=10', req, function(d,s,x) {
  67. var results = [];
  68. try {
  69. $.each(d, function(k, v) {
  70. $.each(v, function(i, item) {
  71. item.label = item.name + ' ' + item.year
  72. });
  73. v.unshift(v.length);
  74. v.unshift(-1);
  75. results.splice.apply(results, v);
  76. });
  77. }
  78. catch (err) {
  79. }
  80. rsp(results);
  81. });
  82. },
  83. select: function(e, ui) {
  84. self.trigger('select', ui.item);
  85. self.$el.val(ui.item.value);
  86. return false;
  87. },
  88. focus: function(e, ui) {
  89. self.$el.val(ui.item.value);
  90. return false;
  91. },
  92. }).change(function(e) {
  93. console.log('Change! ' + self.$el.val());
  94. });
  95. },
  96. });
  97. // This works with a set of selected rows within a table to pick the series, season and episode of each
  98. var SeriesPicker = Backbone.View.extend({
  99. events: {
  100. },
  101. initialize: function() {
  102. this.views = this.options.views;
  103. this.episodes = [];
  104. // make up all the input elements, disabled to start
  105. // build series picker
  106. this.showPicker = new MetaSearch({mediaType: 2});
  107. this.showPicker.$el.appendTo(this.views[0].$el.find('.meta-data-1'));
  108. this.showPicker.bind('select', this.showPicked, this);
  109. // build season picker
  110. this.seasonPicker = new Autocomplete({minLength: 0, shortCutProp: 'label'});
  111. this.seasonPicker.$el.appendTo(this.views[0].$el.find('.meta-data-2'));
  112. this.seasonPicker.bind('select', this.seasonPicked, this);
  113. // build episode pickers
  114. this.epPickers = [];
  115. _.each(this.views, function(view) {
  116. var picker = new Autocomplete({model: view.model, matchOnStart: true, shortCutProp: 'ep_num'});
  117. this.epPickers.push(picker);
  118. picker.$el.appendTo(view.$el.find('.meta-data-3'));
  119. picker.bind('select', this.episodePicked, this);
  120. }, this);
  121. if (this.options.autofocus) {
  122. this.showPicker.$el.focus();
  123. }
  124. },
  125. showPicked: function(item) {
  126. var metaID = item.id;
  127. // go fetch episode listing and such
  128. var self = this;
  129. $.getJSON(formatStr('/api/1/series/{}/episodes', metaID), function(episodes, status, xhr) {
  130. // get a list of Seasons
  131. var seasons = _.uniq(_.map(episodes, function(ep) {return ep.season;}));
  132. // add in nice labels
  133. _.each(episodes, function(ep, i) {
  134. ep.label = formatStr('{} - {}', ep.ep_num, ep.ep_name)
  135. });
  136. // now set up the picker
  137. self.episodes = episodes;
  138. self.seasonPicker
  139. .setSource(seasons)
  140. .open();
  141. });
  142. },
  143. seasonPicked: function(item) {
  144. // filter down the episode list
  145. var eps = _.filter(this.episodes, function(ep) {return ep.season==item.value;});
  146. // set up the episode pickers
  147. _.each(this.epPickers, function(ep) {
  148. ep.setSource(eps);
  149. });
  150. },
  151. episodePicked: function(item, picker) {
  152. // commit and mark "done", can still be edited
  153. // TODO: This should be modeled as adding this content to the episodes content list, not fiddling with a foreign key reference.
  154. var metaID = item.id;
  155. picker.model.save({metadata_id: metaID});
  156. },
  157. cancel: function() {
  158. // walk back through the edit stages
  159. // if we're already at the start
  160. this.trigger('cancel');
  161. },
  162. });
  163. // A Backbone view style wrapper around jQuery UI's autocomplete widget
  164. var Autocomplete = Backbone.View.extend({
  165. // OPTIONS:
  166. // matchOnStart: true|false.
  167. // If present and true, the autocomplete list will be filtered
  168. // down to those items whose label text starts with the term,
  169. // rather than those which contain the term.
  170. // shortCutProp: propName.
  171. // If present, this will allow a user to avoid having to "pick"
  172. // an item from the list by using the text
  173. // TODO: if a user types an item in exactly, make it so it's as if they chose that option
  174. tagName: 'input',
  175. events: {
  176. },
  177. initialize: function() {
  178. var self = this;
  179. this.$el.autocomplete({
  180. minLength: (this.options.minLength == undefined ? 1 : this.options.minLength),
  181. delay: this.options.delay || 50,
  182. select: function(e, ui) {
  183. self.trigger('select', ui.item, self);
  184. },
  185. });
  186. this.setSource(this.options.source);
  187. if (this.options.matchOnStart) {
  188. this.$el.bind('autocompleteresponse', function(event, ui) {
  189. // this is complicated because I need to manipulate in-place
  190. var val = $(this).val();
  191. // get things to reject
  192. var rejects = _.map(ui.content, function(c, i) {
  193. if (c.value.slice(0, val.length) != val) {
  194. return i;
  195. }
  196. });
  197. // now remove in reverse order so indices aren't broken by removals
  198. rejects.reverse();
  199. _.each(rejects, function(r) {
  200. if (r != undefined) {
  201. ui.content.splice(r, 1);
  202. }
  203. });
  204. });
  205. }
  206. if (this.options.shortCutProp) {
  207. // save the last set of options
  208. this.$el.bind('autocompleteresponse', function(event, ui) {
  209. self.lastOptions = ui.content;
  210. });
  211. // shortcut if we can
  212. var prop = this.options.shortCutProp;
  213. this.$el.bind('autocompletechange', function(event, ui) {
  214. if (ui.item != null) { // they picked from the list, so leave it alone
  215. return;
  216. }
  217. var val = self.$el.val();
  218. var newitem = _.find(self.lastOptions, function(item) {
  219. return item[prop] == val;
  220. });
  221. if (newitem != null) {
  222. self.$el.val(newitem.value);
  223. self.trigger('select', newitem, self);
  224. }
  225. });
  226. }
  227. },
  228. setEnabled: function(enabled) {
  229. if (enabled) {
  230. this.$el.removeAttr('disabled');
  231. }
  232. else {
  233. this.$el.attr('disabled', true);
  234. }
  235. return this;
  236. },
  237. setSource: function(source) {
  238. this.options.source = source;
  239. this.$el.autocomplete('option', 'source', source);
  240. return this;
  241. },
  242. // Clear the value that we're storing.
  243. clear: function() {
  244. this.$el.val('');
  245. return this;
  246. },
  247. focus: function() {
  248. this.$el.focus();
  249. return this;
  250. },
  251. open: function() {
  252. this.$el.autocomplete('search', '');
  253. return this;
  254. },
  255. });
  256. // draw the entire matching app area
  257. var MatcherView = Backbone.View.extend({
  258. tagName: 'table',
  259. events: {
  260. },
  261. initialize: function() {
  262. unmatchedContent.bind('add', this.addOne, this);
  263. unmatchedContent.bind('remove', this.removeOne, this);
  264. unmatchedContent.bind('reset', this.addAll, this);
  265. this.activePicker = null; // this is the meta-data "picker" view
  266. this.rows = $('<tbody>').appendTo(this.$el);
  267. this.$el.selecttable();
  268. this.viewMap = {};
  269. $('body').keydown(_.bind(this.keyPress, this));
  270. // get ourselves some data
  271. $.getJSON('/api/1/content?hasmetadata=false', function(data, status, xhr) {
  272. unmatchedContent.reset(data);
  273. });
  274. },
  275. addOne: function(unmatched) {
  276. var view = new ContentSelectRowView({model: unmatched});
  277. this.rows.append(view.render().el);
  278. this.viewMap[unmatched.id] = view;
  279. },
  280. removeOne: function(unmatched) {
  281. var view = this.viewMap[unmatched.id];
  282. if (view != undefined) {
  283. view.$el.remove();
  284. this.viewMap[unmatched.id] = undefined;
  285. }
  286. },
  287. addAll: function() {
  288. this.viewMap = {};
  289. unmatchedContent.each(this.addOne, this);
  290. },
  291. keyPress: function(e) {
  292. // TODO: get keybinding solution...
  293. var key = String.fromCharCode(e.which).toLowerCase();
  294. // TODO: can use $.ui.keyCode for special keys
  295. // var keyCode = $.ui.keyCode;
  296. // switch( event.keyCode ) {
  297. if (key == 'm') {
  298. return this.startPickMovie();
  299. }
  300. else if (key == 's') {
  301. return this.startPickShow();
  302. }
  303. else if (e.which == 46) {
  304. return this.deleteContent();
  305. }
  306. else if (e.which == 27) { // escape
  307. return this.cancelEditing();
  308. }
  309. },
  310. deleteContent: function(e) {
  311. var models = this.selectedModels();
  312. _.each(models, function(m) {
  313. m.destroy({wait: true});
  314. });
  315. },
  316. startPickMovie: function() {
  317. var rows = this.$el.selecttable('getRows');
  318. if (this.editStage != 0 || rows.length < 1) {
  319. return true;
  320. }
  321. // go into edit mode
  322. this.editStage = 1;
  323. this.$el.selecttable('lockRows');
  324. // create movie picker in first row
  325. var picker = new MetaSearch({mediaType: 1});
  326. picker.$el.appendTo(rows.first().find('.meta-data-1'));
  327. picker.$el.focus();
  328. // set up "on-pick" handler
  329. var self = this;
  330. picker.bind('select', function(item) {
  331. var metaID = item.id;
  332. var models = this.selectedModels();
  333. $.each(models, function(i, m) {
  334. m.save({metadata_id: metaID});
  335. });
  336. // clean up
  337. self.editStage = 0;
  338. self.$el.selecttable('unlockRows');
  339. self.$el.selecttable('clearRows');
  340. picker.remove();
  341. picker.unbind();
  342. }, this);
  343. return false;
  344. },
  345. selectedModels: function() {
  346. var rows = this.$el.selecttable('getRows');
  347. if (rows.length < 1) {
  348. return [];
  349. }
  350. var ids = {}; // hold a quick search cache of ids
  351. $.each(rows, function(i,e) {
  352. ids[e.getAttribute('data-id')] = true;
  353. });
  354. return unmatchedContent.filter(function(m) {return m.id in ids;});
  355. },
  356. selectedViews: function() {
  357. var rows = this.$el.selecttable('getRows');
  358. if (rows.length < 1) {
  359. return [];
  360. }
  361. return _.map(rows, function(row) {
  362. return this.viewMap[row.getAttribute('data-id')];
  363. }, this);
  364. },
  365. startPickShow: function() {
  366. // TODO: make generic and use for both movies and series, factory for picker...
  367. var rows = this.$el.selecttable('getRows');
  368. if (this.activePicker != null || rows.length < 1) {
  369. return true;
  370. }
  371. // go into edit mode
  372. this.$el.selecttable('lockRows');
  373. this.activePicker = new SeriesPicker({
  374. el: this.el,
  375. views: this.selectedViews(),
  376. autofocus: true,
  377. });
  378. this.activePicker.bind('cancel', function() {
  379. this.$el.selecttable('unlockRows');
  380. });
  381. return false;
  382. },
  383. cancelEditing: function() {
  384. if (this.activePicker != null) {
  385. this.activePicker.cancel();
  386. return false;
  387. }
  388. },
  389. });
  390. // create the new app
  391. var matcher = new MatcherView;
  392. $('#matcher').append(matcher.el)
  393. });