1 #!/usr/bin/env python2.5
4 # Copyright (c) 2007-2008 INdT.
5 # Copyright (c) 2011 Neal H. Walfield
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Lesser General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Lesser General Public License for more details.
16 # You should have received a copy of the GNU Lesser General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # ============================================================================
21 __appname__ = 'FeedingIt'
22 __author__ = 'Yves Marcoz'
23 __version__ = '0.9.1~woodchuck'
24 __description__ = 'A simple RSS Reader for Maemo 5'
25 # ============================================================================
28 from pango import FontDescription
33 from webkit import WebView
38 from os.path import isfile, isdir, exists
39 from os import mkdir, remove, stat, environ
41 from aboutdialog import HeAboutDialog
42 from portrait import FremantleRotation
43 from threading import Thread, activeCount
44 from feedingitdbus import ServerObject
45 from config import Config
46 from cgi import escape
51 logger = logging.getLogger(__name__)
53 from rss_sqlite import Listing
54 from opml import GetOpmlData, ExportOpmlData
58 from socket import setdefaulttimeout
60 setdefaulttimeout(timeout)
68 USER_AGENT = 'Mozilla/5.0 (compatible; Maemo 5;) %s %s' % (__appname__, __version__)
69 ABOUT_ICON = 'feedingit'
70 ABOUT_COPYRIGHT = 'Copyright (c) 2010 %s' % __author__
71 ABOUT_WEBSITE = 'http://feedingit.marcoz.org/'
72 ABOUT_BUGTRACKER = 'https://garage.maemo.org/tracker/?group_id=1202'
73 ABOUT_DONATE = None # TODO: Create a donation page + add its URL here
75 color_style = gtk.rc_get_style_by_paths(gtk.settings_get_default() , 'GtkButton', 'osso-logical-colors', gtk.Button)
76 unread_color = color_style.lookup_color('ActiveTextColor')
77 read_color = color_style.lookup_color('DefaultTextColor')
80 CONFIGDIR="/home/user/.feedingit/"
81 LOCK = CONFIGDIR + "update.lock"
84 from htmlentitydefs import name2codepoint
86 COLUMN_ICON, COLUMN_MARKUP, COLUMN_KEY = range(3)
88 FEED_COLUMN_MARKUP, FEED_COLUMN_KEY = range(2)
92 MARKUP_TEMPLATE= '<span font_desc="%s" foreground="%s">%%s</span>'
93 MARKUP_TEMPLATE_ENTRY_UNREAD = '<span font_desc="%s %%s" foreground="%s">%%s</span>'
94 MARKUP_TEMPLATE_ENTRY = '<span font_desc="%s italic %%s" foreground="%s">%%s</span>'
96 # Build the markup template for the Maemo 5 text style
97 head_font = style.get_font_desc('SystemFont')
98 sub_font = style.get_font_desc('SmallSystemFont')
100 #head_color = style.get_color('ButtonTextColor')
101 head_color = style.get_color('DefaultTextColor')
102 sub_color = style.get_color('DefaultTextColor')
103 active_color = style.get_color('ActiveTextColor')
105 bg_color = style.get_color('DefaultBackgroundColor').to_string()
106 c1=hex(min(int(bg_color[1:5],16)+10000, 65535))[2:6]
107 c2=hex(min(int(bg_color[5:9],16)+10000, 65535))[2:6]
108 c3=hex(min(int(bg_color[9:],16)+10000, 65535))[2:6]
109 bg_color = "#" + c1 + c2 + c3
112 head = MARKUP_TEMPLATE % (head_font.to_string(), head_color.to_string())
113 normal_sub = MARKUP_TEMPLATE % (sub_font.to_string(), sub_color.to_string())
115 entry_head = MARKUP_TEMPLATE_ENTRY % (head_font.get_family(), head_color.to_string())
116 entry_normal_sub = MARKUP_TEMPLATE_ENTRY % (sub_font.get_family(), sub_color.to_string())
118 active_head = MARKUP_TEMPLATE % (head_font.to_string(), active_color.to_string())
119 active_sub = MARKUP_TEMPLATE % (sub_font.to_string(), active_color.to_string())
121 entry_active_head = MARKUP_TEMPLATE_ENTRY_UNREAD % (head_font.get_family(), active_color.to_string())
122 entry_active_sub = MARKUP_TEMPLATE_ENTRY_UNREAD % (sub_font.get_family(), active_color.to_string())
124 FEED_TEMPLATE = '\n'.join((head, normal_sub))
125 FEED_TEMPLATE_UNREAD = '\n'.join((head, active_sub))
127 ENTRY_TEMPLATE = entry_head
128 ENTRY_TEMPLATE_UNREAD = entry_active_head
130 notification_iface = None
133 global notification_iface
135 bus = dbus.SessionBus()
136 proxy = bus.get_object('org.freedesktop.Notifications',
137 '/org/freedesktop/Notifications')
139 = dbus.Interface(proxy, 'org.freedesktop.Notifications')
142 notification_iface.SystemNoteInfoprint("FeedingIt: " + message)
144 if notification_iface is None:
149 except dbus.DBusException:
150 # Rebind the name and try again.
155 # Removes HTML or XML character references and entities from a text string.
157 # @param text The HTML (or XML) source text.
158 # @return The plain text, as a Unicode string, if necessary.
159 # http://effbot.org/zone/re-sub.htm#unescape-html
164 # character reference
166 if text[:3] == "&#x":
167 return unichr(int(text[3:-1], 16))
169 return unichr(int(text[2:-1]))
175 text = unichr(name2codepoint[text[1:-1]])
178 return text # leave as is
179 return sub("&#?\w+;", fixup, text)
182 class AddWidgetWizard(gtk.Dialog):
183 def __init__(self, parent, listing, urlIn, categories, titleIn=None, isEdit=False, currentCat=1):
184 gtk.Dialog.__init__(self)
185 self.set_transient_for(parent)
187 #self.category = categories[0]
188 self.category = currentCat
191 self.set_title('Edit RSS feed')
193 self.set_title('Add new RSS feed')
196 self.btn_add = self.add_button('Save', 2)
198 self.btn_add = self.add_button('Add', 2)
200 self.set_default_response(2)
202 self.nameEntry = hildon.Entry(gtk.HILDON_SIZE_AUTO)
203 self.nameEntry.set_placeholder('Feed name')
204 # If titleIn matches urlIn, there is no title.
205 if not titleIn == None and titleIn != urlIn:
206 self.nameEntry.set_text(titleIn)
207 self.nameEntry.select_region(-1, -1)
209 self.urlEntry = hildon.Entry(gtk.HILDON_SIZE_AUTO)
210 self.urlEntry.set_placeholder('Feed URL')
211 self.urlEntry.set_text(urlIn)
212 self.urlEntry.select_region(-1, -1)
213 self.urlEntry.set_activates_default(True)
215 self.table = gtk.Table(3, 2, False)
216 self.table.set_col_spacings(5)
217 label = gtk.Label('Name:')
218 label.set_alignment(1., .5)
219 self.table.attach(label, 0, 1, 0, 1, gtk.FILL)
220 self.table.attach(self.nameEntry, 1, 2, 0, 1)
221 label = gtk.Label('URL:')
222 label.set_alignment(1., .5)
223 self.table.attach(label, 0, 1, 1, 2, gtk.FILL)
224 self.table.attach(self.urlEntry, 1, 2, 1, 2)
225 selector = self.create_selector(categories, listing)
226 picker = hildon.PickerButton(gtk.HILDON_SIZE_FINGER_HEIGHT, hildon.BUTTON_ARRANGEMENT_VERTICAL)
227 picker.set_selector(selector)
228 picker.set_title("Select category")
229 #picker.set_text(listing.getCategoryTitle(self.category), None) #, "Subtitle")
230 picker.set_name('HildonButton-finger')
231 picker.set_alignment(0,0,1,1)
233 self.table.attach(picker, 0, 2, 2, 3, gtk.FILL)
235 self.vbox.pack_start(self.table)
240 return (self.nameEntry.get_text(), self.urlEntry.get_text(), self.category)
242 def create_selector(self, choices, listing):
243 #self.pickerDialog = hildon.PickerDialog(self.parent)
244 selector = hildon.TouchSelector(text=True)
248 title = listing.getCategoryTitle(item)
249 iter = selector.append_text(str(title))
250 if self.category == item:
251 selector.set_active(0, index)
252 self.map[title] = item
254 selector.connect("changed", self.selection_changed)
255 #self.pickerDialog.set_selector(selector)
258 def selection_changed(self, selector, button):
259 current_selection = selector.get_current_text()
260 if current_selection:
261 self.category = self.map[current_selection]
263 class AddCategoryWizard(gtk.Dialog):
264 def __init__(self, parent, titleIn=None, isEdit=False):
265 gtk.Dialog.__init__(self)
266 self.set_transient_for(parent)
269 self.set_title('Edit Category')
271 self.set_title('Add Category')
274 self.btn_add = self.add_button('Save', 2)
276 self.btn_add = self.add_button('Add', 2)
278 self.set_default_response(2)
280 self.nameEntry = hildon.Entry(gtk.HILDON_SIZE_AUTO)
281 self.nameEntry.set_placeholder('Category name')
282 if not titleIn == None:
283 self.nameEntry.set_text(titleIn)
284 self.nameEntry.select_region(-1, -1)
286 self.table = gtk.Table(1, 2, False)
287 self.table.set_col_spacings(5)
288 label = gtk.Label('Name:')
289 label.set_alignment(1., .5)
290 self.table.attach(label, 0, 1, 0, 1, gtk.FILL)
291 self.table.attach(self.nameEntry, 1, 2, 0, 1)
292 #label = gtk.Label('URL:')
293 #label.set_alignment(1., .5)
294 #self.table.attach(label, 0, 1, 1, 2, gtk.FILL)
295 #self.table.attach(self.urlEntry, 1, 2, 1, 2)
296 self.vbox.pack_start(self.table)
301 return self.nameEntry.get_text()
303 class DownloadBar(gtk.ProgressBar):
306 if hasattr (cls, 'class_init_done'):
309 cls.downloadbars = []
310 # Total number of jobs we are monitoring.
312 # Number of jobs complete (of those that we are monitoring).
317 cls.class_init_done = True
319 bus = dbus.SessionBus()
320 bus.add_signal_receiver(handler_function=cls.update_progress,
322 signal_name='UpdateProgress',
323 dbus_interface='org.marcoz.feedingit',
324 path='/org/marcoz/feedingit/update')
326 def __init__(self, parent):
329 gtk.ProgressBar.__init__(self)
331 self.downloadbars.append(weakref.ref (self))
333 self.__class__.update_bars()
336 def downloading(cls):
338 return cls.done != cls.total
341 def update_progress(cls, percent_complete,
342 completed, in_progress, queued,
343 bytes_downloaded, bytes_updated, bytes_per_second,
345 if not cls.downloadbars:
348 cls.total = completed + in_progress + queued
350 cls.progress = percent_complete / 100.
351 if cls.progress < 0: cls.progress = 0
352 if cls.progress > 1: cls.progress = 1
355 for ref in cls.downloadbars:
358 # The download bar disappeared.
359 cls.downloadbars.remove (ref)
361 bar.emit("download-done", feed_updated)
363 if in_progress == 0 and queued == 0:
364 for ref in cls.downloadbars:
367 # The download bar disappeared.
368 cls.downloadbars.remove (ref)
370 bar.emit("download-done", None)
376 def update_bars(cls):
377 # In preparation for i18n/l10n
379 return (a if n == 1 else b)
381 text = (N_('Updated %d of %d feeds ', 'Updated %d of %d feeds',
383 % (cls.done, cls.total))
385 for ref in cls.downloadbars:
388 # The download bar disappeared.
389 cls.downloadbars.remove (ref)
392 bar.set_fraction(cls.progress)
394 class SortList(hildon.StackableWindow):
395 def __init__(self, parent, listing, feedingit, after_closing, category=None):
396 hildon.StackableWindow.__init__(self)
397 self.set_transient_for(parent)
399 self.isEditingCategories = False
400 self.category = category
401 self.set_title(listing.getCategoryTitle(category))
403 self.isEditingCategories = True
404 self.set_title('Categories')
405 self.listing = listing
406 self.feedingit = feedingit
407 self.after_closing = after_closing
409 self.connect('destroy', lambda w: self.after_closing())
410 self.vbox2 = gtk.VBox(False, 2)
412 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
413 button.set_image(gtk.image_new_from_icon_name('keyboard_move_up', gtk.ICON_SIZE_BUTTON))
414 button.connect("clicked", self.buttonUp)
415 self.vbox2.pack_start(button, expand=False, fill=False)
417 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
418 button.set_image(gtk.image_new_from_icon_name('keyboard_move_down', gtk.ICON_SIZE_BUTTON))
419 button.connect("clicked", self.buttonDown)
420 self.vbox2.pack_start(button, expand=False, fill=False)
422 self.vbox2.pack_start(gtk.Label(), expand=True, fill=False)
424 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
425 button.set_image(gtk.image_new_from_icon_name('general_add', gtk.ICON_SIZE_BUTTON))
426 button.connect("clicked", self.buttonAdd)
427 self.vbox2.pack_start(button, expand=False, fill=False)
429 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
430 button.set_image(gtk.image_new_from_icon_name('general_information', gtk.ICON_SIZE_BUTTON))
431 button.connect("clicked", self.buttonEdit)
432 self.vbox2.pack_start(button, expand=False, fill=False)
434 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
435 button.set_image(gtk.image_new_from_icon_name('general_delete', gtk.ICON_SIZE_BUTTON))
436 button.connect("clicked", self.buttonDelete)
437 self.vbox2.pack_start(button, expand=False, fill=False)
439 #button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
440 #button.set_label("Done")
441 #button.connect("clicked", self.buttonDone)
442 #self.vbox.pack_start(button)
443 self.hbox2= gtk.HBox(False, 10)
444 self.pannableArea = hildon.PannableArea()
445 self.treestore = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
446 self.treeview = gtk.TreeView(self.treestore)
447 self.hbox2.pack_start(self.pannableArea, expand=True)
449 self.hbox2.pack_end(self.vbox2, expand=False)
450 self.set_default_size(-1, 600)
453 menu = hildon.AppMenu()
454 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
455 button.set_label("Import from OPML")
456 button.connect("clicked", self.feedingit.button_import_clicked)
459 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
460 button.set_label("Export to OPML")
461 button.connect("clicked", self.feedingit.button_export_clicked)
463 self.set_app_menu(menu)
467 #self.connect("destroy", self.buttonDone)
469 def displayFeeds(self):
470 self.treeview.destroy()
471 self.treestore = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
472 self.treeview = gtk.TreeView()
474 self.treeview.get_selection().set_mode(gtk.SELECTION_SINGLE)
475 hildon.hildon_gtk_tree_view_set_ui_mode(self.treeview, gtk.HILDON_UI_MODE_EDIT)
477 self.treeview.append_column(gtk.TreeViewColumn('Feed Name', gtk.CellRendererText(), text = 0))
479 self.pannableArea.add(self.treeview)
483 def refreshList(self, selected=None, offset=0):
484 #rect = self.treeview.get_visible_rect()
485 #y = rect.y+rect.height
486 self.treestore = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
487 if self.isEditingCategories:
488 for key in self.listing.getListOfCategories():
489 item = self.treestore.append([self.listing.getCategoryTitle(key), key])
493 for key in self.listing.getListOfFeeds(category=self.category):
494 item = self.treestore.append([self.listing.getFeedTitle(key), key])
497 self.treeview.set_model(self.treestore)
498 if not selected == None:
499 self.treeview.get_selection().select_iter(selectedItem)
500 self.treeview.scroll_to_cell(self.treeview.get_model().get_path(selectedItem))
501 self.pannableArea.show_all()
503 def getSelectedItem(self):
504 (model, iter) = self.treeview.get_selection().get_selected()
507 return model.get_value(iter, 1)
509 def findIndex(self, key):
513 for row in self.treestore:
515 return (before, row.iter)
516 if key == list(row)[0]:
520 return (before, None)
522 def buttonUp(self, button):
523 key = self.getSelectedItem()
525 if self.isEditingCategories:
526 self.listing.moveCategoryUp(key)
528 self.listing.moveUp(key)
529 self.refreshList(key, -10)
531 def buttonDown(self, button):
532 key = self.getSelectedItem()
534 if self.isEditingCategories:
535 self.listing.moveCategoryDown(key)
537 self.listing.moveDown(key)
538 self.refreshList(key, 10)
540 def buttonDelete(self, button):
541 key = self.getSelectedItem()
543 message = 'Really remove this feed and its entries?'
544 dlg = hildon.hildon_note_new_confirmation(self, message)
547 if response == gtk.RESPONSE_OK:
548 if self.isEditingCategories:
549 self.listing.removeCategory(key)
551 self.listing.removeFeed(key)
554 def buttonEdit(self, button):
555 key = self.getSelectedItem()
557 if key == 'ArchivedArticles':
558 message = 'Cannot edit the archived articles feed.'
559 hildon.hildon_banner_show_information(self, '', message)
561 if self.isEditingCategories:
563 SortList(self.parent, self.listing, self.feedingit, None, category=key)
566 wizard = AddWidgetWizard(self, self.listing, self.listing.getFeedUrl(key), self.listing.getListOfCategories(), self.listing.getFeedTitle(key), True, currentCat=self.category)
569 (title, url, category) = wizard.getData()
571 self.listing.editFeed(key, title, url, category=category)
575 def buttonDone(self, *args):
578 def buttonAdd(self, button, urlIn="http://"):
579 if self.isEditingCategories:
580 wizard = AddCategoryWizard(self)
583 title = wizard.getData()
584 if (not title == ''):
585 self.listing.addCategory(title)
587 wizard = AddWidgetWizard(self, self.listing, urlIn, self.listing.getListOfCategories())
590 (title, url, category) = wizard.getData()
592 self.listing.addFeed(title, url, category=category)
597 class DisplayArticle(hildon.StackableWindow):
599 A Widget for displaying an article.
601 def __init__(self, article_id, feed, feed_key, articles, config, listing):
603 article_id - The identifier of the article to load.
605 feed - The feed object containing the article (an
606 rss_sqlite:Feed object).
608 feed_key - The feed's identifier.
610 articles - A list of articles from the feed to display.
611 Needed for selecting the next/previous article (article_next).
613 config - A configuration object (config:Config).
615 listing - The listing object (rss_sqlite:Listing) that
616 contains the feed and article.
618 hildon.StackableWindow.__init__(self)
620 self.article_id = None
622 self.feed_key = feed_key
623 self.articles = articles
625 self.listing = listing
627 self.set_title(self.listing.getFeedTitle(feed_key))
629 # Init the article display
630 self.view = WebView()
631 self.view.set_zoom_level(float(config.getArtFontSize())/10.)
632 self.view.connect("motion-notify-event", lambda w,ev: True)
633 self.view.connect('load-started', self.load_started)
634 self.view.connect('load-finished', self.load_finished)
635 self.view.connect('navigation-requested', self.navigation_requested)
636 self.view.connect("button_press_event", self.button_pressed)
637 self.gestureId = self.view.connect(
638 "button_release_event", self.button_released)
640 self.pannable_article = hildon.PannableArea()
641 self.pannable_article.add(self.view)
643 self.add(self.pannable_article)
645 self.pannable_article.show_all()
648 menu = hildon.AppMenu()
650 def menu_button(label, callback):
651 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
652 button.set_label(label)
653 button.connect("clicked", callback)
656 menu_button("Allow horizontal scrolling", self.horiz_scrolling_button)
657 menu_button("Open in browser", self.open_in_browser)
658 if feed_key == "ArchivedArticles":
660 "Remove from archived articles", self.remove_archive_button)
662 menu_button("Add to archived articles", self.archive_button)
664 self.set_app_menu(menu)
667 self.destroyId = self.connect("destroy", self.destroyWindow)
669 self.article_open(article_id)
671 def article_open(self, article_id):
673 Load the article with the specified id.
675 # If an article was open, close it.
676 if self.article_id is not None:
677 self.article_closed()
679 self.article_id = article_id
680 self.set_for_removal = False
681 self.loadedArticle = False
682 self.initial_article_load = True
684 contentLink = self.feed.getContentLink(self.article_id)
685 if contentLink.startswith("/home/user/"):
686 self.view.open("file://%s" % contentLink)
687 self.currentUrl = self.feed.getExternalLink(self.article_id)
689 self.view.load_html_string('This article has not been downloaded yet. Click <a href="%s">here</a> to view online.' % contentLink, contentLink)
690 self.currentUrl = str(contentLink)
692 self.feed.setEntryRead(self.article_id)
694 def article_closed(self):
696 The user has navigated away from the article. Execute any
699 if self.set_for_removal:
700 self.emit("article-deleted", self.article_id)
702 self.emit("article-closed", self.article_id)
705 def navigation_requested(self, wv, fr, req):
707 http://webkitgtk.org/reference/webkitgtk-webkitwebview.html#WebKitWebView-navigation-requested
710 fr - a WebKitWebFrame
711 req - WebKitNetworkRequest
713 if self.initial_article_load:
714 # Always initially load an article in the internal
716 self.initial_article_load = False
719 # When following a link, only use the internal browser if so
720 # configured. Otherwise, launch an external browser.
721 if self.config.getOpenInExternalBrowser():
722 self.open_in_browser(None, req.get_uri())
727 def load_started(self, *widget):
728 hildon.hildon_gtk_window_set_progress_indicator(self, 1)
730 def load_finished(self, *widget):
731 hildon.hildon_gtk_window_set_progress_indicator(self, 0)
732 frame = self.view.get_main_frame()
733 if self.loadedArticle:
734 self.currentUrl = frame.get_uri()
736 self.loadedArticle = True
738 def button_pressed(self, window, event):
740 The user pressed a "mouse button" (in our case, this means the
741 user likely started to drag with the finger).
743 We are only interested in whether the user performs a drag.
744 We record the starting position and when the user "releases
745 the button," we see how far the mouse moved.
747 self.coords = (event.x, event.y)
749 def button_released(self, window, event):
750 x = self.coords[0] - event.x
751 y = self.coords[1] - event.y
753 if (2*abs(y) < abs(x)):
755 self.article_next(forward=False)
757 self.article_next(forward=True)
759 # We handled the event. Don't propagate it further.
762 def article_next(self, forward=True):
764 Advance to the next (or, if forward is false, the previous)
772 id = self.feed.getNextId(id, forward)
780 if id in self.articles:
781 self.article_open(id)
784 def destroyWindow(self, *args):
785 self.article_closed()
786 self.disconnect(self.destroyId)
789 def horiz_scrolling_button(self, *widget):
790 self.pannable_article.disconnect(self.gestureId)
791 self.pannable_article.set_property("mov-mode", hildon.MOVEMENT_MODE_BOTH)
793 def archive_button(self, *widget):
794 # Call the listing.addArchivedArticle
795 self.listing.addArchivedArticle(self.feed_key, self.article_id)
797 def remove_archive_button(self, *widget):
798 self.set_for_removal = True
800 def open_in_browser(self, object, link=None):
802 Open the specified link using the system's browser. If not
803 link is specified, reopen the current page using the system's
807 link = self.currentUrl
809 bus = dbus.SessionBus()
810 b_proxy = bus.get_object("com.nokia.osso_browser",
811 "/com/nokia/osso_browser/request")
812 b_iface = dbus.Interface(b_proxy, 'com.nokia.osso_browser')
814 notify("Opening %s" % link)
816 # We open the link asynchronously: if the web browser is not
817 # already running, this can take a while.
820 Something went wrong opening the URL.
823 notify("Error opening %s: %s" % (link, str(exception)))
826 b_iface.open_new_window(link,
827 reply_handler=lambda *args: None,
828 error_handler=error_handler())
830 class DisplayFeed(hildon.StackableWindow):
831 def __init__(self, listing, config):
832 hildon.StackableWindow.__init__(self)
833 self.connect('configure-event', self.on_configure_event)
834 self.connect("delete_event", self.delete_window)
835 self.connect("destroy", self.destroyWindow)
837 self.listing = listing
842 # If hide read articles is set, this is set to the set of
843 # unread articles at the time that feed is loaded. The last
844 # bit is important: when the user selects the next article,
845 # but then decides to move back, previous should select the
847 self.articles = list()
849 self.downloadDialog = False
851 #self.listing.setCurrentlyDisplayedFeed(self.key)
855 menu = hildon.AppMenu()
856 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
857 button.set_label("Update feed")
858 button.connect("clicked", self.button_update_clicked)
861 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
862 button.set_label("Mark all as read")
863 button.connect("clicked", self.buttonReadAllClicked)
866 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
867 button.set_label("Delete read articles")
868 button.connect("clicked", self.buttonPurgeArticles)
870 self.archived_article_buttons = [button]
872 self.set_app_menu(menu)
875 self.feedItems = gtk.ListStore(str, str)
877 self.feedList = hildon.GtkTreeView(gtk.HILDON_UI_MODE_NORMAL)
878 self.feedList.set_model(self.feedItems)
879 self.feedList.set_rules_hint(True)
880 self.feedList.set_grid_lines(gtk.TREE_VIEW_GRID_LINES_HORIZONTAL)
881 self.feedList.set_hover_selection(False)
882 self.feedList.connect('hildon-row-tapped',
883 self.on_feedList_row_activated)
885 self.markup_renderer = gtk.CellRendererText()
886 self.markup_renderer.set_property('wrap-mode', pango.WRAP_WORD_CHAR)
887 self.markup_renderer.set_property('background', bg_color) #"#333333")
888 (width, height) = self.get_size()
889 self.markup_renderer.set_property('wrap-width', width-20)
890 self.markup_renderer.set_property('ypad', 8)
891 self.markup_renderer.set_property('xpad', 5)
892 markup_column = gtk.TreeViewColumn('', self.markup_renderer, \
893 markup=FEED_COLUMN_MARKUP)
894 self.feedList.append_column(markup_column)
896 vbox = gtk.VBox(False, 10)
897 vbox.pack_start(self.feedList)
899 self.pannableFeed = hildon.PannableArea()
900 self.pannableFeed.set_property('hscrollbar-policy', gtk.POLICY_NEVER)
901 self.pannableFeed.add_with_viewport(vbox)
903 self.main_vbox = gtk.VBox(False, 0)
904 self.main_vbox.pack_start(self.pannableFeed)
906 self.add(self.main_vbox)
908 self.main_vbox.show_all()
910 def on_configure_event(self, window, event):
911 if getattr(self, 'markup_renderer', None) is None:
914 # Fix up the column width for wrapping the text when the window is
915 # resized (i.e. orientation changed)
916 self.markup_renderer.set_property('wrap-width', event.width-20)
917 it = self.feedItems.get_iter_first()
918 while it is not None:
919 markup = self.feedItems.get_value(it, FEED_COLUMN_MARKUP)
920 self.feedItems.set_value(it, FEED_COLUMN_MARKUP, markup)
921 it = self.feedItems.iter_next(it)
923 def delete_window(self, *args):
925 Prevent the window from being deleted.
931 except AttributeError:
935 self.listing.updateUnread(key)
936 self.emit("feed-closed", key)
938 self.feedItems.clear()
942 def destroyWindow(self, *args):
945 except AttributeError:
950 def fix_title(self, title):
951 return escape(unescape(title).replace("<em>","").replace("</em>","").replace("<nobr>","").replace("</nobr>","").replace("<wbr>",""))
953 def displayFeed(self, key=None):
955 Select and display a feed. If feed, title and key are None,
956 reloads the current feed.
961 except AttributeError:
965 self.listing.updateUnread(self.key)
966 self.emit("feed-closed", self.key)
968 self.feed = self.listing.getFeed(key)
969 self.feedTitle = self.listing.getFeedTitle(key)
972 self.set_title(self.feedTitle)
974 selection = self.feedList.get_selection()
975 if selection is not None:
976 selection.set_mode(gtk.SELECTION_NONE)
978 for b in self.archived_article_buttons:
979 if key == "ArchivedArticles":
984 #self.pannableFeed.set_property("mov-mode", hildon.MOVEMENT_MODE_BOTH)
985 hideReadArticles = self.config.getHideReadArticles()
987 articles = self.feed.getIds(onlyUnread=True)
989 articles = self.feed.getIds()
991 self.articles[:] = []
993 self.feedItems.clear()
996 isRead = self.feed.isEntryRead(id)
999 if not ( isRead and hideReadArticles ):
1000 title = self.fix_title(self.feed.getTitle(id))
1001 self.articles.append(id)
1003 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), title)
1005 markup = ENTRY_TEMPLATE_UNREAD % (self.config.getFontSize(), title)
1007 self.feedItems.append((markup, id))
1010 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), "No Articles To Display")
1011 self.feedItems.append((markup, ""))
1016 self.pannableFeed.destroy()
1017 #self.remove(self.pannableFeed)
1019 def on_feedList_row_activated(self, treeview, path): #, column):
1020 if not self.articles:
1021 # There are not actually any articles. Ignore.
1024 selection = self.feedList.get_selection()
1025 selection.set_mode(gtk.SELECTION_SINGLE)
1026 self.feedList.get_selection().select_path(path)
1027 model = treeview.get_model()
1028 iter = model.get_iter(path)
1029 key = model.get_value(iter, FEED_COLUMN_KEY)
1030 # Emulate legacy "button_clicked" call via treeview
1031 gobject.idle_add(self.button_clicked, treeview, key)
1034 def button_clicked(self, button, index, previous=False, next=False):
1035 newDisp = DisplayArticle(index, self.feed, self.key, self.articles, self.config, self.listing)
1036 stack = hildon.WindowStack.get_default()
1039 stack.pop_and_push(1, newDisp, tmp)
1041 gobject.timeout_add(200, self.destroyArticle, tmp)
1046 if type(self.disp).__name__ == "DisplayArticle":
1047 gobject.timeout_add(200, self.destroyArticle, self.disp)
1051 self.disp.show_all()
1054 if self.key == "ArchivedArticles":
1055 self.ids.append(self.disp.connect("article-deleted", self.onArticleDeleted))
1056 self.ids.append(self.disp.connect("article-closed", self.onArticleClosed))
1058 def buttonPurgeArticles(self, *widget):
1060 self.feed.purgeReadArticles()
1061 #self.feed.saveFeed(CONFIGDIR)
1064 def destroyArticle(self, handle):
1065 handle.destroyWindow()
1067 def mark_item_read(self, key):
1068 it = self.feedItems.get_iter_first()
1069 while it is not None:
1070 k = self.feedItems.get_value(it, FEED_COLUMN_KEY)
1072 title = self.fix_title(self.feed.getTitle(key))
1073 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), title)
1074 self.feedItems.set_value(it, FEED_COLUMN_MARKUP, markup)
1076 it = self.feedItems.iter_next(it)
1078 def onArticleClosed(self, object, index):
1079 selection = self.feedList.get_selection()
1080 selection.set_mode(gtk.SELECTION_NONE)
1081 self.mark_item_read(index)
1083 def onArticleDeleted(self, object, index):
1085 self.feed.removeArticle(index)
1086 #self.feed.saveFeed(CONFIGDIR)
1090 def do_update_feed(self):
1091 self.listing.updateFeed (self.key, priority=-1)
1093 def button_update_clicked(self, button):
1094 gobject.idle_add(self.do_update_feed)
1096 def show_download_bar(self):
1097 if not type(self.downloadDialog).__name__=="DownloadBar":
1098 self.downloadDialog = DownloadBar(self.window)
1099 self.downloadDialog.connect("download-done", self.onDownloadDone)
1100 self.main_vbox.pack_end(self.downloadDialog,
1101 expand=False, fill=False)
1102 self.downloadDialog.show()
1104 def onDownloadDone(self, widget, feed):
1105 if feed is not None and hasattr(self, 'feed') and feed == self.feed:
1106 self.feed = self.listing.getFeed(self.key)
1110 self.downloadDialog.destroy()
1111 self.downloadDialog = False
1113 def buttonReadAllClicked(self, button):
1115 self.feed.markAllAsRead()
1116 it = self.feedItems.get_iter_first()
1117 while it is not None:
1118 k = self.feedItems.get_value(it, FEED_COLUMN_KEY)
1119 title = self.fix_title(self.feed.getTitle(k))
1120 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), title)
1121 self.feedItems.set_value(it, FEED_COLUMN_MARKUP, markup)
1122 it = self.feedItems.iter_next(it)
1124 #for index in self.feed.getIds():
1125 # self.feed.setEntryRead(index)
1126 # self.mark_item_read(index)
1132 self.window = hildon.StackableWindow()
1133 hildon.hildon_gtk_window_set_progress_indicator(self.window, 1)
1135 self.config = Config(self.window, CONFIGDIR+"config.ini")
1138 self.orientation = FremantleRotation(__appname__, main_window=self.window, app=self)
1139 self.orientation.set_mode(self.config.getOrientation())
1140 except Exception, e:
1141 logger.warn("Could not start rotation manager: %s" % str(e))
1143 self.window.set_title(__appname__)
1144 self.mainVbox = gtk.VBox(False,10)
1146 if isfile(CONFIGDIR+"/feeds.db"):
1147 self.introLabel = gtk.Label("Loading...")
1149 self.introLabel = gtk.Label("Updating database to new format...\nThis can take several minutes.")
1151 self.mainVbox.pack_start(self.introLabel)
1153 self.window.add(self.mainVbox)
1154 self.window.show_all()
1155 gobject.idle_add(self.createWindow)
1157 def createWindow(self):
1159 self.listing = Listing(self.config, CONFIGDIR)
1161 self.downloadDialog = False
1163 self.introLabel.destroy()
1164 self.pannableListing = hildon.PannableArea()
1166 # The main area is a view consisting of an icon and two
1167 # strings. The view is bound to the Listing's database via a
1170 self.feedItems = gtk.TreeStore(gtk.gdk.Pixbuf, str, str)
1171 self.feedList = gtk.TreeView(self.feedItems)
1172 self.feedList.connect('row-activated', self.on_feedList_row_activated)
1174 self.pannableListing.add(self.feedList)
1176 icon_renderer = gtk.CellRendererPixbuf()
1177 icon_renderer.set_property('width', LIST_ICON_SIZE + 2*LIST_ICON_BORDER)
1178 icon_column = gtk.TreeViewColumn('', icon_renderer, \
1180 self.feedList.append_column(icon_column)
1182 markup_renderer = gtk.CellRendererText()
1183 markup_column = gtk.TreeViewColumn('', markup_renderer, \
1184 markup=COLUMN_MARKUP)
1185 self.feedList.append_column(markup_column)
1186 self.mainVbox.pack_start(self.pannableListing)
1187 self.mainVbox.show_all()
1189 self.displayListing()
1191 hildon.hildon_gtk_window_set_progress_indicator(self.window, 0)
1192 gobject.idle_add(self.late_init)
1194 def update_progress(self, percent_complete,
1195 completed, in_progress, queued,
1196 bytes_downloaded, bytes_updated, bytes_per_second,
1198 if (in_progress or queued) and not self.downloadDialog:
1199 self.downloadDialog = DownloadBar(self.window)
1200 self.downloadDialog.connect("download-done", self.onDownloadDone)
1201 self.mainVbox.pack_end(self.downloadDialog, expand=False, fill=False)
1202 self.downloadDialog.show()
1204 if self.__dict__.get ('disp', None):
1205 self.disp.show_download_bar ()
1207 def onDownloadDone(self, widget, feed):
1209 self.downloadDialog.destroy()
1210 self.downloadDialog = False
1211 self.displayListing()
1213 def late_init(self):
1214 # Finish building the GUI.
1215 menu = hildon.AppMenu()
1216 # Create a button and add it to the menu
1217 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1218 button.set_label("Update feeds")
1219 button.connect("clicked", self.button_update_clicked, "All")
1222 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1223 button.set_label("Mark all as read")
1224 button.connect("clicked", self.button_markAll)
1227 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1228 button.set_label("Add new feed")
1229 button.connect("clicked", lambda b: self.addFeed())
1232 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1233 button.set_label("Manage subscriptions")
1234 button.connect("clicked", self.button_organize_clicked)
1237 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1238 button.set_label("Settings")
1239 button.connect("clicked", self.button_preferences_clicked)
1242 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1243 button.set_label("About")
1244 button.connect("clicked", self.button_about_clicked)
1247 self.window.set_app_menu(menu)
1250 # Initialize the DBus interface.
1251 self.dbusHandler = ServerObject(self)
1252 bus = dbus.SessionBus()
1253 bus.add_signal_receiver(handler_function=self.update_progress,
1255 signal_name='UpdateProgress',
1256 dbus_interface='org.marcoz.feedingit',
1257 path='/org/marcoz/feedingit/update')
1259 # Check whether auto-update is enabled.
1260 self.autoupdate = False
1261 self.checkAutoUpdate()
1263 gobject.idle_add(self.build_feed_display)
1265 def build_feed_display(self):
1266 if not hasattr(self, 'disp'):
1267 self.disp = DisplayFeed(self.listing, self.config)
1268 self.disp.connect("feed-closed", self.onFeedClosed)
1270 def button_markAll(self, button):
1271 for key in self.listing.getListOfFeeds():
1272 feed = self.listing.getFeed(key)
1273 feed.markAllAsRead()
1274 #for id in feed.getIds():
1275 # feed.setEntryRead(id)
1276 self.listing.updateUnread(key)
1277 self.displayListing()
1279 def button_about_clicked(self, button):
1280 HeAboutDialog.present(self.window, \
1290 def button_export_clicked(self, button):
1291 opml = ExportOpmlData(self.window, self.listing)
1293 def button_import_clicked(self, button):
1294 opml = GetOpmlData(self.window)
1295 feeds = opml.getData()
1296 for (title, url) in feeds:
1297 self.listing.addFeed(title, url)
1298 self.displayListing()
1300 def addFeed(self, urlIn="http://"):
1301 wizard = AddWidgetWizard(self.window, self.listing, urlIn, self.listing.getListOfCategories())
1304 (title, url, category) = wizard.getData()
1306 self.listing.addFeed(title, url, category=category)
1308 self.displayListing()
1310 def button_organize_clicked(self, button):
1311 def after_closing():
1312 self.displayListing()
1313 SortList(self.window, self.listing, self, after_closing)
1315 def do_update_feeds(self):
1316 for k in self.listing.getListOfFeeds():
1317 self.listing.updateFeed (k)
1319 def button_update_clicked(self, button, key):
1320 gobject.idle_add(self.do_update_feeds)
1322 def onDownloadsDone(self, *widget):
1323 self.downloadDialog.destroy()
1324 self.downloadDialog = False
1325 self.displayListing()
1327 def button_preferences_clicked(self, button):
1328 dialog = self.config.createDialog()
1329 dialog.connect("destroy", self.prefsClosed)
1331 def show_confirmation_note(self, parent, title):
1332 note = hildon.Note("confirmation", parent, "Are you sure you want to delete " + title +"?")
1334 retcode = gtk.Dialog.run(note)
1337 if retcode == gtk.RESPONSE_OK:
1342 def saveExpandedLines(self):
1343 self.expandedLines = []
1344 model = self.feedList.get_model()
1345 model.foreach(self.checkLine)
1347 def checkLine(self, model, path, iter, data = None):
1348 if self.feedList.row_expanded(path):
1349 self.expandedLines.append(path)
1351 def restoreExpandedLines(self):
1352 model = self.feedList.get_model()
1353 model.foreach(self.restoreLine)
1355 def restoreLine(self, model, path, iter, data = None):
1356 if path in self.expandedLines:
1357 self.feedList.expand_row(path, False)
1359 def displayListing(self):
1360 icon_theme = gtk.icon_theme_get_default()
1361 default_pixbuf = icon_theme.load_icon(ABOUT_ICON, LIST_ICON_SIZE, \
1362 gtk.ICON_LOOKUP_USE_BUILTIN)
1364 self.saveExpandedLines()
1366 self.feedItems.clear()
1367 hideReadFeed = self.config.getHideReadFeeds()
1368 order = self.config.getFeedSortOrder()
1370 categories = self.listing.getListOfCategories()
1371 if len(categories) > 1:
1372 showCategories = True
1374 showCategories = False
1376 for categoryId in categories:
1378 title = self.listing.getCategoryTitle(categoryId)
1379 keys = self.listing.getSortedListOfKeys(order, onlyUnread=hideReadFeed, category=categoryId)
1381 if showCategories and len(keys)>0:
1382 category = self.feedItems.append(None, (None, title, categoryId))
1383 #print "catID" + str(categoryId) + " " + str(self.category)
1384 if categoryId == self.category:
1386 expandedRow = category
1389 unreadItems = self.listing.getFeedNumberOfUnreadItems(key)
1390 title = xml.sax.saxutils.escape(self.listing.getFeedTitle(key))
1391 updateTime = self.listing.getFeedUpdateTime(key)
1392 subtitle = '%s / %d unread items' % (updateTime, unreadItems)
1394 markup = FEED_TEMPLATE_UNREAD % (title, subtitle)
1396 markup = FEED_TEMPLATE % (title, subtitle)
1399 icon_filename = self.listing.getFavicon(key)
1400 pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(icon_filename, \
1401 LIST_ICON_SIZE, LIST_ICON_SIZE)
1403 pixbuf = default_pixbuf
1406 self.feedItems.append(category, (pixbuf, markup, key))
1408 self.feedItems.append(None, (pixbuf, markup, key))
1411 self.restoreExpandedLines()
1414 # self.feedList.expand_row(self.feeItems.get_path(expandedRow), True)
1418 def on_feedList_row_activated(self, treeview, path, column):
1419 model = treeview.get_model()
1420 iter = model.get_iter(path)
1421 key = model.get_value(iter, COLUMN_KEY)
1424 #print "Key: " + str(key)
1426 self.category = catId
1427 if treeview.row_expanded(path):
1428 treeview.collapse_row(path)
1430 # treeview.expand_row(path, True)
1431 #treeview.collapse_all()
1432 #treeview.expand_row(path, False)
1433 #for i in range(len(path)):
1434 # self.feedList.expand_row(path[:i+1], False)
1435 #self.show_confirmation_note(self.window, "Working")
1441 def openFeed(self, key):
1443 self.build_feed_display()
1444 self.disp.displayFeed(key)
1446 def openArticle(self, key, id):
1449 self.disp.button_clicked(None, id)
1451 def onFeedClosed(self, object, key):
1452 gobject.idle_add(self.displayListing)
1454 def quit(self, *args):
1459 self.window.connect("destroy", self.quit)
1462 def prefsClosed(self, *widget):
1464 self.orientation.set_mode(self.config.getOrientation())
1467 self.displayListing()
1468 self.checkAutoUpdate()
1470 def checkAutoUpdate(self, *widget):
1471 interval = int(self.config.getUpdateInterval()*3600000)
1472 if self.config.isAutoUpdateEnabled():
1473 if self.autoupdate == False:
1474 self.autoupdateId = gobject.timeout_add(interval, self.automaticUpdate)
1475 self.autoupdate = interval
1476 elif not self.autoupdate == interval:
1477 # If auto-update is enabled, but not at the right frequency
1478 gobject.source_remove(self.autoupdateId)
1479 self.autoupdateId = gobject.timeout_add(interval, self.automaticUpdate)
1480 self.autoupdate = interval
1482 if not self.autoupdate == False:
1483 gobject.source_remove(self.autoupdateId)
1484 self.autoupdate = False
1486 def automaticUpdate(self, *widget):
1487 # Need to check for internet connection
1488 # If no internet connection, try again in 10 minutes:
1489 # gobject.timeout_add(int(5*3600000), self.automaticUpdate)
1490 #file = open("/home/user/.feedingit/feedingit_widget.log", "a")
1491 #from time import localtime, strftime
1492 #file.write("App: %s\n" % strftime("%a, %d %b %Y %H:%M:%S +0000", localtime()))
1494 self.button_update_clicked(None, None)
1497 def getStatus(self):
1499 for key in self.listing.getListOfFeeds():
1500 if self.listing.getFeedNumberOfUnreadItems(key) > 0:
1501 status += self.listing.getFeedTitle(key) + ": \t" + str(self.listing.getFeedNumberOfUnreadItems(key)) + " Unread Items\n"
1503 status = "No unread items"
1506 def grabFocus(self):
1507 self.window.present()
1509 if __name__ == "__main__":
1511 debugging.init(dot_directory=".feedingit", program_name="feedingit")
1513 gobject.signal_new("feed-closed", DisplayFeed, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1514 gobject.signal_new("article-closed", DisplayArticle, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1515 gobject.signal_new("article-deleted", DisplayArticle, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1516 gobject.signal_new("download-done", DownloadBar, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1517 gobject.threads_init()
1518 if not isdir(CONFIGDIR):
1522 logger.error("Error: Can't create configuration directory")
1523 from sys import exit