Modified storage engine to use sqlite3
[feedingit] / src / rss_sqlite.py
1 #!/usr/bin/env python2.5
2
3
4 # Copyright (c) 2007-2008 INdT.
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Lesser General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 #  This program is distributed in the hope that it will be useful,
11 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #  GNU Lesser General Public License for more details.
14 #
15 #  You should have received a copy of the GNU Lesser General Public License
16 #  along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 #
18
19 # ============================================================================
20 # Name        : FeedingIt.py
21 # Author      : Yves Marcoz
22 # Version     : 0.5.4
23 # Description : Simple RSS Reader
24 # ============================================================================
25
26 import sqlite3
27 from os.path import isfile, isdir
28 from shutil import rmtree
29 from os import mkdir, remove, utime
30 import md5
31 import feedparser
32 import time
33 import urllib2
34 from BeautifulSoup import BeautifulSoup
35 from urlparse import urljoin
36
37 def getId(string):
38     return md5.new(string).hexdigest()
39
40 class Feed:
41     def __init__(self, configdir, key):
42         self.key = key
43         self.configdir = configdir
44         self.dir = "%s/%s.d" %(self.configdir, self.key)
45         if not isdir(self.dir):
46             mkdir(self.dir)
47         if not isfile("%s/%s.db" %(self.dir, self.key)):
48             self.db = sqlite3.connect("%s/%s.db" %(self.dir, self.key) )
49             self.db.execute("CREATE TABLE feed (id text, title text, contentLink text, date float, updated float, link text, read int);")
50             self.db.execute("CREATE TABLE images (id text, imagePath text);")
51             self.db.commit()
52         else:
53             self.db = sqlite3.connect("%s/%s.db" %(self.dir, self.key) )
54
55     def addImage(self, configdir, key, baseurl, url):
56         filename = configdir+key+".d/"+getId(url)
57         if not isfile(filename):
58             try:
59                 f = urllib2.urlopen(urljoin(baseurl,url))
60                 outf = open(filename, "w")
61                 outf.write(f.read())
62                 f.close()
63                 outf.close()
64             except:
65                 print "Could not download " + url
66         else:
67             #open(filename,"a").close()  # "Touch" the file
68             file = open(filename,"a")
69             utime(filename, None)
70             file.close()
71         return filename
72
73     def updateFeed(self, configdir, url, etag, modified, expiryTime=24, proxy=None, imageCache=False):
74         # Expiry time is in hours
75         if proxy == None:
76             tmp=feedparser.parse(url, etag = etag, modified = modified)
77         else:
78             tmp=feedparser.parse(url, etag = etag, modified = modified, handlers = [proxy])
79         expiry = float(expiryTime) * 3600.
80
81         currentTime = time.time()
82         # Check if the parse was succesful (number of entries > 0, else do nothing)
83         if len(tmp["entries"])>0:
84            # The etag and modified value should only be updated if the content was not null
85            try:
86                etag = tmp["etag"]
87            except KeyError:
88                etag = None
89            try:
90                modified = tmp["modified"]
91            except KeyError:
92                modified = None
93            try:
94                f = urllib2.urlopen(urljoin(tmp["feed"]["link"],"/favicon.ico"))
95                data = f.read()
96                f.close()
97                outf = open(self.dir+"/favicon.ico", "w")
98                outf.write(data)
99                outf.close()
100                del data
101            except:
102                #import traceback
103                #traceback.print_exc()
104                 pass
105
106
107            #reversedEntries = self.getEntries()
108            #reversedEntries.reverse()
109
110            ids = self.getIds()
111
112            tmp["entries"].reverse()
113            for entry in tmp["entries"]:
114                date = self.extractDate(entry)
115                try:
116                    entry["title"]
117                except:
118                    entry["title"] = "No Title"
119                try:
120                    entry["link"]
121                except:
122                    entry["link"] = ""
123                tmpEntry = {"title":entry["title"], "content":self.extractContent(entry),
124                             "date":date, "link":entry["link"]}
125                id = self.generateUniqueId(tmpEntry)
126                
127                #articleTime = time.mktime(self.entries[id]["dateTuple"])
128                if not id in ids:
129                    soup = BeautifulSoup(self.getArticle(tmpEntry)) #tmpEntry["content"])
130                    images = soup('img')
131                    baseurl = tmpEntry["link"]
132                    if imageCache:
133                       for img in images:
134                           try:
135                             filename = self.addImage(configdir, self.key, baseurl, img['src'])
136                             img['src']=filename
137                             self.db.execute("INSERT INTO images (id, imagePath) VALUES (?, ?);", (id, filename) )
138                             self.db.commit()
139                           except:
140                               import traceback
141                               traceback.print_exc()
142                               print "Error downloading image %s" % img
143                    tmpEntry["contentLink"] = configdir+self.key+".d/"+id+".html"
144                    file = open(tmpEntry["contentLink"], "w")
145                    file.write(soup.prettify())
146                    file.close()
147                    values = (id, tmpEntry["title"], tmpEntry["contentLink"], tmpEntry["date"], currentTime, tmpEntry["link"], 0)
148                    self.db.execute("INSERT INTO feed (id, title, contentLink, date, updated, link, read) VALUES (?, ?, ?, ?, ?, ?, ?);", values)
149                    self.db.commit() 
150                else:
151                    try:
152                        filename = configdir+self.key+".d/"+id+".html"
153                        file = open(filename,"a")
154                        utime(filename, None)
155                        file.close()
156                        images = self.db.execute("SELECT imagePath FROM images where id=?;", (id, )).fetchall()
157                        for image in images:
158                             file = open(image[0],"a")
159                             utime(image[0], None)
160                             file.close()
161                    except:
162                        pass
163             
164            
165            rows = self.db.execute("SELECT id FROM feed WHERE (read=0 AND updated<?) OR (read=1 AND updated<?);", (2*expiry, expiry))
166            for row in rows:
167                self.removeEntry(row[0])
168             
169            from glob import glob
170            from os import stat
171            for file in glob(configdir+self.key+".d/*"):
172                 #
173                 stats = stat(file)
174                 #
175                 # put the two dates into matching format
176                 #
177                 lastmodDate = stats[8]
178                 #
179                 expDate = time.time()-expiry*3
180                 # check if image-last-modified-date is outdated
181                 #
182                 if expDate > lastmodDate:
183                     #
184                     try:
185                         #
186                         #print 'Removing', file
187                         #
188                         remove(file) # commented out for testing
189                         #
190                     except OSError:
191                         #
192                         print 'Could not remove', file
193         return (currentTime, etag, modified)
194     
195     def setEntryRead(self, id):
196         self.db.execute("UPDATE feed SET read=1 WHERE id=?;", (id,) )
197         self.db.commit()
198         
199     def setEntryUnread(self, id):
200         self.db.execute("UPDATE feed SET read=0 WHERE id=?;", (id,) )
201         self.db.commit()     
202
203     def isEntryRead(self, id):
204         read_status = self.db.execute("SELECT read FROM feed WHERE id=?;", (id,) ).fetchone()[0]
205         return read_status==1  # Returns True if read==1, and False if read==0
206     
207     def getTitle(self, id):
208         return self.db.execute("SELECT title FROM feed WHERE id=?;", (id,) ).fetchone()[0]
209     
210     def getContentLink(self, id):
211         return self.db.execute("SELECT contentLink FROM feed WHERE id=?;", (id,) ).fetchone()[0]
212     
213     def getExternalLink(self, id):
214         return self.db.execute("SELECT link FROM feed WHERE id=?;", (id,) ).fetchone()[0]
215     
216     def getDate(self, id):
217         dateStamp = self.db.execute("SELECT date FROM feed WHERE id=?;", (id,) ).fetchone()[0]
218         return time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime(dateStamp))
219
220     def getDateTuple(self, id):
221         dateStamp = self.db.execute("SELECT date FROM feed WHERE id=?;", (id,) ).fetchone()[0]
222         return time.localtime(dateStamp)
223     
224     def getDateStamp(self, id):
225         return self.db.execute("SELECT date FROM feed WHERE id=?;", (id,) ).fetchone()[0]
226     
227     def generateUniqueId(self, entry):
228         return getId(str(entry["date"]) + str(entry["title"]))
229     
230     def getIds(self):
231         rows = self.db.execute("SELECT id FROM feed;").fetchall()
232         ids = []
233         for row in rows:
234             ids.append(row[0])
235         ids.reverse()
236         return ids
237     
238     def getNextId(self, id):
239         ids = self.getIds()
240         index = ids.index(id)
241         return ids[(index+1)%len(ids)]
242         
243     def getPreviousId(self, id):
244         ids = self.getIds()
245         index = ids.index(id)
246         return ids[(index-1)%len(ids)]
247     
248     def getNumberOfUnreadItems(self):
249         return self.db.execute("SELECT count(*) FROM feed WHERE read=0;").fetchone()[0]
250     
251     def getNumberOfEntries(self):
252         return self.db.execute("SELECT count(*) FROM feed;").fetchone()[0]
253
254     def getArticle(self, entry):
255         #self.setEntryRead(id)
256         #entry = self.entries[id]
257         title = entry['title']
258         #content = entry.get('content', entry.get('summary_detail', {}))
259         content = entry["content"]
260
261         link = entry['link']
262         date = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime(entry["date"]) )
263
264         #text = '''<div style="color: black; background-color: white;">'''
265         text = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
266         text += "<html><head><title>" + title + "</title>"
267         text += '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>\n'
268         #text += '<style> body {-webkit-user-select: none;} </style>'
269         text += '</head><body><div><a href=\"' + link + '\">' + title + "</a>"
270         text += "<BR /><small><i>Date: " + date + "</i></small></div>"
271         text += "<BR /><BR />"
272         text += content
273         text += "</body></html>"
274         return text
275    
276     def getContent(self, id):
277         contentLink = self.db.execute("SELECT contentLink FROM feed WHERE id=?;", (id,)).fetchone()[0]
278         try:
279             file = open(self.entries[id]["contentLink"])
280             content = file.read()
281             file.close()
282         except:
283             content = "Content unavailable"
284         return content
285     
286     def extractDate(self, entry):
287         if entry.has_key("updated_parsed"):
288             return time.mktime(entry["updated_parsed"])
289         elif entry.has_key("published_parsed"):
290             return time.mktime(entry["published_parsed"])
291         else:
292             return 0
293         
294     def extractContent(self, entry):
295         content = ""
296         if entry.has_key('summary'):
297             content = entry.get('summary', '')
298         if entry.has_key('content'):
299             if len(entry.content[0].value) > len(content):
300                 content = entry.content[0].value
301         if content == "":
302             content = entry.get('description', '')
303         return content
304     
305     def removeEntry(self, id):
306         contentLink = self.db.execute("SELECT contentLink FROM feed WHERE id=?;", (id,)).fetchone()[0]
307         if contentLink:
308             try:
309                 os.remove(contentLink)
310             except:
311                 print "File not found for deletion: %s" % contentLink
312         self.db.execute("DELETE FROM feed WHERE id=?;", (id,) )
313         self.db.execute("DELETE FROM images WHERE id=?;", (id,) )
314         self.db.commit()
315  
316 class ArchivedArticles(Feed):    
317     def addArchivedArticle(self, title, link, date, configdir):
318         id = self.generateUniqueId({"date":date, "title":title})
319         values = (id, title, None, date, 0, link, 0)
320         self.db.execute("INSERT INTO feed (id, title, contentLink, date, updated, link, read) VALUES (?, ?, ?, ?, ?, ?, ?);", values)
321         self.db.commit()
322
323     def updateFeed(self, configdir, url, etag, modified, expiryTime=24, proxy=None, imageCache=False):
324         currentTime = time.time()
325         rows = self.db.execute("SELECT id, link FROM feed WHERE updated=0;")
326         for row in rows:
327             id = row[0]
328             link = row[1]
329             f = urllib2.urlopen(link)
330             #entry["content"] = f.read()
331             html = f.read()
332             f.close()
333             soup = BeautifulSoup(html)
334             images = soup('img')
335             baseurl = link
336             for img in images:
337                 filename = self.addImage(configdir, self.key, baseurl, img['src'])
338                 img['src']=filename
339             contentLink = configdir+self.key+".d/"+id+".html"
340             file = open(contentLink, "w")
341             file.write(soup.prettify())
342             file.close()
343             
344             self.db.execute("UPDATE feed SET read=0, contentLink=?, updated=? WHERE id=?;", (contentLink, time.time(), id) )
345             self.db.commit()
346         return (currentTime, None, None)
347     
348     def purgeReadArticles(self):
349         rows = self.db.execute("SELECT id FROM feed WHERE read=0;")
350         ids = self.getIds()
351         for row in rows:
352             self.removeEntry(row[0])
353                 
354     def removeArticle(self, id):
355         self.removeEntry(id)
356
357 class Listing:
358     # Lists all the feeds in a dictionary, and expose the data
359     def __init__(self, configdir):
360         self.configdir = configdir
361         
362         self.db = sqlite3.connect("%s/feeds.db" % self.configdir)
363         
364         try:
365             self.db.execute("create table feeds(id text, url text, title text, unread int, updateTime float, rank int, etag text, modified text);")
366             if isfile(self.configdir+"feeds.pickle"):
367                 self.importOldFormatFeeds()
368             else:
369                 self.addFeed("Maemo News", "http://maemo.org/news/items.xml")
370         except:
371             # Table already created
372             pass
373         
374     def importOldFormatFeeds(self):
375         """This function loads feeds that are saved in an outdated format, and converts them to sqlite"""
376         import rss
377         listing = rss.Listing(self.configdir)
378         rank = 0
379         for id in listing.getListOfFeeds():
380             try:
381                 rank += 1
382                 values = (id, listing.getFeedTitle(id) , listing.getFeedUrl(id), 0, time.time(), rank, None, "None")
383                 self.db.execute("INSERT INTO feeds (id, title, url, unread, updateTime, rank, etag, modified) VALUES (?, ?, ? ,? ,? ,?, ?, ?);", values)
384                 self.db.commit()
385                 
386                 feed = listing.getFeed(id)
387                 new_feed = self.getFeed(id)
388                 
389                 items = feed.getIds()[:]
390                 items.reverse()
391                 for item in items:
392                         if feed.isEntryRead(item):
393                             read_status = 1
394                         else:
395                             read_status = 0 
396                         values = (item, feed.getTitle(item), feed.getContentLink(item), time.time(), time.time(), feed.getExternalLink(item), read_status)
397                         new_feed.db.execute("INSERT INTO feed (id, title, contentLink, date, updated, link, read) VALUES (?, ?, ?, ?, ?, ?, ?);", values)
398                         new_feed.db.commit()
399                         images = feed.getImages(item)
400                         for image in images:
401                             new_feed.db.execute("INSERT INTO images (id, imagePath) VALUES (?, ?);", (item, image) )
402                             new_feed.db.commit()
403                 self.updateUnread(id)
404             except:
405                 import traceback
406                 traceback.print_exc()
407         remove(self.configdir+"feeds.pickle")
408                 
409         
410     def addArchivedArticle(self, key, index):
411         feed = self.getFeed(key)
412         title = feed.getTitle(index)
413         link = feed.getExternalLink(index)
414         date = feed.getDate(index)
415         count = self.db.execute("SELECT count(*) FROM feeds where id=?;", ("ArchivedArticles",) ).fetchone()[0]
416         if count == 0:
417             self.addFeed("Archived Articles", "", id="ArchivedArticles")
418
419         archFeed = self.getFeed("ArchivedArticles")
420         archFeed.addArchivedArticle(title, link, date, self.configdir)
421         self.updateUnread("ArchivedArticles")
422             
423     def updateFeed(self, key, expiryTime=24, proxy=None, imageCache=False):
424         feed = self.getFeed(key)
425         db = sqlite3.connect("%s/feeds.db" % self.configdir)
426         (url, etag, modified) = db.execute("SELECT url, etag, modified FROM feeds WHERE id=?;", (key,) ).fetchone()
427         (updateTime, etag, modified) = feed.updateFeed(self.configdir, url, etag, eval(modified), expiryTime, proxy, imageCache)
428         db.execute("UPDATE feeds SET updateTime=?, etag=?, modified=? WHERE id=?;", (updateTime, etag, str(modified), key) )
429         db.commit()
430         
431     def getFeed(self, key):
432         if key == "ArchivedArticles":
433             return ArchivedArticles(self.configdir, key)
434         return Feed(self.configdir, key)
435         
436     def editFeed(self, key, title, url):
437         self.db.execute("UPDATE feeds SET title=?, url=? WHERE id=?;", (title, url, key))
438         self.db.commit()
439         
440     def getFeedUpdateTime(self, key):
441         return time.ctime(self.db.execute("SELECT updateTime FROM feeds WHERE id=?;", (key,)).fetchone()[0])
442         
443     def getFeedNumberOfUnreadItems(self, key):
444         return self.db.execute("SELECT unread FROM feeds WHERE id=?;", (key,)).fetchone()[0]
445         
446     def getFeedTitle(self, key):
447         return self.db.execute("SELECT title FROM feeds WHERE id=?;", (key,)).fetchone()[0]
448         
449     def getFeedUrl(self, key):
450         return self.db.execute("SELECT url FROM feeds WHERE id=?;", (key,)).fetchone()[0]
451         
452     def getListOfFeeds(self):
453         rows = self.db.execute("SELECT id FROM feeds ORDER BY rank;" )
454         keys = []
455         for row in rows:
456             if row[0]:
457                 keys.append(row[0])
458         return keys
459     
460     def getSortedListOfKeys(self, order):
461         if   order == "Most unread":
462             tmp = "ORDER BY unread"
463             #keyorder = sorted(feedInfo, key = lambda k: feedInfo[k][1], reverse=True)
464         elif order == "Least unread":
465             tmp = "ORDER BY unread DESC"
466             #keyorder = sorted(feedInfo, key = lambda k: feedInfo[k][1])
467         elif order == "Most recent":
468             tmp = "ORDER BY updateTime"
469             #keyorder = sorted(feedInfo, key = lambda k: feedInfo[k][2], reverse=True)
470         elif order == "Least recent":
471             tmp = "ORDER BY updateTime DESC"
472             #keyorder = sorted(feedInfo, key = lambda k: feedInfo[k][2])
473         else: # order == "Manual" or invalid value...
474             tmp = "ORDER BY rank"
475             #keyorder = sorted(feedInfo, key = lambda k: feedInfo[k][0])
476         sql = "SELECT id FROM feeds " + tmp
477         rows = self.db.execute(sql)
478         keys = []
479         for row in rows:
480             if row[0]:
481                 keys.append(row[0])
482         return keys
483     
484     def getFavicon(self, key):
485         filename = "%s%s.d/favicon.ico" % (self.configdir, key)
486         if isfile(filename):
487             return filename
488         else:
489             return False
490         
491     def updateUnread(self, key):
492         feed = self.getFeed(key)
493         self.db.execute("UPDATE feeds SET unread=? WHERE id=?;", (feed.getNumberOfUnreadItems(), key))
494         self.db.commit()
495
496     def addFeed(self, title, url, id=None):
497         if not id:
498             id = getId(title)
499         count = self.db.execute("SELECT count(*) FROM feeds WHERE id=?;", (id,) ).fetchone()[0]
500         if count == 0:
501             max_rank = self.db.execute("SELECT MAX(rank) FROM feeds;").fetchone()[0]
502             if max_rank == None:
503                 max_rank = 0
504             values = (id, title, url, 0, 0, max_rank+1, None, "None")
505             self.db.execute("INSERT INTO feeds (id, title, url, unread, updateTime, rank, etag, modified) VALUES (?, ?, ? ,? ,? ,?, ?, ?);", values)
506             self.db.commit()
507             # Ask for the feed object, it will create the necessary tables
508             self.getFeed(id)
509             return True
510         else:
511             return False
512     
513     def removeFeed(self, key):
514         rank = self.db.execute("SELECT rank FROM feeds WHERE id=?;", (key,) ).fetchone()[0]
515         self.db.execute("DELETE FROM feeds WHERE id=?;", (key, ))
516         self.db.execute("UPDATE feeds SET rank=rank-1 WHERE rank>?;", (rank,) )
517         self.db.commit()
518
519         if isdir(self.configdir+key+".d/"):
520            rmtree(self.configdir+key+".d/")
521         #self.saveConfig()
522         
523     #def saveConfig(self):
524     #    self.listOfFeeds["feedingit-order"] = self.sortedKeys
525     #    file = open(self.configdir+"feeds.pickle", "w")
526     #    pickle.dump(self.listOfFeeds, file)
527     #    file.close()
528         
529     def moveUp(self, key):
530         rank = self.db.execute("SELECT rank FROM feeds WHERE id=?;", (key,)).fetchone()[0]
531         if rank>0:
532             self.db.execute("UPDATE feeds SET rank=? WHERE rank=?;", (rank, rank-1) )
533             self.db.execute("UPDATE feeds SET rank=? WHERE id=?;", (rank-1, key) )
534             self.db.commit()
535         
536     def moveDown(self, key):
537         rank = self.db.execute("SELECT rank FROM feeds WHERE id=?;", (key,)).fetchone()[0]
538         max_rank = self.db.execute("SELECT MAX(rank) FROM feeds;").fetchone()[0]
539         if rank<max_rank:
540             self.db.execute("UPDATE feeds SET rank=? WHERE rank=?;", (rank, rank+1) )
541             self.db.execute("UPDATE feeds SET rank=? WHERE id=?;", (rank+1, key) )
542             self.db.commit()
543             
544             
545