summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Crute <mcrute@gmail.com>2014-06-04 00:50:25 -0700
committerMike Crute <mcrute@gmail.com>2014-06-04 00:50:44 -0700
commitee2972cc3b82edb60c77abddda5b0ff23c9c9797 (patch)
treec2c94bdac19b3ff4c52eaae30892974a636cc4dd
downloadweb_rss_reader-ee2972cc3b82edb60c77abddda5b0ff23c9c9797.tar.bz2
web_rss_reader-ee2972cc3b82edb60c77abddda5b0ff23c9c9797.tar.xz
web_rss_reader-ee2972cc3b82edb60c77abddda5b0ff23c9c9797.zip
Initial pass
-rwxr-xr-xapp.py209
-rw-r--r--requirements.txt1
-rw-r--r--templates/items.html183
3 files changed, 393 insertions, 0 deletions
diff --git a/app.py b/app.py
new file mode 100755
index 0000000..755d55c
--- /dev/null
+++ b/app.py
@@ -0,0 +1,209 @@
1#!/usr/bin/env python3
2
3import sqlite3
4import base64
5from copy import copy
6from datetime import datetime
7from collections import defaultdict
8
9from flask import Flask, render_template, jsonify, request, make_response
10
11app = Flask(__name__)
12
13
14class 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
38class 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
56class 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
80class 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
156def json_list(data):
157 return [i.to_json() for i in data]
158
159
160@app.route('/')
161def index():
162 reader = DBReader('../../cache.db')
163 return render_template('items.html', items=reader.get_unread())
164
165
166@app.route('/feed/')
167def feed_list():
168 reader = DBReader('../../cache.db')
169 return jsonify({ 'feeds': json_list(reader.get_feeds()) })
170
171
172@app.route('/feed/<token>')
173def feed(token):
174 reader = DBReader('../../cache.db')
175 return jsonify(reader.get_feed(token).to_json())
176
177
178@app.route('/feed/<token>/items')
179def 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')
185def 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"])
191def 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
207if __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>