PageRenderTime 47ms CodeModel.GetById 15ms RepoModel.GetById 0ms app.codeStats 0ms

/main.js

https://github.com/msoftware/ttrss-mobile
JavaScript | 1640 lines | 1058 code | 350 blank | 232 comment | 193 complexity | 4a9d0c5384135b753da5156fb02e8d40 MD5 | raw file
  1. /***************** Models *************/
  2. function defineModels(){
  3. /************ categories ***********/
  4. // default model to store a category
  5. var CategoryModel = Backbone.Model.extend();
  6. // model for a collection of categories
  7. var CategoriesModel = Backbone.Collection.extend({
  8. comparator: "title",
  9. model: CategoryModel,
  10. sync: function(method, collection, options){
  11. if (method == "read"){
  12. // only action for a category: read
  13. var request = {
  14. op: "getCategories",
  15. enable_nested: "true"
  16. };
  17. ttRssApiCall(request, function(res){
  18. collection.reset(res, {merge: true});
  19. }, true);
  20. } else {
  21. console.log("CategoriesModel.sync method called for an " +
  22. "unsupported method:" + method);
  23. }
  24. }
  25. });
  26. // keep a global collection
  27. window.categoriesModel = new CategoriesModel();
  28. /************* feeds ***************/
  29. // default model to store a feed
  30. var FeedModel = Backbone.Model.extend();
  31. // model for a collection of feeds from a category
  32. var FeedsModel = Backbone.Collection.extend({
  33. catId: null, // nothing by default
  34. comparator: "title",
  35. model: FeedModel,
  36. // to get the current feed ID from the fragment
  37. getCurrentCatId: function(){
  38. var f = Backbone.history.fragment;
  39. var re = /^cat(-?\d+)(\/.*)?$/;
  40. var id = f.replace(re, "$1");
  41. return parseInt(id);
  42. },
  43. sync: function(method, collection, options){
  44. // only action for a category: read
  45. if (method == "read"){
  46. var request = {
  47. op: "getFeeds",
  48. cat_id: -4/* collection.getCurrentCatId() */
  49. };
  50. ttRssApiCall(
  51. request,
  52. function(res){
  53. // reset collection with updated data
  54. collection.catId = collection.getCurrentCatId();
  55. collection.reset(res, {merge: true});
  56. }, true);
  57. } else {
  58. console.log("FeedsModel.sync called for an unsupported method: " + method);
  59. }
  60. }, // sync
  61. });
  62. // keep a global collection
  63. window.feedsModel = new FeedsModel();
  64. /************ 1 article ***************/
  65. // model to store an article
  66. window.ArticleModel = Backbone.Model.extend({
  67. sync: function(method, model, options){
  68. if (method == "read"){
  69. ttRssApiCall(
  70. { op: 'getArticle',
  71. article_id: model.id },
  72. function(m){
  73. if (m.length == 0){
  74. console.log("ArticleModel.sync: recived nothing for article " +
  75. model.id);
  76. model.set("title", "Error");
  77. model.set("content",
  78. "The article with ID " + model.id + " could no be retrieved.");
  79. } else {
  80. model.set(m[0]);
  81. }
  82. }, true);
  83. } else if (method == "update"){
  84. // save attributes that changed
  85. _.each(_.keys(this.changed), function(att){
  86. this.toggle(att);
  87. }, this);
  88. } else {
  89. console.log("ArticleModel.sync called on an unsupported method: " + method);
  90. }
  91. },
  92. toggle: function(what){
  93. var field;
  94. if (what == "marked"){
  95. field = 0; // star
  96. } else if (what == "published"){
  97. field = 1;
  98. } else if (what == "unread"){
  99. field = 2;
  100. }
  101. /* 0 -> set to true
  102. 1 -> set to false */
  103. var m = ((this.get(what)) == true ? 1 : 0 );
  104. if (field != null){
  105. ttRssApiCall(
  106. { op: 'updateArticle',
  107. article_ids: this.id,
  108. mode: m,
  109. field: field },
  110. function(m){ jQuery.noop(); } , true);
  111. } else {
  112. console.log("ArticleModel.toggle called with an " +
  113. "unexpected parameter : " + what);
  114. }
  115. } // toggle
  116. });
  117. /*********** articles *************/
  118. // model for a collection of articles
  119. var ArticlesModel = Backbone.Collection.extend({
  120. comparator: "updated",
  121. model: window.ArticleModel,
  122. // data from this feed ID is inside
  123. feedId: null,
  124. // to get the current feed ID from the fragment
  125. getCurrentFeedId: function(){
  126. var f = Backbone.history.fragment;
  127. var re = /^cat-?\d+\/feed(-?\d+)(\/.*)?$/;
  128. var id = f.replace(re, "$1");
  129. return parseInt(id);
  130. },
  131. sync: function(method, collection, options) {
  132. if (method == "read"){
  133. var feedId = collection.getCurrentFeedId();
  134. // we need to fetch the articles list for this feed
  135. var msg = {
  136. op: "getHeadlines",
  137. show_excerpt: false,
  138. view_mode: "adaptive",
  139. show_content: true,
  140. limit: 10
  141. };
  142. if (feedId == -9){
  143. // special case (all articles from a whole category)
  144. msg.feed_id = window.feedsModel.getCurrentCatId();
  145. msg.is_cat = true;
  146. } else {
  147. // normal case
  148. msg.feed_id = feedId;
  149. }
  150. ttRssApiCall(
  151. msg, function(res){
  152. collection.feedId = collection.getCurrentFeedId();
  153. collection.reset(res, {merge: true});
  154. }, true);
  155. } else {
  156. console.log("ArticlesModel.sync called for an unsupported method: " + method);
  157. }
  158. }, // sync()
  159. toggleUnread: function(){
  160. var articles = "";
  161. // do we need to mark all as read or unread?
  162. if (this.where({unread: true}).length > 0){
  163. this.where({unread: true}).forEach(
  164. function(m){
  165. m.set({unread: false})
  166. articles += m.id + ",";
  167. });
  168. } else {
  169. this.where({unread: false}).forEach(
  170. function(m){
  171. m.set({unread: true})
  172. articles += m.id + ",";
  173. });
  174. }
  175. //remove last comma
  176. articles = articles.substr(0, articles.length - 1);
  177. // we send an update event to notify the view
  178. this.trigger("update");
  179. // API call to mark as read
  180. ttRssApiCall(
  181. { op: 'updateArticle',
  182. article_ids: articles,
  183. mode: 2,
  184. field: 2 },
  185. function(m){ jQuery.noop(); } , true);
  186. }
  187. });
  188. // keep one global collection
  189. window.articlesModel = new ArticlesModel();
  190. /*********** config ********/
  191. // a model to store configuration (from getConfig in the API)
  192. var ConfigModel = Backbone.Model.extend({
  193. sync: function(method, model, options){
  194. if (method == "read"){
  195. ttRssApiCall(
  196. {'op': 'getConfig'},
  197. function(m){
  198. model.set(m);
  199. }, true);
  200. }
  201. }
  202. });
  203. // global config model
  204. window.configModel = new ConfigModel();
  205. } //defineModels
  206. /************ BACKBONE views*************/
  207. function defineViews(){
  208. /*********** categories *************/
  209. // a view for each row of a categories list
  210. CategoryRowView = Backbone.View.extend({
  211. render: function(){
  212. var html = listElementTpl({
  213. href: '#cat' + this.model.id,
  214. title: this.model.get('title'),
  215. count: this.model.get('unread') });
  216. this.el.innerHTML = html;
  217. return this;
  218. },
  219. initialize: function() {
  220. this.el = document.createElement('li');
  221. this.listenTo(this.model, "change", this.render);
  222. },
  223. tagName: 'li'
  224. });
  225. // a view for page with all the categories
  226. CategoriesPageView = Backbone.View.extend({
  227. render: function(){
  228. if (this.collection.size() == 0){
  229. this.$lv.html(roListElementTpl({text: "Loading..."}));
  230. } else {
  231. // clean up the list
  232. this.$lv.empty();
  233. // special categories
  234. var special = [];
  235. /* categories with unread */
  236. var unread = [];
  237. /* other categories */
  238. var other = [];
  239. this.collection.forEach(function(cat){
  240. var row = new CategoryRowView({model:cat})
  241. var li = row.render();
  242. if (cat.id < 0){
  243. special.push(li.el);
  244. } else if (cat.get("unread") > 0){
  245. unread.push(li.el);
  246. } else {
  247. other.push(li.el);
  248. }
  249. }, this);
  250. if (special.length != 0){
  251. // we have special cat
  252. this.$lv.append(listSeparatorTpl({ text: 'Special' }));
  253. _.each(special, function(s){
  254. this.$lv.append(s);
  255. }, this);
  256. }
  257. if (unread.length != 0){
  258. // we have other categories
  259. this.$lv.append(listSeparatorTpl({ text: 'With unread articles' }));
  260. _.each(unread, function(u){
  261. this.$lv.append(u);
  262. }, this);
  263. }
  264. if (other.length != 0){
  265. // we have other categories
  266. this.$lv.append(listSeparatorTpl({ text: 'Categories' }));
  267. _.each(other, function(o){
  268. this.$lv.append(o);
  269. }, this);
  270. }
  271. }
  272. this.$lv.listview("refresh");
  273. return this;
  274. },
  275. initialize: function() {
  276. this.listenTo(this.collection, "reset", this.render);
  277. // refresh button for categories
  278. this.$('a.refreshButton').on('click', this, function(e){
  279. e.data.collection.fetch();
  280. $('#catPopupMenu').popup('close');
  281. e.preventDefault();
  282. });
  283. // store in the object a reference on the listview
  284. this.$lv = this.$("div:jqmData(role='content') " +
  285. "ul:jqmData(role='listview')");
  286. } // initialize
  287. });
  288. // tie the view to the listview of the categories page
  289. window.categoriesPageView = new CategoriesPageView({
  290. el: $("#categories"),
  291. collection: window.categoriesModel
  292. });
  293. /************ Feeds **************/
  294. // a view for each row of a feeds list
  295. FeedRowView = Backbone.View.extend({
  296. render: function(event){
  297. var html;
  298. // get the icons directory from the conf
  299. var iconsDir = window.configModel.get("icons_dir");
  300. if ((iconsDir == undefined) && (this.model.get("has_icon"))){
  301. // request to be notifed when icons path will be ready
  302. // asked by the page view
  303. window.configModel.once("change:icons_dir", this.render, this);
  304. }
  305. // the link src
  306. var link = "#" + Backbone.history.fragment + "/feed" + this.model.id;
  307. if ((iconsDir == undefined) || (! this.model.get("has_icon"))){
  308. // we can't display with icons or dot not need them
  309. html = listElementTpl({
  310. href: link,
  311. title: this.model.get('title'),
  312. count: this.model.get('unread')
  313. });
  314. } else {
  315. // we add an icon
  316. var iconSrc = window.apiPath + iconsDir + "/" + this.model.id + ".ico";
  317. html = listElementWithIconTpl({
  318. href: link,
  319. title: this.model.get('title'),
  320. count: this.model.get('unread'),
  321. src: iconSrc
  322. });
  323. }
  324. this.el.innerHTML = html;
  325. // refresh the listview
  326. var $lv = window.feedsPageView.$("div:jqmData(role='content') " +
  327. "ul:jqmData(role='listview')");
  328. $lv.listview("refresh");
  329. return this;
  330. },
  331. initialize: function() {
  332. this.el = document.createElement('li');
  333. this.listenTo(this.model, "change", this.render);
  334. },
  335. tagName: 'li'
  336. });
  337. // a view for the page of the list of feeds of a category
  338. var FeedsPageView = Backbone.View.extend({
  339. // callback to render the title in the header
  340. renderTitle: function(){
  341. // placeholder for the title of the category
  342. var $h1Tag = this.$("div:jqmData(role='header') h1");
  343. // catId on the fragment
  344. var catId = this.collection.getCurrentCatId();
  345. // cat model
  346. var catModel = window.categoriesModel.get(catId);
  347. if (catModel != undefined){
  348. // title is available now
  349. $h1Tag.html(catModel.get("title"));
  350. } else {
  351. // default title
  352. $h1Tag.html("Feeds");
  353. }
  354. }, // renderTitle
  355. // callback to render the list of feeds of a category
  356. renderList: function(){
  357. // data for the listview
  358. var lvData = "";
  359. // real category ID
  360. var id = window.feedsModel.getCurrentCatId();
  361. if (this.collection.catId != id){
  362. // it's loading right now, we don't have any cached data
  363. lvData = roListElementTpl({text: "Loading..."});
  364. } else {
  365. // we have data from the good collection, updated or not
  366. // an array of the models for this category
  367. var feeds = this.collection.where({cat_id: id})
  368. if (feeds.length == 0){
  369. // no elements in the collection
  370. if (id == -2){
  371. lvData = roListElementTpl({text: "No labels"});
  372. } else {
  373. lvData = roListElementTpl({text: "No feeds"});
  374. }
  375. } else {
  376. // we can add list elements
  377. var unreadCount = 0;
  378. // feeds with unread
  379. var unread = "";
  380. feeds.forEach(function(feed){
  381. var count = feed.get("unread");
  382. if (count > 0){
  383. var row = new FeedRowView({model:feed})
  384. var li = row.render();
  385. unread += li.el.outerHTML;
  386. unreadCount += count;
  387. }
  388. }, this);
  389. // other feeds
  390. var other = "";
  391. feeds.forEach(function(feed){
  392. if (feed.get("unread") <= 0){
  393. var row = new FeedRowView({model: feed})
  394. var li = row.render();
  395. other += li.el.outerHTML;
  396. }
  397. }, this);
  398. // the all feeds link (-9 is its special ID)
  399. var all = "";
  400. if ((id <= -10) || (id > 0)){
  401. // only when on real categories or labels
  402. all += "<li>" + listElementTpl({
  403. href: "#" + Backbone.history.fragment + "/feed-9",
  404. title: "All",
  405. count: unreadCount
  406. }) + "</li>";
  407. }
  408. // unread separator
  409. if ((unread != "") && ((other != "")||(all != ""))){
  410. unread = listSeparatorTpl({ text: 'With unread' }) + unread;
  411. }
  412. // other separator
  413. if ((other != "") && ((unread != "") || (all != ""))){
  414. other = listSeparatorTpl({ text: 'Already read' }) + other;
  415. }
  416. // we add everything to the view
  417. lvData = all + unread + other;
  418. }
  419. }
  420. this.$lv.html(lvData);
  421. this.$lv.listview('refresh');
  422. return this;
  423. }, // renderList
  424. render: function(){
  425. this.renderTitle();
  426. this.renderList();
  427. // get the icons directory from the conf
  428. if (! window.configModel.has("icons_dir")){
  429. window.configModel.fetch();
  430. }
  431. if (window.categoriesModel.length == 0){
  432. // request the categories and ask to be notified once
  433. window.categoriesModel.once("reset", this.renderTitle, this);
  434. window.categoriesModel.fetch();
  435. }
  436. return this;
  437. }, // render
  438. initialize: function(){
  439. // re-render the list when
  440. this.listenTo(this.collection, "reset", this.renderList);
  441. // register refresh button for feeds
  442. this.$("a.refreshButton").on(
  443. // this is on from jQuery
  444. "click",
  445. this,
  446. function(e){
  447. e.data.collection.fetch();
  448. $('#feedsMenuPopup').popup('close');
  449. e.preventDefault();
  450. }
  451. );
  452. // listview div
  453. this.$lv = this.$("div:jqmData(role='content') " +
  454. "ul:jqmData(role='listview')");
  455. } // initialize
  456. }); //FeedsPageView
  457. // global object for the view
  458. window.feedsPageView = new FeedsPageView({
  459. el: $("#feeds"),
  460. collection: window.feedsModel
  461. });
  462. /*************** Articles *************/
  463. // a view for each row (article) of a feeds list
  464. var ArticleRowView = Backbone.View.extend({
  465. render: function(event){
  466. var link = "#" + Backbone.history.fragment +
  467. "/art" + this.model.id;
  468. var dateStr = updateTimeToString(this.model.get("updated"));
  469. var html;
  470. var catId = window.feedsModel.getCurrentCatId();
  471. var feedId = window.articlesModel.getCurrentFeedId();
  472. var feedModel = window.feedsModel.get(this.model.get("feed_id"));
  473. if (this.model.get("unread")){
  474. if (((catId >= 0) && (feedId != -9)) || (feedModel == undefined)){
  475. // normal cat, we know the feed name
  476. html = articleLiElementTpl({
  477. href: link,
  478. date: dateStr,
  479. title: this.model.get('title') });
  480. } else {
  481. // special cat, we show the feed name
  482. html = articleFeedLiElementTpl({
  483. href: link,
  484. date: dateStr,
  485. title: this.model.get('title'),
  486. feed: feedModel.get("title") });
  487. }
  488. } else {
  489. //read article
  490. if (((catId >= 0) && (feedId != -9)) || (feedModel == undefined)){
  491. // normal cat, we know the feed name
  492. html = articleReadLiElementTpl({
  493. href: link,
  494. date: dateStr,
  495. title: this.model.get('title') });
  496. } else {
  497. // special cat, we show the feed name
  498. html = articleReadFeedLiElementTpl({
  499. href: link,
  500. date: dateStr,
  501. title: this.model.get('title'),
  502. feed: feedModel.get("title") });
  503. }
  504. }
  505. this.el.innerHTML = html;
  506. return this;
  507. }, // render
  508. initialize: function() {
  509. this.el = document.createElement('li');
  510. },
  511. tagName: 'li'
  512. });
  513. // a view for the page with the list of articles of a feed
  514. var ArticlesPageView = Backbone.View.extend({
  515. // callback to update the href of the back button
  516. renderBackButton: function(){
  517. // back button href
  518. var href = Backbone.history.fragment;
  519. href = "#" + href.substr(0, href.lastIndexOf("/"));
  520. this.$("div:jqmData(role='header') a:first").attr("href", href);
  521. },
  522. // callback to render the title in the header
  523. renderTitle: function(){
  524. // catId on the fragment
  525. var feedId = window.articlesModel.getCurrentFeedId();
  526. // placeholder for the title of the category
  527. var $h1Tag = this.$("div:jqmData(role='header') h1");
  528. // feed model
  529. var feedModel = window.feedsModel.get(feedId);
  530. if (feedModel == undefined){
  531. // default title
  532. $h1Tag.html("Articles");
  533. } else {
  534. // title is available now
  535. $h1Tag.html(feedModel.get("title"));
  536. }
  537. }, // renderTitle
  538. // callback to render the listview of articles of a feed
  539. renderList: function(){
  540. // data to add to the listview
  541. var lData = "";
  542. // real feed ID
  543. var id = window.articlesModel.getCurrentFeedId();
  544. if (this.collection.feedId != id){
  545. // it's loading right now, we don't have any cached data
  546. lData += roListElementTpl({text: "Loading..."});
  547. // waiting to be notified a second time
  548. } else {
  549. // we have data from the good collection, updated or not
  550. if (this.collection.length == 0){
  551. // no elements in the collection
  552. lData += roListElementTpl({text: "No articles"});
  553. } else {
  554. // we can add list elements
  555. this.collection.forEach(function(article){
  556. var row = new ArticleRowView({model: article})
  557. var li = row.render();
  558. lData += li.el.outerHTML;
  559. }, this);
  560. // TODO check if there is more to load
  561. }
  562. }
  563. this.$lv.html(lData);
  564. this.$lv.listview('refresh');
  565. return this;
  566. }, // renderList
  567. renderMarkAllButton: function(){
  568. var but = this.$("a.toggleUnreadButton");
  569. if (this.collection.length == 0){
  570. // disable button, no articles in the list
  571. but.addClass("ui-disabled");
  572. but.html("Mark all as ?");
  573. } else {
  574. but.removeClass("ui-disabled");
  575. if (this.collection.where({unread: true}).length > 0){
  576. but.html("Mark all as read");
  577. } else {
  578. but.html("Mark all as unread");
  579. }
  580. }
  581. },
  582. // callback to render the title in the header
  583. render: function(){
  584. this.renderBackButton();
  585. this.renderTitle();
  586. this.renderList();
  587. this.renderMarkAllButton();
  588. /* if the feed model isn't available, we need to
  589. fetch it and update the title when it will be
  590. ready */
  591. var feedModel = window.feedsModel.get(
  592. window.articlesModel.getCurrentFeedId());
  593. if (feedModel == undefined){
  594. window.feedsModel.once("reset", this.renderTitle, this);
  595. window.feedsModel.fetch();
  596. }
  597. return this;
  598. },
  599. initialize: function(){
  600. // render the list when elements are added or removed
  601. this.listenTo(this.collection, "reset", this.renderList);
  602. this.listenTo(this.collection, "update", this.renderList);
  603. // register refresh button clicks
  604. this.$('a.refreshButton').on(
  605. 'click',
  606. this,
  607. function(e){
  608. e.data.collection.fetch();
  609. $("#artMenuPopup").popup('close');
  610. e.preventDefault();
  611. }
  612. );
  613. // register mark all as read button
  614. this.$('a.toggleUnreadButton').on(
  615. 'click',
  616. this,
  617. function(e){
  618. e.data.collection.toggleUnread();
  619. $("#artMenuPopup").popup('close');
  620. e.preventDefault();
  621. }
  622. );
  623. // render the mark all button every time the collection
  624. // change
  625. this.listenTo(this.collection, "reset", this.renderMarkAllButton);
  626. this.listenTo(this.collection, "change", this.renderMarkAllButton);
  627. this.listenTo(this.collection, "update", this.renderMarkAllButton);
  628. // listview div
  629. this.$lv = this.$("div:jqmData(role='content') " +
  630. "ul:jqmData(role='listview')");
  631. } // initialize
  632. }); //ArticlesPageView
  633. window.articlesPageView = new ArticlesPageView({
  634. el: $("#articles"),
  635. collection: window.articlesModel
  636. });
  637. /************** 1 ARTICLE view, reading **************/
  638. var ArticlePageView = Backbone.View.extend({
  639. // callback to render the title in the head
  640. renderTitle: function(){
  641. if (this.model.has("title")){
  642. // title is available now
  643. var $h1Tag = this.$("div:jqmData(role='header') h1");
  644. $h1Tag.html(this.model.get("title"));
  645. } else {
  646. // title will be fetch and we'll be notified by the model
  647. this.model.once("change:title", this.renderTitle, this);
  648. }
  649. }, // renderTitle
  650. render: function(){
  651. // back button
  652. this.renderBackButton();
  653. // Title in the header update
  654. this.renderTitle();
  655. // header with link, update info & feed
  656. this.listenTo(this.model, "change:title", this.renderContentHeader);
  657. this.renderContentHeader();
  658. var feedModel = window.feedsModel.get(this.model.get("feed_id"));
  659. if (feedModel == undefined){
  660. // we don't have the feed name
  661. window.feedsModel.once("reset", this.renderContentHeader, this);
  662. window.feedsModel.fetch();
  663. }
  664. // content part, article
  665. this.listenTo(this.model, "change:content", this.renderContentHeader);
  666. this.renderContent();
  667. // unread toggle
  668. this.renderUnreadToggleButton();
  669. this.listenTo(this.model, "change:unread", this.renderUnreadToggleButton);
  670. // unread toggle
  671. this.renderStarredToggleButton();
  672. this.listenTo(this.model, "change:marked", this.renderStarredToggleButton);
  673. // unread toggle
  674. this.renderPublishToggleButton();
  675. this.listenTo(this.model, "change:published", this.renderPublishToggleButton);
  676. return this;
  677. },
  678. // callback to update the href of the back button
  679. renderBackButton: function(){
  680. // back button href
  681. var href = Backbone.history.fragment;
  682. href = "#" + href.substr(0, href.lastIndexOf("/"));
  683. this.$("div:jqmData(role='header') a:first").attr("href", href);
  684. },
  685. renderContentHeader: function(){
  686. var $headerDiv = this.$("div:jqmData(role='content') > div.header");
  687. if ($headerDiv.length == 0){
  688. // no div yet
  689. this.$("div:jqmData(role='content')").prepend("<div class=\"header\"></div>");
  690. $headerDiv = this.$("div:jqmData(role='content') > div.header");
  691. }
  692. var link = this.model.get("link");
  693. if (! link){
  694. link = "";
  695. }
  696. var title = this.model.get("title");
  697. if (! title){
  698. title = "Title loading...";
  699. }
  700. var feedModel = window.feedsModel.get(this.model.get("feed_id"));
  701. var feed = "loading";
  702. if (feedModel != undefined){
  703. feed = feedModel.get("title");
  704. }
  705. var time = this.model.get("updated");
  706. if (! time){
  707. time = "loading...";
  708. } else {
  709. time = updateTimeToString(time);
  710. }
  711. var updTxt = "Published: ";
  712. if (this.model.get("is_updated")){
  713. updTxt = "Updated: "
  714. }
  715. $headerDiv.html(
  716. articleTitleTpl({
  717. href: link,
  718. title: title,
  719. feed: feed,
  720. time: time,
  721. update: updTxt
  722. })
  723. );
  724. }, //renderContentHeader
  725. // this callback can be called as a method or an event callback
  726. renderContent: function(event){
  727. // the div for the content
  728. var $contentDiv = this.$("div:jqmData(role='content') > div.main");
  729. if ($contentDiv.length == 0){
  730. // no div yet
  731. this.$("div:jqmData(role='content')").append("<div class=\"main\"></div>");
  732. $contentDiv = this.$("div:jqmData(role='content') > div.main");
  733. }
  734. if (this.model.has("content")){
  735. // this article is ready to be fully displayed
  736. var article = this.model.get("content");
  737. // apply content filters
  738. article = cleanArticle(article, this.model.get("link"));
  739. // display article
  740. $contentDiv.html(article);
  741. // remove any hardcoded sizes
  742. $contentDiv.find('img,object,iframe,audio,video').removeAttr("width");
  743. $contentDiv.find('img,object,iframe,audio,video').removeAttr("height");
  744. $contentDiv.trigger('create');
  745. // add previous/next links at the bottom
  746. if (window.articlesModel.length == 0){
  747. // collection empty, update it
  748. window.articlesModel.once("reset", this.renderPrevNext, this);
  749. window.articlesModel.fetch();
  750. } else {
  751. this.renderPrevNext();
  752. }
  753. // mark as read and save it to the backend
  754. this.model.set({ unread: false });
  755. this.model.save();
  756. } else {
  757. // no content yet, waiting to be notified
  758. $contentDiv.html(articleLoadingTpl({msg: "Loading..."}));
  759. this.model.once("change:content", this.renderContent, this);
  760. }
  761. }, // renderContent
  762. renderUnreadToggleButton: function(){
  763. var but = this.$("a.toggleUnreadButton");
  764. if (this.model.get("unread")){
  765. but.html("Mark as read");
  766. } else {
  767. but.html("Mark as unread");
  768. }
  769. },
  770. renderStarredToggleButton: function(){
  771. var but = this.$("a.toggleStarredButton");
  772. if (this.model.get("marked")){
  773. but.html("Remove star");
  774. } else {
  775. but.html("Mark as starred");
  776. }
  777. },
  778. renderPublishToggleButton: function(){
  779. var but = this.$("a.togglePublishButton");
  780. if (this.model.get("published")){
  781. but.html("Unpublish");
  782. } else {
  783. but.html("Publish");
  784. }
  785. },
  786. renderPrevNext: function(){
  787. // html to add
  788. var html = "";
  789. // is the article in the collection
  790. var m = window.articlesModel.get(this.model.id);
  791. if (m == null){
  792. return;
  793. }
  794. var index = window.articlesModel.indexOf(m);
  795. if (index == -1){
  796. // nothing to do, article not in the collection
  797. return ;
  798. }
  799. // base link
  800. var ln = "#" + Backbone.history.fragment;
  801. ln = ln.substring(0, ln.lastIndexOf("art") + 3);
  802. if (index > 0){
  803. // do we have a previous article?
  804. var prevArt = window.articlesModel.at(index - 1);
  805. html += gridLeftButtonTpl({
  806. href: ln + prevArt.id,
  807. cl: "",
  808. title: prevArt.get("title")
  809. });
  810. } else {
  811. // disabled button
  812. html += gridLeftButtonTpl({
  813. href: "#",
  814. cl: "ui-disabled",
  815. title: ""
  816. });
  817. }
  818. if (index + 1 < window.articlesModel.length){
  819. // do we have a next article?
  820. var nextArt = window.articlesModel.at(index + 1);
  821. html += gridRightButtonTpl({
  822. href: ln + nextArt.id,
  823. cl: "",
  824. title: nextArt.get("title")
  825. });
  826. } else {
  827. // disabled button
  828. html += gridRightButtonTpl({
  829. href: "#",
  830. cl: "ui-disabled",
  831. title: ""
  832. });
  833. }
  834. // we now have the HTML ready, add it to the content
  835. this.$("div:jqmData(role='content') > div.main")
  836. .append(html).trigger('create');
  837. },
  838. initialize: function(){
  839. // mark as unread button on an article
  840. this.$('a.toggleUnreadButton').on('click', this, function(e){
  841. var artModel = e.data.model;
  842. artModel.set("unread", ! artModel.get("unread"));
  843. artModel.save();
  844. $('#readPopupMenu').popup('close');
  845. e.preventDefault();
  846. });
  847. // mark as starred button on an article
  848. this.$('a.toggleStarredButton').on('click', this, function(e){
  849. var artModel = e.data.model;
  850. artModel.set("marked", ! artModel.get("marked"));
  851. artModel.save();
  852. $('#readPopupMenu').popup('close');
  853. e.preventDefault();
  854. });
  855. // publish button on an article
  856. this.$('a.togglePublishButton').on('click', this, function(e){
  857. var artModel = e.data.model;
  858. artModel.set("published", ! artModel.get("published"));
  859. artModel.save();
  860. $('#readPopupMenu').popup('close');
  861. e.preventDefault();
  862. });
  863. // store a reference on the listview
  864. this.$lv = this.$("div:jqmData(role='content') " +
  865. "ul:jqmData(role='listview')");
  866. } // initialize
  867. }); // ArticlePageView
  868. window.articlePageView = new ArticlePageView({
  869. el: $("#read")
  870. });
  871. } // defineViews
  872. /************** underscore templates ************/
  873. var listSeparatorTpl;
  874. var listElementTpl;
  875. var listElementWithIconTpl;
  876. var listLoadMoreTpl;
  877. var roListElementTpl;
  878. var articleLiElementTpl;
  879. var articleFeedLiElementTpl;
  880. var articleReadLiElementTpl;
  881. var articleReadFeedLiElementTpl;
  882. var articleLoadingTpl;
  883. var articleTitleTpl;
  884. function compileTemplates(){
  885. // a jQuery listview separator element
  886. listSeparatorTpl = _.template('<li data-role="list-divider"><%= text %></li>');
  887. // a jQuery listview link element (to put inside a li)
  888. listElementTpl = _.template('<a href="<%= href %>">' +
  889. '<%= title %>' +
  890. '<span class="ui-li-count"><%= count %></span>' +
  891. '</a>');
  892. // a jQuery listview link element with icon (to put inside a li)
  893. listElementWithIconTpl = _.template('<a href="<%= href %>">' +
  894. '<img src="<%= src %>" class="ui-li-icon"></img>' +
  895. '<%= title %>' +
  896. '<span class="ui-li-count"><%= count %></span>' +
  897. '</a>');
  898. // a jQuery listview read-only element
  899. roListElementTpl = _.template('<li class="ui-li-static"><%= text %></li>');
  900. //
  901. listLoadMoreTpl = _.template('<li data-theme="b"><a class="loadMoreButton" href="#">' +
  902. '<h2>Load more articles...</h2></a></li>');
  903. // the content of a LI element for an article
  904. articleLiElementTpl = _.template('<a href="<%= href %>">' +
  905. '<h3><%= title %></h3>' +
  906. '<p class="ui-li-desc"><%= date %></p></a>');
  907. // the content of a LI element for an article with the feed Name
  908. articleFeedLiElementTpl = _.template('<a href="<%= href %>">' +
  909. '<h3><%= title %></h3>' +
  910. '<p class="ul-li-desc"><strong><%= feed %></strong></p>' +
  911. '<p class="ui-li-desc"><%= date %></p></a>');
  912. // the content of a LI element for a read article
  913. articleReadLiElementTpl = _.template('<a href="<%= href %>">' +
  914. '<h3><%= title %></h3>' +
  915. '<p class="ui-li-desc"><%= date %>&nbsp;&ndash;&nbsp;<em>already read</em></p></a>');
  916. // the content of a LI element for a read article with the feed Name
  917. articleReadFeedLiElementTpl = _.template('<a href="<%= href %>">' +
  918. '<h3><%= title %></h3>' +
  919. '<p class="ul-li-desc"><strong><%= feed %></strong></p>' +
  920. '<p class="ui-li-desc"><%= date %>&nbsp;&ndash;&nbsp;<em>already read</em></p></a>');
  921. // the content of the content DIV when an article is loading
  922. articleLoadingTpl = _.template('<h3><%= msg %></h3>');
  923. // the header content for an article page
  924. articleTitleTpl = _.template('<h3><a href="<%= href %>" target="_blank"><%= title %></a></h3>' +
  925. '<p class="feed">Feed: <%= feed %></p>' +
  926. '<p class="updateTime"><%= update %><time><%= time %></time></p>');
  927. // button for the prev/next
  928. gridLeftButtonTpl = _.template('<div class="ui-grid-a">' +
  929. '<div class="ui-block-a">' +
  930. '<a data-role="button" data-icon="arrow-l" href="<%= href %>" class="<%= cl %>">previous</a>' +
  931. '<em><%= title %></em></div>');
  932. gridRightButtonTpl = _.template('<div class="ui-block-b">' +
  933. '<a data-role="button" data-icon="arrow-r" data-iconpos="right" href="<%= href %>" class="<%= cl %>">next</a>' +
  934. '<em><%= title %></em></div>' +
  935. '</div>');
  936. }
  937. /*************** BACKBONE Router ************/
  938. // to define the Backbone router of the webapp
  939. function defineRouter(){
  940. MyRouter = Backbone.Router.extend({
  941. routes: {
  942. "login": "login", // #login
  943. "login?from=*qr": "login", // #login?from=#cat4/feed23
  944. "": "categories", // #
  945. "cat:catId": "feeds", // #cat4
  946. "cat:catId/feed:feedId": "articles", // #cat4/feed23
  947. "cat:catId/feed:feedId/art:artId": "read", // #cat4/feed23/art1234
  948. "*path": "defaultRoute" // #*
  949. },
  950. defaultRoute: function(path){
  951. if (! isLoggedIn()){
  952. window.myRouter.navigate('login', {trigger: true});
  953. } else {
  954. window.myRouter.navigate('', {trigger: true});
  955. }
  956. },
  957. login: function() {
  958. this.transitionOptions = {transition: "slideup"},
  959. this.goto('#login');
  960. },
  961. categories: function(){
  962. // show the page
  963. this.goto(window.categoriesPageView.render().$el);
  964. // update model
  965. window.categoriesModel.fetch();
  966. },
  967. articles: function(catId, feedId){
  968. // test feedId is an integer (negative or positive)
  969. var id = parseInt(feedId);
  970. if (isNaN(id)){
  971. window.myRouter.navigate('', {trigger: true});
  972. } else {
  973. // go to the view
  974. this.goto(window.articlesPageView.render().$el);
  975. // update model
  976. window.articlesModel.fetch();
  977. }
  978. },
  979. feeds: function(catId){
  980. // test catId is an integer (negative or positive)
  981. var id = parseInt(catId);
  982. if (isNaN(id)){
  983. window.myRouter.navigate('', {trigger: true});
  984. } else {
  985. // go to the view
  986. this.goto(window.feedsPageView.render().$el);
  987. // update model
  988. window.feedsModel.fetch();
  989. }
  990. },
  991. read: function(catName, feedName, artId){
  992. var id = parseInt(artId);
  993. if (isNaN(id)){
  994. // id invalid, go to categories page
  995. window.myRouter.navigate('', {trigger: true});
  996. } else {
  997. // the model
  998. var art = window.articlesModel.get(id);
  999. if (art == undefined){
  1000. /* we could not find the model in the collection
  1001. this must be the first page loaded */
  1002. art = new window.ArticleModel({id: id});
  1003. }
  1004. // set the model of the view & display it
  1005. var view = window.articlePageView;
  1006. view.model = art;
  1007. this.goto(view.render().$el);
  1008. // scroll to top
  1009. window.scroll(0,0);
  1010. // tell the model to get all the article data
  1011. // if we don't have content yet
  1012. if (! art.has("content")){
  1013. art.fetch();
  1014. }
  1015. }
  1016. },
  1017. transitionOptions: {},
  1018. setNextTransOptions : function(obj){
  1019. this.transitionOptions = obj;
  1020. },
  1021. goto: function(page){
  1022. $.mobile.changePage(page, this.transitionOptions);
  1023. // reset transitions options
  1024. this.transitionOptions = {};
  1025. } // goto
  1026. });
  1027. window.myRouter = new MyRouter();
  1028. }
  1029. /************* utilities ***********/
  1030. // to check the start of a string
  1031. if (typeof String.prototype.startsWith != 'function') {
  1032. String.prototype.startsWith = function (str){
  1033. return this.indexOf(str) == 0;
  1034. };
  1035. }
  1036. // clean up a dom object (article to display)
  1037. function cleanArticle(content, domain){
  1038. var data = "<article>" + content + "</article>";
  1039. var $dom = $(data);
  1040. /* ARS Technica styles DIVs */
  1041. $dom.find('div').removeAttr('style');
  1042. /* ARS technica bookmarks */
  1043. $dom.find('div.feedflare').remove();
  1044. /* Feedburner images */
  1045. $dom.find('img[href~="feedburner"]').remove();
  1046. var $toClean = $dom.find('img,object,iframe');
  1047. $toClean.removeAttr('height');
  1048. $toClean.each(
  1049. function(index, e){
  1050. // if relativeURL, add domain
  1051. var src = $(e).attr('src');
  1052. if ($.mobile.path.isRelativeUrl(src)){
  1053. var newsrc = $.mobile.path.makeUrlAbsolute(
  1054. src,
  1055. domain
  1056. );
  1057. $(e).attr('src', newsrc);
  1058. }
  1059. }
  1060. );
  1061. // make all links open in a new tab
  1062. $toClean = $dom.find('a');
  1063. $toClean.each(
  1064. function(index, e){
  1065. $(e).attr('target', '_blank');
  1066. }
  1067. );
  1068. return $dom;
  1069. }
  1070. /* function to call TTRSS
  1071. - req => the request as a JSON object
  1072. - success => the success callback (one param the content)
  1073. - async => async call? */
  1074. function ttRssApiCall(req, success, async){
  1075. jQuery.ajax(
  1076. {
  1077. url: window.apiPath + 'api/',
  1078. contentType: "application/json",
  1079. dataType: 'json',
  1080. cache: 'false',
  1081. data: JSON.stringify(req),
  1082. type: 'post',
  1083. async: async
  1084. }
  1085. )
  1086. .done(function(data){
  1087. if (data.status == 0){
  1088. success(data.content);
  1089. } else {
  1090. apiErrorHandler(data.content);
  1091. }
  1092. });
  1093. }
  1094. /* Most of the calls (except login, logout, isLoggedIn)
  1095. require valid login session or will return this
  1096. error object: {"error":"NOT_LOGGED_IN"} */
  1097. function apiErrorHandler(msg){
  1098. if (msg.error == "NOT_LOGGED_IN"){
  1099. var where = "login";
  1100. if (location.hash != ""){
  1101. // we store where we-re coming from in a query string
  1102. where += "?from=" + location.hash;
  1103. }
  1104. window.myRouter.navigate(where, {trigger: true});
  1105. } else {
  1106. alert('apiErrorHandler\nUnknown API error message' + msg.error);
  1107. }
  1108. }
  1109. function ajaxErrorHandler(event, jqXHR, ajaxSettings, thrownError){
  1110. // TODO
  1111. alert('ajaxErrorHandler' + thrownError);
  1112. }
  1113. /* to make a logout call */
  1114. function logout(){
  1115. var msg = {
  1116. 'op': 'logout'
  1117. };
  1118. ttRssApiCall(msg,
  1119. function(){
  1120. window.myRouter.navigate('login', {trigger: true});
  1121. },
  1122. function(m){
  1123. alert('Could not logout :\n' + m);
  1124. }, true
  1125. );
  1126. }
  1127. function registerLoginPageActions(){
  1128. // register login button action
  1129. $('#login form').submit(function(e){
  1130. $.mobile.loading( 'show', { text: 'Authenticating...', textVisible: true} );
  1131. e.preventDefault();
  1132. // message to send
  1133. var data = {
  1134. op: "login",
  1135. user: $('#loginInput').val(),
  1136. password : $('#passwordInput').val()
  1137. };
  1138. jQuery.ajax(
  1139. {
  1140. url: window.apiPath + 'api/',
  1141. contentType: "application/json",
  1142. dataType: 'json',
  1143. cache: 'false',
  1144. data: JSON.stringify(data),
  1145. type: 'post',
  1146. async: false
  1147. }
  1148. )
  1149. .done(function(data){
  1150. if (data.status == 0){
  1151. window.myRouter.setNextTransOptions({reverse: true, transition: "slideup"});
  1152. // try to get from query string if it exists
  1153. var fragment = location.hash;
  1154. var re = /\?from=#(.+)/;
  1155. var nextRoute = "cat";
  1156. var ex = re.exec(fragment)
  1157. if (ex != null){
  1158. nextRoute = ex[1];
  1159. }
  1160. window.myRouter.navigate(nextRoute, {trigger: true});
  1161. } else {
  1162. var msg = "Unknown answer from the API:" + data.content;
  1163. if (data.content.error == "API_DISABLED"){
  1164. msg = 'API is disabled for this user';
  1165. } else if (data.content.error == "LOGIN_ERROR"){
  1166. msg = "Specified username and password are incorrect";
  1167. }
  1168. alert(msg);
  1169. $.mobile.loading('hide');
  1170. }
  1171. });
  1172. }); // login button
  1173. }
  1174. function isLoggedIn(){
  1175. var msg = {
  1176. 'op': 'isLoggedIn'
  1177. };
  1178. var loggedIn;
  1179. jQuery.ajax(
  1180. {
  1181. url: window.apiPath + 'api/',
  1182. contentType: "application/json",
  1183. dataType: 'json',
  1184. cache: 'false',
  1185. data: JSON.stringify(msg),
  1186. type: 'post',
  1187. async: false
  1188. }
  1189. )
  1190. .done(function(data){
  1191. if (data.status == 0){
  1192. loggedIn = data.content.status;
  1193. } else {
  1194. apiErrorHandler (data.content);
  1195. }
  1196. });
  1197. return loggedIn;
  1198. } // isLoggedIn
  1199. // it returns a valid formatted string
  1200. // of the update time representation of Tiny Tiny RSS
  1201. function updateTimeToString(time){
  1202. var date = new Date(time * 1000);
  1203. var now = new Date(Date.now());
  1204. // date in YYYY-MM-DD
  1205. var year = date.getFullYear();
  1206. var month = date.getMonth() + 1;
  1207. month = (month < 10 ? "0" : "") + month;
  1208. var day = date.getDate();
  1209. day = (day < 10 ? "0" : "") + day;
  1210. var dateStr = year + "-" + month + "-" + day;
  1211. // time in HH:MM
  1212. var hour = date.getHours();
  1213. hour = (hour < 10 ? "0" : "") + hour;
  1214. var min = date.getMinutes();
  1215. min = (min < 10 ? "0" : "") + min;
  1216. var timeStr = hour + ":" + min;
  1217. // now in YYYY-MM-DD
  1218. year = now.getFullYear();
  1219. month = now.getMonth() + 1;
  1220. month = (month < 10 ? "0" : "") + month;
  1221. day = now.getDate();
  1222. day = (day < 10 ? "0" : "") + day;
  1223. var nowStr = year + "-" + month + "-" + day;
  1224. // if today, puts the time of day
  1225. if (dateStr == nowStr){
  1226. // only time if it's today
  1227. dateStr = timeStr;
  1228. } else {
  1229. dateStr = dateStr + " " + timeStr;
  1230. }
  1231. return dateStr;
  1232. }
  1233. /************** init bindings *************/
  1234. $(document).bind('mobileinit', function(event){
  1235. // desactivate jQueryMobile routing (we use Backbone.Router)
  1236. $.mobile.ajaxEnabled = false;
  1237. $.mobile.linkBindingEnabled = false;
  1238. $.mobile.hashListeningEnabled = false;
  1239. $.mobile.pushStateEnabled = false;
  1240. $.mobile.changePage.defaults.changeHash = false;
  1241. $.mobile.defaultPageTransition = "slide";
  1242. });
  1243. var g_init = false;
  1244. $(document).bind('pageinit', function(event){
  1245. if (! g_init){
  1246. g_init = true;
  1247. // events for login page
  1248. registerLoginPageActions();
  1249. // my handler for AJAX errors
  1250. $(document).ajaxError(ajaxErrorHandler);
  1251. // underscore.js
  1252. compileTemplates();
  1253. // backbone.js
  1254. defineModels();
  1255. defineViews();
  1256. defineRouter();
  1257. // initialize all logout buttons
  1258. $('a.logoutButton').on('click',
  1259. function(e){
  1260. e.preventDefault();
  1261. $.mobile.loading( 'show', { text: 'Logging out...', textVisible: true} );
  1262. logout();
  1263. }
  1264. );
  1265. // initialize all logout buttons
  1266. $('a.backButton').on('click',
  1267. function(e){
  1268. window.myRouter.setNextTransOptions({reverse: true});
  1269. }
  1270. );
  1271. // initialize all menu buttons
  1272. $("a[data-rel='popup']").on('click',
  1273. function(e){
  1274. e.preventDefault();
  1275. var popupId = $(e.currentTarget).attr('href');
  1276. var transition = $(popupId).attr('data-transition');
  1277. $(popupId).popup("open", {transition: transition,
  1278. positionTo: $(e.currentTarget) });
  1279. }
  1280. );
  1281. // prepare all pages now
  1282. $("div:jqmData(role='page')").page();
  1283. // first transition
  1284. window.myRouter.setNextTransOptions({transition: "fade"});
  1285. // start Backbone router
  1286. if (!Backbone.history.start({pushState: false, root: window.webappPath, silent: false})){
  1287. alert("Could not start router!");
  1288. }
  1289. }
  1290. });