diff options
author | Mike Crute <mcrute@gmail.com> | 2014-06-04 00:50:25 -0700 |
---|---|---|
committer | Mike Crute <mcrute@gmail.com> | 2014-06-04 00:50:44 -0700 |
commit | ee2972cc3b82edb60c77abddda5b0ff23c9c9797 (patch) | |
tree | c2c94bdac19b3ff4c52eaae30892974a636cc4dd | |
download | web_rss_reader-ee2972cc3b82edb60c77abddda5b0ff23c9c9797.tar.bz2 web_rss_reader-ee2972cc3b82edb60c77abddda5b0ff23c9c9797.tar.xz web_rss_reader-ee2972cc3b82edb60c77abddda5b0ff23c9c9797.zip |
Initial pass
-rwxr-xr-x | app.py | 209 | ||||
-rw-r--r-- | requirements.txt | 1 | ||||
-rw-r--r-- | templates/items.html | 183 |
3 files changed, 393 insertions, 0 deletions
@@ -0,0 +1,209 @@ | |||
1 | #!/usr/bin/env python3 | ||
2 | |||
3 | import sqlite3 | ||
4 | import base64 | ||
5 | from copy import copy | ||
6 | from datetime import datetime | ||
7 | from collections import defaultdict | ||
8 | |||
9 | from flask import Flask, render_template, jsonify, request, make_response | ||
10 | |||
11 | app = Flask(__name__) | ||
12 | |||
13 | |||
14 | class DataObject(object): | ||
15 | |||
16 | @classmethod | ||
17 | def from_db_row(cls, cursor, row): | ||
18 | return cls(*row) | ||
19 | |||
20 | def _update_json(self, data): | ||
21 | return | ||
22 | |||
23 | def to_json(self): | ||
24 | data = copy(self.__dict__) | ||
25 | self._update_json(data) | ||
26 | data['self'] = self.self_link | ||
27 | return data | ||
28 | |||
29 | def __repr__(self): | ||
30 | return '{self.__class__.__name__}({self.id!r}, {self.title!r})'.format( | ||
31 | self=self) | ||
32 | |||
33 | @property | ||
34 | def self_link(self): | ||
35 | return self.URL.format(self=self) | ||
36 | |||
37 | |||
38 | class RSSItem(DataObject): | ||
39 | |||
40 | URL = '/item/{self.id}' | ||
41 | |||
42 | def __init__(self, id, title, author, feed_title, feed_url, url, | ||
43 | publish_date, content, read): | ||
44 | self.id = id | ||
45 | self.feed_url = RSSFeed(feed_url, None, None).self_link | ||
46 | self.feed_title = feed_title | ||
47 | self.title = title | ||
48 | self.author = author | ||
49 | self.url = url | ||
50 | self.publish_date = datetime.fromtimestamp(publish_date) | ||
51 | self.content = content | ||
52 | self.read = not bool(read) | ||
53 | self.no_content = len(content) < 100 | ||
54 | |||
55 | |||
56 | class RSSFeed(DataObject): | ||
57 | |||
58 | URL = '/feed/{self.id}' | ||
59 | |||
60 | def __init__(self, feed_url, url, title): | ||
61 | self.feed_url = feed_url | ||
62 | self.url = url | ||
63 | self.title = title | ||
64 | |||
65 | def _update_json(self, data): | ||
66 | data['items'] = '/feed/{}/items'.format(self.id) | ||
67 | |||
68 | @property | ||
69 | def id(self): | ||
70 | return base64.b64encode( | ||
71 | self.feed_url.encode('utf-8')).decode('utf-8').strip('=') | ||
72 | |||
73 | @staticmethod | ||
74 | def parse_token(token): | ||
75 | return base64.b64decode( | ||
76 | '{}========'.format(token).encode('utf-8')).decode('utf-8') | ||
77 | |||
78 | |||
79 | |||
80 | class DBReader(object): | ||
81 | |||
82 | BASE_QUERY = ''' | ||
83 | SELECT | ||
84 | i.id, i.title, i.author, f.title, | ||
85 | f.rssurl, i.url, i.pubDate, i.content, | ||
86 | i.unread | ||
87 | FROM | ||
88 | rss_item i | ||
89 | INNER JOIN rss_feed f | ||
90 | ON f.rssurl = i.feedurl | ||
91 | {where} | ||
92 | {where_cond} | ||
93 | ORDER BY | ||
94 | i.pubDate desc | ||
95 | ''' | ||
96 | |||
97 | def __init__(self, db_path): | ||
98 | self.db_path = db_path | ||
99 | |||
100 | def _get_connection(self): | ||
101 | conn = sqlite3.connect(self.db_path) | ||
102 | conn.row_factory = RSSItem.from_db_row | ||
103 | return conn | ||
104 | |||
105 | def _fetch(self, where=None, params=()): | ||
106 | with self._get_connection() as con: | ||
107 | curs = con.cursor() | ||
108 | args = { 'where': '', 'where_cond': '' } | ||
109 | |||
110 | if where: | ||
111 | args.update({ 'where': 'WHERE', 'where_cond': where }) | ||
112 | |||
113 | curs.execute(self.BASE_QUERY.format(**args), params) | ||
114 | return curs.fetchall() | ||
115 | |||
116 | def update_unread(self, id, unread=True): | ||
117 | with self._get_connection() as con: | ||
118 | con.execute( | ||
119 | 'UPDATE rss_item SET unread = ? WHERE id = ?', | ||
120 | [1 if unread else 0, id]) | ||
121 | con.commit() | ||
122 | |||
123 | def get_entry(self, id): | ||
124 | return self._fetch("i.id = ?", [id])[0] | ||
125 | |||
126 | def get_unread(self): | ||
127 | data = defaultdict(list) | ||
128 | unread = self._fetch("i.unread = ?", [1]) | ||
129 | |||
130 | for record in unread: | ||
131 | data[record.feed_title].append(record) | ||
132 | |||
133 | return sorted(data.items()) | ||
134 | |||
135 | def get_unread_for_feed(self, token, only_unread=False): | ||
136 | if only_unread: | ||
137 | return self._fetch("i.unread = ? AND i.feedurl = ?", [1, RSSFeed.parse_token(token)]) | ||
138 | else: | ||
139 | return self._fetch("i.feedurl = ?", [RSSFeed.parse_token(token)]) | ||
140 | |||
141 | def get_feeds(self): | ||
142 | with self._get_connection() as con: | ||
143 | con.row_factory = RSSFeed.from_db_row | ||
144 | curs = con.cursor() | ||
145 | curs.execute('SELECT rssurl, url, title FROM rss_feed') | ||
146 | return curs.fetchall() | ||
147 | |||
148 | def get_feed(self, token): | ||
149 | with self._get_connection() as con: | ||
150 | con.row_factory = RSSFeed.from_db_row | ||
151 | curs = con.cursor() | ||
152 | curs.execute('SELECT rssurl, url, title FROM rss_feed WHERE rssurl = ?', [RSSFeed.parse_token(token)]) | ||
153 | return curs.fetchall()[0] | ||
154 | |||
155 | |||
156 | def json_list(data): | ||
157 | return [i.to_json() for i in data] | ||
158 | |||
159 | |||
160 | @app.route('/') | ||
161 | def index(): | ||
162 | reader = DBReader('../../cache.db') | ||
163 | return render_template('items.html', items=reader.get_unread()) | ||
164 | |||
165 | |||
166 | @app.route('/feed/') | ||
167 | def feed_list(): | ||
168 | reader = DBReader('../../cache.db') | ||
169 | return jsonify({ 'feeds': json_list(reader.get_feeds()) }) | ||
170 | |||
171 | |||
172 | @app.route('/feed/<token>') | ||
173 | def feed(token): | ||
174 | reader = DBReader('../../cache.db') | ||
175 | return jsonify(reader.get_feed(token).to_json()) | ||
176 | |||
177 | |||
178 | @app.route('/feed/<token>/items') | ||
179 | def feed_items(token): | ||
180 | reader = DBReader('../../cache.db') | ||
181 | return jsonify({ 'items': json_list(reader.get_unread_for_feed(token)) }) | ||
182 | |||
183 | |||
184 | @app.route('/feed/<token>/items/unread') | ||
185 | def unread_feed_items(token): | ||
186 | reader = DBReader('../../cache.db') | ||
187 | return jsonify({ 'items': json_list(reader.get_unread_for_feed(token, True)) }) | ||
188 | |||
189 | |||
190 | @app.route("/item/<int:entry_id>", methods=["GET", "POST"]) | ||
191 | def item(entry_id): | ||
192 | #post read=1 | ||
193 | reader = DBReader('../../cache.db') | ||
194 | |||
195 | if request.method == 'POST': | ||
196 | try: | ||
197 | read = bool(int(request.form.get('read'))) | ||
198 | except: | ||
199 | return make_response('', 400) | ||
200 | |||
201 | reader.update_unread(entry_id, not read) | ||
202 | return make_response('', 204) | ||
203 | else: | ||
204 | return jsonify(reader.get_entry(entry_id).to_json()) | ||
205 | |||
206 | |||
207 | if __name__ == '__main__': | ||
208 | app.debug = True | ||
209 | app.run() | ||
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..632a1ef --- /dev/null +++ b/requirements.txt | |||
@@ -0,0 +1 @@ | |||
Flask==0.10.1 | |||
diff --git a/templates/items.html b/templates/items.html new file mode 100644 index 0000000..093b211 --- /dev/null +++ b/templates/items.html | |||
@@ -0,0 +1,183 @@ | |||
1 | <!doctype html> | ||
2 | <html class="no-js" lang=""> | ||
3 | <head> | ||
4 | <title>RSS Reader</title> | ||
5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
6 | <style type="text/css"> | ||
7 | body { | ||
8 | font: 12px Georgia,serif; | ||
9 | } | ||
10 | |||
11 | .entries li { | ||
12 | position: relative; | ||
13 | list-style: none; | ||
14 | border: 1px solid #999; | ||
15 | margin: 0.5em 0; | ||
16 | } | ||
17 | |||
18 | .entries { | ||
19 | margin: 0; | ||
20 | padding: 0; | ||
21 | } | ||
22 | |||
23 | .entries a.headline { | ||
24 | display: block; | ||
25 | background: #ddd; | ||
26 | padding: 1em; | ||
27 | color: black; | ||
28 | text-decoration: none; | ||
29 | font-weight: bolder; | ||
30 | } | ||
31 | |||
32 | .entries .highlighted a.headline { | ||
33 | background-color: #FFFF99; | ||
34 | } | ||
35 | |||
36 | .entries li > input.read { | ||
37 | position: absolute; | ||
38 | top: 1em; | ||
39 | right: 1em; | ||
40 | } | ||
41 | |||
42 | .entries .content { | ||
43 | padding: 1em; | ||
44 | display: none; | ||
45 | } | ||
46 | |||
47 | h2 a.mark-all-read { | ||
48 | display: inline-block; | ||
49 | position: absolute; | ||
50 | right: 1em; | ||
51 | } | ||
52 | </style> | ||
53 | |||
54 | <script src="//code.jquery.com/jquery-2.1.1.min.js"></script> | ||
55 | |||
56 | <script type="text/javascript"> | ||
57 | /** | ||
58 | * Copyright (c) 2007-2014 Ariel Flesler - aflesler<a>gmail<d>com | http://flesler.blogspot.com | ||
59 | * Licensed under MIT | ||
60 | * @author Ariel Flesler | ||
61 | * @version 1.4.12 | ||
62 | */ | ||
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 | |||
66 | <script type="text/javascript"> | ||
67 | $(document).ready(function() { | ||
68 | $("a.headline").on("click", function(event) { | ||
69 | event.preventDefault(); | ||
70 | var target = $(event.target); | ||
71 | target.off("click"); | ||
72 | $.ajax(target.parents("li").attr("data-url")).done(function(data) { | ||
73 | if (data.no_content) { | ||
74 | var win = window.open(data.url, "_blank"); | ||
75 | if (!win) { | ||
76 | alert("Popups are blocked"); | ||
77 | } | ||
78 | } else { | ||
79 | var contentArea = target.siblings("div.content"); | ||
80 | contentArea.append(data.content); | ||
81 | contentArea.show(); | ||
82 | } | ||
83 | target.parents("li").attr("data-target-url", data.url); | ||
84 | target.siblings("input[type=checkbox]").attr("checked", true); | ||
85 | target.siblings("input[type=checkbox]").trigger("change"); | ||
86 | }); | ||
87 | return false; | ||
88 | }); | ||
89 | |||
90 | $("a.headline").on("mark-read", function(event) { | ||
91 | var box = $(event.target).next("input[type=checkbox]"); | ||
92 | box.attr("checked", !box.is(":checked")); | ||
93 | box.trigger("change"); | ||
94 | }); | ||
95 | |||
96 | $(".entries li > input[type=checkbox]").on("change", function(event) { | ||
97 | var value = $(event.target).is(':checked') ? 1 : 0; | ||
98 | $.ajax($(event.target).parents("li").attr("data-url"), { | ||
99 | data: { read: value }, | ||
100 | type: "POST" | ||
101 | }); | ||
102 | }); | ||
103 | |||
104 | $("h2 a.mark-all-read").on("click", function(event) { | ||
105 | event.preventDefault(); | ||
106 | |||
107 | $(event.target).parents(".feed").find("a.headline").each(function(idx, box) { | ||
108 | $(box).trigger("mark-read"); | ||
109 | }); | ||
110 | |||
111 | return false; | ||
112 | }); | ||
113 | |||
114 | $(document).on("keydown", function(event) { | ||
115 | var element = $(".entries .highlighted"); | ||
116 | |||
117 | switch (event.keyCode) { | ||
118 | case 74: // J | ||
119 | element.removeClass("highlighted"); | ||
120 | |||
121 | if (element.length == 0) { | ||
122 | $(".entries li:first-child").first().addClass("highlighted"); | ||
123 | } else { | ||
124 | var next = element.next("li"); | ||
125 | if (next.length != 1) { | ||
126 | element.parents(".feed").next(".feed").find("li").first().addClass("highlighted"); | ||
127 | } else { | ||
128 | next.addClass("highlighted"); | ||
129 | } | ||
130 | } | ||
131 | |||
132 | $.scrollTo(".highlighted", { margin: true, offset: -50 }); | ||
133 | break; | ||
134 | case 75: // K | ||
135 | element.removeClass("highlighted"); | ||
136 | |||
137 | if (element.length == 0) { | ||
138 | $(".entries li:first-child").first().addClass("highlighted"); | ||
139 | } else { | ||
140 | var prev = element.prev("li"); | ||
141 | if (prev.length != 1) { | ||
142 | element.parents(".feed").prev(".feed").find("li").last().addClass("highlighted"); | ||
143 | } else { | ||
144 | prev.addClass("highlighted"); | ||
145 | } | ||
146 | } | ||
147 | |||
148 | $.scrollTo(".highlighted", { margin: true, offset: -50 }); | ||
149 | break; | ||
150 | case 79: // O | ||
151 | var target = $(element).attr("data-target-url"); | ||
152 | if (target) { | ||
153 | window.open(target, "_blank"); | ||
154 | } else { | ||
155 | element.find("a.headline").trigger("click"); | ||
156 | } | ||
157 | break; | ||
158 | case 82: // R | ||
159 | element.find("a.headline").trigger("mark-read"); | ||
160 | break; | ||
161 | } | ||
162 | }); | ||
163 | }); | ||
164 | </script> | ||
165 | </head> | ||
166 | <body> | ||
167 | <h1>RSS Entries</h1> | ||
168 | {% for feed, records in items %} | ||
169 | <div class="feed"> | ||
170 | <h2>{{ feed }} <a href="#" class="mark-all-read">Mark All Read</a></h2> | ||
171 | <ul class="entries"> | ||
172 | {% for record in records %} | ||
173 | <li data-url="{{ record.self_link }}" > | ||
174 | <a class="headline" href="{{ record.url }}">{{ record.title }}</a> | ||
175 | <input type="checkbox" class="read" /> | ||
176 | <div class="content"></div> | ||
177 | </li> | ||
178 | {% endfor %} | ||
179 | </ul> | ||
180 | </div> | ||
181 | {% endfor %} | ||
182 | </body> | ||
183 | </html> | ||