diff options
author | Mike Crute <mcrute@gmail.com> | 2015-07-29 18:30:42 -0700 |
---|---|---|
committer | Mike Crute <mcrute@gmail.com> | 2015-07-29 18:30:42 -0700 |
commit | 979633a50f11afbb6388ac0fd46e45aac64a286a (patch) | |
tree | 98ed772da65e8b37349e5abfca3c9f3b9da41760 | |
parent | c68030ada8a1c7bf0b90be17aa37315d8e2ad89b (diff) | |
download | web_rss_reader-979633a50f11afbb6388ac0fd46e45aac64a286a.tar.bz2 web_rss_reader-979633a50f11afbb6388ac0fd46e45aac64a286a.tar.xz web_rss_reader-979633a50f11afbb6388ac0fd46e45aac64a286a.zip |
-rwxr-xr-x | app.py | 85 | ||||
-rw-r--r-- | templates/items.html | 250 |
2 files changed, 213 insertions, 122 deletions
@@ -43,8 +43,7 @@ class RSSItem(DataObject): | |||
43 | def __init__(self, id, title, author, feed_title, feed_url, url, | 43 | def __init__(self, id, title, author, feed_title, feed_url, url, |
44 | publish_date, content, read): | 44 | publish_date, content, read): |
45 | self.id = id | 45 | self.id = id |
46 | self.feed_url = RSSFeed(feed_url, None, None).self_link | 46 | self.feed = RSSFeed(feed_url, None, feed_title) |
47 | self.feed_title = feed_title | ||
48 | self.title = title | 47 | self.title = title |
49 | self.author = author | 48 | self.author = author |
50 | self.url = url | 49 | self.url = url |
@@ -53,6 +52,10 @@ class RSSItem(DataObject): | |||
53 | self.read = not bool(read) | 52 | self.read = not bool(read) |
54 | self.no_content = len(content) < 100 | 53 | self.no_content = len(content) < 100 |
55 | 54 | ||
55 | def _update_json(self, data): | ||
56 | data['feed'] = self.feed.self_link | ||
57 | return data | ||
58 | |||
56 | 59 | ||
57 | class RSSFeed(DataObject): | 60 | class RSSFeed(DataObject): |
58 | 61 | ||
@@ -64,7 +67,16 @@ class RSSFeed(DataObject): | |||
64 | self.title = title | 67 | self.title = title |
65 | 68 | ||
66 | def _update_json(self, data): | 69 | def _update_json(self, data): |
67 | data['items'] = '/feed/{}/items'.format(self.id) | 70 | data['items'] = self.items |
71 | data['unread_items'] = self.unread_items | ||
72 | |||
73 | @property | ||
74 | def items(self): | ||
75 | return '/feed/{}/items'.format(self.id) | ||
76 | |||
77 | @property | ||
78 | def unread_items(self): | ||
79 | return '/feed/{}/items/unread'.format(self.id) | ||
68 | 80 | ||
69 | @property | 81 | @property |
70 | def id(self): | 82 | def id(self): |
@@ -77,6 +89,13 @@ class RSSFeed(DataObject): | |||
77 | '{}========'.format(token).encode('utf-8')).decode('utf-8') | 89 | '{}========'.format(token).encode('utf-8')).decode('utf-8') |
78 | 90 | ||
79 | 91 | ||
92 | class UnreadRSSFeed(RSSFeed): | ||
93 | |||
94 | def __init__(self, feed_url, url, title, unread_count): | ||
95 | super(UnreadRSSFeed, self).__init__(feed_url, url, title) | ||
96 | self.unread_count = unread_count | ||
97 | |||
98 | |||
80 | 99 | ||
81 | class DBReader(object): | 100 | class DBReader(object): |
82 | 101 | ||
@@ -95,6 +114,20 @@ class DBReader(object): | |||
95 | i.pubDate desc | 114 | i.pubDate desc |
96 | ''' | 115 | ''' |
97 | 116 | ||
117 | UNREAD_FEEDS = ''' | ||
118 | SELECT | ||
119 | f.rssurl, f.url, f.title, | ||
120 | count(*) as unread_count | ||
121 | FROM | ||
122 | rss_item i | ||
123 | LEFT JOIN rss_feed f | ||
124 | ON f.rssurl = i.feedurl | ||
125 | WHERE | ||
126 | i.unread = 1 | ||
127 | GROUP BY | ||
128 | i.feedurl | ||
129 | ''' | ||
130 | |||
98 | def __init__(self, db_path): | 131 | def __init__(self, db_path): |
99 | self.con = sqlite3.connect(db_path) | 132 | self.con = sqlite3.connect(db_path) |
100 | self.con.row_factory = RSSItem.from_db_row | 133 | self.con.row_factory = RSSItem.from_db_row |
@@ -120,17 +153,26 @@ class DBReader(object): | |||
120 | [1 if unread else 0, id]) | 153 | [1 if unread else 0, id]) |
121 | con.commit() | 154 | con.commit() |
122 | 155 | ||
156 | def update_feed_unread(self, token, unread=True): | ||
157 | with self.con as con: | ||
158 | con.execute( | ||
159 | 'UPDATE rss_item SET unread = ? WHERE feedurl = ?', | ||
160 | [1 if unread else 0, RSSFeed.parse_token(token)]) | ||
161 | con.commit() | ||
162 | |||
123 | def get_entry(self, id): | 163 | def get_entry(self, id): |
124 | return self._fetch("i.id = ?", [id])[0] | 164 | return self._fetch("i.id = ?", [id])[0] |
125 | 165 | ||
126 | def get_unread(self): | 166 | def get_unread(self): |
127 | data = defaultdict(list) | 167 | data = defaultdict(list) |
128 | unread = self._fetch("i.unread = ?", [1]) | 168 | return self._fetch("i.unread = ?", [1]) |
129 | |||
130 | for record in unread: | ||
131 | data[record.feed_title].append(record) | ||
132 | 169 | ||
133 | return sorted(data.items()) | 170 | def get_unread_feeds(self): |
171 | with self.con as con: | ||
172 | con.row_factory = UnreadRSSFeed.from_db_row | ||
173 | curs = con.cursor() | ||
174 | curs.execute(self.UNREAD_FEEDS) | ||
175 | return curs.fetchall() | ||
134 | 176 | ||
135 | def get_unread_for_feed(self, token, only_unread=False): | 177 | def get_unread_for_feed(self, token, only_unread=False): |
136 | if only_unread: | 178 | if only_unread: |
@@ -159,16 +201,21 @@ def json_list(data): | |||
159 | 201 | ||
160 | @app.route('/') | 202 | @app.route('/') |
161 | def index(): | 203 | def index(): |
162 | reader = DBReader(DB_PATH) | 204 | return render_template('items.html') |
163 | return render_template('items.html', items=reader.get_unread()) | ||
164 | 205 | ||
165 | 206 | ||
166 | @app.route('/feed/') | 207 | @app.route('/feed') |
167 | def feed_list(): | 208 | def feed_list(): |
168 | reader = DBReader(DB_PATH) | 209 | reader = DBReader(DB_PATH) |
169 | return jsonify({ 'feeds': json_list(reader.get_feeds()) }) | 210 | return jsonify({ 'feeds': json_list(reader.get_feeds()) }) |
170 | 211 | ||
171 | 212 | ||
213 | @app.route('/feed/unread') | ||
214 | def unread_feed_list(): | ||
215 | reader = DBReader(DB_PATH) | ||
216 | return jsonify({ 'feeds': json_list(reader.get_unread_feeds()) }) | ||
217 | |||
218 | |||
172 | @app.route('/feed/<token>') | 219 | @app.route('/feed/<token>') |
173 | def feed(token): | 220 | def feed(token): |
174 | reader = DBReader(DB_PATH) | 221 | reader = DBReader(DB_PATH) |
@@ -182,13 +229,27 @@ def feed_items(token): | |||
182 | return jsonify({ 'items': json_list(unread), "count": len(unread) }) | 229 | return jsonify({ 'items': json_list(unread), "count": len(unread) }) |
183 | 230 | ||
184 | 231 | ||
185 | @app.route('/feed/<token>/items/unread') | 232 | @app.route('/feed/<token>/items/unread', methods=["GET", "POST"]) |
186 | def unread_feed_items(token): | 233 | def unread_feed_items(token): |
187 | reader = DBReader(DB_PATH) | 234 | reader = DBReader(DB_PATH) |
235 | if request.method == 'POST': | ||
236 | try: | ||
237 | read = bool(int(request.form.get('read'))) | ||
238 | except: | ||
239 | return make_response('', 400) | ||
240 | |||
241 | reader.update_feed_unread(token, not read) | ||
242 | |||
188 | unread = reader.get_unread_for_feed(token, True) | 243 | unread = reader.get_unread_for_feed(token, True) |
189 | return jsonify({ 'items': json_list(unread), "count": len(unread) }) | 244 | return jsonify({ 'items': json_list(unread), "count": len(unread) }) |
190 | 245 | ||
191 | 246 | ||
247 | @app.route('/item/unread') | ||
248 | def unread_item_list(): | ||
249 | reader = DBReader(DB_PATH) | ||
250 | return jsonify({ 'items': json_list(reader.get_unread()) }) | ||
251 | |||
252 | |||
192 | @app.route("/item/<int:entry_id>", methods=["GET", "POST"]) | 253 | @app.route("/item/<int:entry_id>", methods=["GET", "POST"]) |
193 | def item(entry_id): | 254 | def item(entry_id): |
194 | #post read=1 | 255 | #post read=1 |
diff --git a/templates/items.html b/templates/items.html index 22adbba..e138ece 100644 --- a/templates/items.html +++ b/templates/items.html | |||
@@ -8,120 +8,80 @@ | |||
8 | font: 12px Georgia,serif; | 8 | font: 12px Georgia,serif; |
9 | } | 9 | } |
10 | 10 | ||
11 | .entries li { | 11 | #feeds, #feed-items { |
12 | position: relative; | 12 | margin: 0; |
13 | padding: 0; | ||
13 | list-style: none; | 14 | list-style: none; |
14 | border: 1px solid #999; | 15 | width: 95%; |
15 | margin: 0.5em 0; | ||
16 | } | 16 | } |
17 | 17 | ||
18 | .entries { | 18 | #feeds-container { |
19 | margin: 0; | 19 | margin: 0; |
20 | padding: 0; | 20 | padding: 0; |
21 | display: inline-block; | ||
22 | vertical-align: top; | ||
23 | border: 1px solid #999; | ||
21 | } | 24 | } |
22 | 25 | ||
23 | .entries a.headline { | 26 | #feeds li { |
24 | display: block; | 27 | padding: 0.7em; |
25 | background: #ddd; | 28 | cursor: pointer; |
26 | padding: 1em; | ||
27 | color: black; | ||
28 | text-decoration: none; | ||
29 | font-weight: bolder; | ||
30 | } | 29 | } |
31 | 30 | ||
32 | .entries .highlighted a.headline { | 31 | #minimize-feeds { |
33 | background-color: #FFFF99; | 32 | cursor: pointer; |
33 | padding: 0.7em; | ||
34 | background-color: #ccc; | ||
34 | } | 35 | } |
35 | 36 | ||
36 | .entries li > input.read { | 37 | #feed-items li { |
37 | position: absolute; | 38 | position: relative; |
38 | top: 1em; | 39 | border: 1px solid gray; |
39 | right: 1em; | 40 | border-top: none; |
41 | width: 1000px; | ||
40 | } | 42 | } |
41 | 43 | ||
42 | .entries .content { | 44 | #feed-items li .title { |
43 | padding: 1em; | 45 | display: inline-block; |
44 | display: none; | 46 | cursor: pointer; |
47 | padding: 0.5em; | ||
48 | margin: 0.1em; | ||
49 | line-height: 1.5em; | ||
45 | } | 50 | } |
46 | 51 | ||
47 | h2 a.mark-all-read { | 52 | #feed-items li:first-child { |
48 | display: inline-block; | 53 | border-top: 1px solid gray; |
49 | position: absolute; | ||
50 | right: 1em; | ||
51 | } | 54 | } |
52 | </style> | ||
53 | 55 | ||
54 | <script src="//code.jquery.com/jquery-2.1.1.min.js"></script> | 56 | #feed-items li input { |
57 | margin: 0.5em; | ||
58 | } | ||
55 | 59 | ||
56 | <script type="text/javascript"> | 60 | #feeds li.active { |
57 | /** | 61 | background-color: #ff9; |
58 | * Copyright (c) 2007-2014 Ariel Flesler - aflesler<a>gmail<d>com | http://flesler.blogspot.com | 62 | border: 1px solid goldenrod; |
59 | * Licensed under MIT | 63 | border-right: none; |
60 | * @author Ariel Flesler | 64 | border-top-left-radius: 10px; |
61 | * @version 1.4.12 | 65 | border-bottom-left-radius: 10px; |
62 | */ | 66 | } |
63 | ;(function(a){if(typeof define==='function'&&define.amd){define(['jquery'],a)}else{a(jQuery)}}(function($){var j=$.scrollTo=function(a,b,c){return $(window).scrollTo(a,b,c)};j.defaults={axis:'xy',duration:parseFloat($.fn.jquery)>=1.3?0:1,limit:true};j.window=function(a){return $(window)._scrollable()};$.fn._scrollable=function(){return this.map(function(){var a=this,isWin=!a.nodeName||$.inArray(a.nodeName.toLowerCase(),['iframe','#document','html','body'])!=-1;if(!isWin)return a;var b=(a.contentWindow||a).document||a.ownerDocument||a;return/webkit/i.test(navigator.userAgent)||b.compatMode=='BackCompat'?b.body:b.documentElement})};$.fn.scrollTo=function(f,g,h){if(typeof g=='object'){h=g;g=0}if(typeof h=='function')h={onAfter:h};if(f=='max')f=9e9;h=$.extend({},j.defaults,h);g=g||h.duration;h.queue=h.queue&&h.axis.length>1;if(h.queue)g/=2;h.offset=both(h.offset);h.over=both(h.over);return this._scrollable().each(function(){if(f==null)return;var d=this,$elem=$(d),targ=f,toff,attr={},win=$elem.is('html,body');switch(typeof targ){case'number':case'string':if(/^([+-]=?)?\d+(\.\d+)?(px|%)?$/.test(targ)){targ=both(targ);break}targ=win?$(targ):$(targ,this);if(!targ.length)return;case'object':if(targ.is||targ.style)toff=(targ=$(targ)).offset()}var e=$.isFunction(h.offset)&&h.offset(d,targ)||h.offset;$.each(h.axis.split(''),function(i,a){var b=a=='x'?'Left':'Top',pos=b.toLowerCase(),key='scroll'+b,old=d[key],max=j.max(d,a);if(toff){attr[key]=toff[pos]+(win?0:old-$elem.offset()[pos]);if(h.margin){attr[key]-=parseInt(targ.css('margin'+b))||0;attr[key]-=parseInt(targ.css('border'+b+'Width'))||0}attr[key]+=e[pos]||0;if(h.over[pos])attr[key]+=targ[a=='x'?'width':'height']()*h.over[pos]}else{var c=targ[pos];attr[key]=c.slice&&c.slice(-1)=='%'?parseFloat(c)/100*max:c}if(h.limit&&/^\d+$/.test(attr[key]))attr[key]=attr[key]<=0?0:Math.min(attr[key],max);if(!i&&h.queue){if(old!=attr[key])animate(h.onAfterFirst);delete attr[key]}});animate(h.onAfter);function animate(a){$elem.animate(attr,g,h.easing,a&&function(){a.call(this,targ,h)})}}).end()};j.max=function(a,b){var c=b=='x'?'Width':'Height',scroll='scroll'+c;if(!$(a).is('html,body'))return a[scroll]-$(a)[c.toLowerCase()]();var d='client'+c,html=a.ownerDocument.documentElement,body=a.ownerDocument.body;return Math.max(html[scroll],body[scroll])-Math.min(html[d],body[d])};function both(a){return $.isFunction(a)||typeof a=='object'?a:{top:a,left:a}};return j})); | ||
64 | </script> | ||
65 | 67 | ||
66 | <script type="text/javascript"> | 68 | .content-iframe { |
67 | /* | 69 | width: 100%; |
68 | * Viewport - jQuery selectors for finding elements in viewport | 70 | border: none; |
69 | * | 71 | } |
70 | * Copyright (c) 2008-2009 Mika Tuupola | 72 | |
71 | * | 73 | .no-content { |
72 | * Licensed under the MIT license: | 74 | background: url(http://www.famfamfam.com/lab/icons/silk/icons/tab.png) no-repeat 99% center; |
73 | * http://www.opensource.org/licenses/mit-license.php | 75 | } |
74 | * | 76 | </style> |
75 | * Project home: | ||
76 | * http://www.appelsiini.net/projects/viewport | ||
77 | * | ||
78 | */ | ||
79 | (function($) { | ||
80 | |||
81 | $.belowthefold = function(element, settings) { | ||
82 | var fold = $(window).height() + $(window).scrollTop(); | ||
83 | return fold <= $(element).offset().top - settings.threshold; | ||
84 | }; | ||
85 | |||
86 | $.abovethetop = function(element, settings) { | ||
87 | var top = $(window).scrollTop(); | ||
88 | return top >= $(element).offset().top + $(element).height() - settings.threshold; | ||
89 | }; | ||
90 | |||
91 | $.rightofscreen = function(element, settings) { | ||
92 | var fold = $(window).width() + $(window).scrollLeft(); | ||
93 | return fold <= $(element).offset().left - settings.threshold; | ||
94 | }; | ||
95 | |||
96 | $.leftofscreen = function(element, settings) { | ||
97 | var left = $(window).scrollLeft(); | ||
98 | return left >= $(element).offset().left + $(element).width() - settings.threshold; | ||
99 | }; | ||
100 | |||
101 | $.inviewport = function(element, settings) { | ||
102 | return !$.rightofscreen(element, settings) && !$.leftofscreen(element, settings) && !$.belowthefold(element, settings) && !$.abovethetop(element, settings); | ||
103 | }; | ||
104 | |||
105 | $.extend($.expr[':'], { | ||
106 | "below-the-fold": function(a, i, m) { | ||
107 | return $.belowthefold(a, {threshold : 0}); | ||
108 | }, | ||
109 | "above-the-top": function(a, i, m) { | ||
110 | return $.abovethetop(a, {threshold : 0}); | ||
111 | }, | ||
112 | "left-of-screen": function(a, i, m) { | ||
113 | return $.leftofscreen(a, {threshold : 0}); | ||
114 | }, | ||
115 | "right-of-screen": function(a, i, m) { | ||
116 | return $.rightofscreen(a, {threshold : 0}); | ||
117 | }, | ||
118 | "in-viewport": function(a, i, m) { | ||
119 | return $.inviewport(a, {threshold : 0}); | ||
120 | } | ||
121 | }); | ||
122 | })(jQuery); | ||
123 | </script> | ||
124 | 77 | ||
78 | <script src="https://mike.crute.me/resources/modernizr-2.8.2.js"></script> | ||
79 | <script src="https://mike.crute.me/resources/jquery-2.1.1.js"></script> | ||
80 | <script src="https://mike.crute.me/resources/underscore-1.6.0.js"></script> | ||
81 | <script src="https://mike.crute.me/resources/jquery.scrollTo-1.4.12.js"></script> | ||
82 | <script src="https://mike.crute.me/resources/jquery.viewport.js"></script> | ||
83 | |||
84 | <!-- | ||
125 | <script type="text/javascript"> | 85 | <script type="text/javascript"> |
126 | $(document).ready(function() { | 86 | $(document).ready(function() { |
127 | $("a.headline").on("click", function(event) { | 87 | $("a.headline").on("click", function(event) { |
@@ -162,13 +122,15 @@ | |||
162 | }); | 122 | }); |
163 | }); | 123 | }); |
164 | 124 | ||
165 | $("h2 a.mark-all-read").on("click", function(event) { | 125 | $("h1 a.mark-all-read").on("click", function(event) { |
166 | event.preventDefault(); | 126 | event.preventDefault(); |
167 | 127 | ||
168 | $(event.target).parents(".feed").find("a.headline").each(function(idx, box) { | 128 | $.ajax($(event.target).parents(".feed").attr("data-unread-items"), { |
169 | $(box).trigger("mark-read"); | 129 | data: { read: 1 }, |
130 | type: "POST" | ||
170 | }); | 131 | }); |
171 | 132 | ||
133 | |||
172 | return false; | 134 | return false; |
173 | }); | 135 | }); |
174 | 136 | ||
@@ -231,22 +193,90 @@ | |||
231 | }); | 193 | }); |
232 | }); | 194 | }); |
233 | </script> | 195 | </script> |
196 | --> | ||
197 | |||
198 | <script type="text/javascript"> | ||
199 | function loadFeedItems(url) { | ||
200 | var feed_item_template = _.template($("#feed_item_template").text()); | ||
201 | $("#feed-items li").remove(); | ||
202 | |||
203 | $.ajax(url).done(function(data) { | ||
204 | _.forEach(data.items, function(item) { | ||
205 | $("#feed-items").append(feed_item_template({ item: item })); | ||
206 | }); | ||
207 | |||
208 | $("#feed-items .title").on("click", function(event) { | ||
209 | var target = $(event.target); | ||
210 | var template = _.template($("#item_contents_template").text()); | ||
211 | |||
212 | $.ajax(target.parents("li").attr("data-item-href")).done(function(data) { | ||
213 | if (data.no_content) { | ||
214 | alert("No content"); | ||
215 | /* | ||
216 | var win = window.open(data.url, "_blank"); | ||
217 | if (!win) { | ||
218 | alert("Popups are blocked"); | ||
219 | } | ||
220 | */ | ||
221 | } else { | ||
222 | target.parent("li").find(".content-area").append(template({ item: data })); | ||
223 | var iframe = $("#" + data.id + "-item-contents"); | ||
224 | |||
225 | iframe.contents().find("html").html(data.content); | ||
226 | iframe.height(iframe.contents().find("html").height()); | ||
227 | } | ||
228 | }); | ||
229 | }); | ||
230 | }); | ||
231 | } | ||
232 | |||
233 | $(document).ready(function() { | ||
234 | var feed_template = _.template($("#feed_template").text()); | ||
235 | |||
236 | $.ajax("/feed/unread").done(function(data) { | ||
237 | _.forEach(data.feeds, function(item) { | ||
238 | $("#feeds").append(feed_template({ item: item })); | ||
239 | }); | ||
240 | |||
241 | $("#feeds li").on("click", function(event) { | ||
242 | var target = $(event.currentTarget); | ||
243 | $("#feeds li").removeClass("active"); | ||
244 | target.addClass("active"); | ||
245 | |||
246 | loadFeedItems(target.attr("data-unread-href")); | ||
247 | }); | ||
248 | }); | ||
249 | |||
250 | $("#minimize-feeds").on("click", function() { | ||
251 | $("#feeds").slideToggle(); | ||
252 | }); | ||
253 | |||
254 | loadFeedItems("/item/unread"); | ||
255 | }); | ||
256 | </script> | ||
234 | </head> | 257 | </head> |
235 | <body> | 258 | <body> |
236 | <h1>RSS Entries</h1> | 259 | <div id="feeds-container"> |
237 | {% for feed, records in items %} | 260 | <div id="minimize-feeds">Feeds</div> |
238 | <div class="feed"> | 261 | <ul id="feeds"> |
239 | <h2>{{ feed }} ({{ records|length }}) <a href="#" class="mark-all-read">Mark All Read</a></h2> | ||
240 | <ul class="entries"> | ||
241 | {% for record in records %} | ||
242 | <li data-url="{{ record.self_link }}" > | ||
243 | <a class="headline" href="{{ record.url }}">{{ record.title }}</a> | ||
244 | <input type="checkbox" class="read" /> | ||
245 | <div class="content"></div> | ||
246 | </li> | ||
247 | {% endfor %} | ||
248 | </ul> | 262 | </ul> |
249 | </div> | 263 | </div> |
250 | {% endfor %} | 264 | <li id="feed-items"></li> |
265 | |||
266 | |||
267 | <script type="text/template" id="feed_template"> | ||
268 | <li data-unread-href="<%= item.unread_items %>"><%= item.title %> (<%= item.unread_count %>)</li> | ||
269 | </script> | ||
270 | |||
271 | <script type="text/template" id="feed_item_template"> | ||
272 | <li data-item-href="<%= item.self %>" <% if (item.no_content) { %>class="no-content"<% } %>> | ||
273 | <input type="checkbox" /><span class="title"><%= item.title %></span> | ||
274 | <div class="content-area"></div> | ||
275 | </li> | ||
276 | </script> | ||
277 | |||
278 | <script type="text/template" id="item_contents_template"> | ||
279 | <iframe id="<%= item.id %>-item-contents" class="content-iframe"></iframe> | ||
280 | </script> | ||
251 | </body> | 281 | </body> |
252 | </html> | 282 | </html> |