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 # ============================================================================
32 from webkit import WebView
37 from os.path import isfile, isdir, exists
38 from os import mkdir, remove, stat, environ
40 from aboutdialog import HeAboutDialog
41 from portrait import FremantleRotation
42 from feedingitdbus import ServerObject
43 from config import Config
44 from cgi import escape
49 logger = logging.getLogger(__name__)
51 from rss_sqlite import Listing
52 from opml import GetOpmlData, ExportOpmlData
56 from socket import setdefaulttimeout
58 setdefaulttimeout(timeout)
66 USER_AGENT = 'Mozilla/5.0 (compatible; Maemo 5;) %s %s' % (__appname__, __version__)
67 ABOUT_ICON = 'feedingit'
68 ABOUT_COPYRIGHT = 'Copyright (c) 2010 %s' % __author__
69 ABOUT_WEBSITE = 'http://feedingit.marcoz.org/'
70 ABOUT_BUGTRACKER = 'https://garage.maemo.org/tracker/?group_id=1202'
71 ABOUT_DONATE = None # TODO: Create a donation page + add its URL here
73 color_style = gtk.rc_get_style_by_paths(gtk.settings_get_default() , 'GtkButton', 'osso-logical-colors', gtk.Button)
74 unread_color = color_style.lookup_color('ActiveTextColor')
75 read_color = color_style.lookup_color('DefaultTextColor')
78 CONFIGDIR="/home/user/.feedingit/"
79 LOCK = CONFIGDIR + "update.lock"
82 from htmlentitydefs import name2codepoint
84 COLUMN_ICON, COLUMN_MARKUP, COLUMN_KEY = range(3)
86 FEED_COLUMN_MARKUP, FEED_COLUMN_KEY = range(2)
90 MARKUP_TEMPLATE= '<span font_desc="%s" foreground="%s">%%s</span>'
91 MARKUP_TEMPLATE_ENTRY_UNREAD = '<span font_desc="%s %%s" foreground="%s">%%s</span>'
92 MARKUP_TEMPLATE_ENTRY = '<span font_desc="%s italic %%s" foreground="%s">%%s</span>'
94 # Build the markup template for the Maemo 5 text style
95 head_font = style.get_font_desc('SystemFont')
96 sub_font = style.get_font_desc('SmallSystemFont')
98 #head_color = style.get_color('ButtonTextColor')
99 head_color = style.get_color('DefaultTextColor')
100 sub_color = style.get_color('DefaultTextColor')
101 active_color = style.get_color('ActiveTextColor')
103 bg_color = style.get_color('DefaultBackgroundColor').to_string()
104 c1=hex(min(int(bg_color[1:5],16)+10000, 65535))[2:6]
105 c2=hex(min(int(bg_color[5:9],16)+10000, 65535))[2:6]
106 c3=hex(min(int(bg_color[9:],16)+10000, 65535))[2:6]
107 bg_color = "#" + c1 + c2 + c3
110 head = MARKUP_TEMPLATE % (head_font.to_string(), head_color.to_string())
111 normal_sub = MARKUP_TEMPLATE % (sub_font.to_string(), sub_color.to_string())
113 entry_head = MARKUP_TEMPLATE_ENTRY % (head_font.get_family(), head_color.to_string())
114 entry_normal_sub = MARKUP_TEMPLATE_ENTRY % (sub_font.get_family(), sub_color.to_string())
116 active_head = MARKUP_TEMPLATE % (head_font.to_string(), active_color.to_string())
117 active_sub = MARKUP_TEMPLATE % (sub_font.to_string(), active_color.to_string())
119 entry_active_head = MARKUP_TEMPLATE_ENTRY_UNREAD % (head_font.get_family(), active_color.to_string())
120 entry_active_sub = MARKUP_TEMPLATE_ENTRY_UNREAD % (sub_font.get_family(), active_color.to_string())
122 FEED_TEMPLATE = '\n'.join((head, normal_sub))
123 FEED_TEMPLATE_UNREAD = '\n'.join((head, active_sub))
125 ENTRY_TEMPLATE = entry_head
126 ENTRY_TEMPLATE_UNREAD = entry_active_head
128 notification_iface = None
131 global notification_iface
133 bus = dbus.SessionBus()
134 proxy = bus.get_object('org.freedesktop.Notifications',
135 '/org/freedesktop/Notifications')
137 = dbus.Interface(proxy, 'org.freedesktop.Notifications')
140 notification_iface.SystemNoteInfoprint("FeedingIt: " + message)
142 if notification_iface is None:
147 except dbus.DBusException:
148 # Rebind the name and try again.
152 def open_in_browser(link):
153 bus = dbus.SessionBus()
154 b_proxy = bus.get_object("com.nokia.osso_browser",
155 "/com/nokia/osso_browser/request")
156 b_iface = dbus.Interface(b_proxy, 'com.nokia.osso_browser')
158 notify("Opening %s" % link)
160 # We open the link asynchronously: if the web browser is not
161 # already running, this can take a while.
164 Something went wrong opening the URL.
167 notify("Error opening %s: %s" % (link, str(exception)))
170 b_iface.open_new_window(link,
171 reply_handler=lambda *args: None,
172 error_handler=error_handler())
175 # Removes HTML or XML character references and entities from a text string.
177 # @param text The HTML (or XML) source text.
178 # @return The plain text, as a Unicode string, if necessary.
179 # http://effbot.org/zone/re-sub.htm#unescape-html
184 # character reference
186 if text[:3] == "&#x":
187 return unichr(int(text[3:-1], 16))
189 return unichr(int(text[2:-1]))
195 text = unichr(name2codepoint[text[1:-1]])
198 return text # leave as is
199 return sub("&#?\w+;", fixup, text)
202 class AddWidgetWizard(gtk.Dialog):
203 def __init__(self, parent, listing, urlIn, categories, titleIn=None, isEdit=False, currentCat=1):
204 gtk.Dialog.__init__(self)
205 self.set_transient_for(parent)
207 #self.category = categories[0]
208 self.category = currentCat
211 self.set_title('Edit RSS feed')
213 self.set_title('Add new RSS feed')
216 self.btn_add = self.add_button('Save', 2)
218 self.btn_add = self.add_button('Add', 2)
220 self.set_default_response(2)
222 self.nameEntry = hildon.Entry(gtk.HILDON_SIZE_AUTO)
223 self.nameEntry.set_placeholder('Feed name')
224 # If titleIn matches urlIn, there is no title.
225 if not titleIn == None and titleIn != urlIn:
226 self.nameEntry.set_text(titleIn)
227 self.nameEntry.select_region(-1, -1)
229 self.urlEntry = hildon.Entry(gtk.HILDON_SIZE_AUTO)
230 self.urlEntry.set_placeholder('Feed URL')
231 self.urlEntry.set_text(urlIn)
232 self.urlEntry.select_region(-1, -1)
233 self.urlEntry.set_activates_default(True)
235 self.table = gtk.Table(3, 2, False)
236 self.table.set_col_spacings(5)
237 label = gtk.Label('Name:')
238 label.set_alignment(1., .5)
239 self.table.attach(label, 0, 1, 0, 1, gtk.FILL)
240 self.table.attach(self.nameEntry, 1, 2, 0, 1)
241 label = gtk.Label('URL:')
242 label.set_alignment(1., .5)
243 self.table.attach(label, 0, 1, 1, 2, gtk.FILL)
244 self.table.attach(self.urlEntry, 1, 2, 1, 2)
245 selector = self.create_selector(categories, listing)
246 picker = hildon.PickerButton(gtk.HILDON_SIZE_FINGER_HEIGHT, hildon.BUTTON_ARRANGEMENT_VERTICAL)
247 picker.set_selector(selector)
248 picker.set_title("Select category")
249 #picker.set_text(listing.getCategoryTitle(self.category), None) #, "Subtitle")
250 picker.set_name('HildonButton-finger')
251 picker.set_alignment(0,0,1,1)
253 self.table.attach(picker, 0, 2, 2, 3, gtk.FILL)
255 self.vbox.pack_start(self.table)
260 return (self.nameEntry.get_text(), self.urlEntry.get_text(), self.category)
262 def create_selector(self, choices, listing):
263 #self.pickerDialog = hildon.PickerDialog(self.parent)
264 selector = hildon.TouchSelector(text=True)
268 title = listing.getCategoryTitle(item)
269 iter = selector.append_text(str(title))
270 if self.category == item:
271 selector.set_active(0, index)
272 self.map[title] = item
274 selector.connect("changed", self.selection_changed)
275 #self.pickerDialog.set_selector(selector)
278 def selection_changed(self, selector, button):
279 current_selection = selector.get_current_text()
280 if current_selection:
281 self.category = self.map[current_selection]
283 class AddCategoryWizard(gtk.Dialog):
284 def __init__(self, parent, titleIn=None, isEdit=False):
285 gtk.Dialog.__init__(self)
286 self.set_transient_for(parent)
289 self.set_title('Edit Category')
291 self.set_title('Add Category')
294 self.btn_add = self.add_button('Save', 2)
296 self.btn_add = self.add_button('Add', 2)
298 self.set_default_response(2)
300 self.nameEntry = hildon.Entry(gtk.HILDON_SIZE_AUTO)
301 self.nameEntry.set_placeholder('Category name')
302 if not titleIn == None:
303 self.nameEntry.set_text(titleIn)
304 self.nameEntry.select_region(-1, -1)
306 self.table = gtk.Table(1, 2, False)
307 self.table.set_col_spacings(5)
308 label = gtk.Label('Name:')
309 label.set_alignment(1., .5)
310 self.table.attach(label, 0, 1, 0, 1, gtk.FILL)
311 self.table.attach(self.nameEntry, 1, 2, 0, 1)
312 #label = gtk.Label('URL:')
313 #label.set_alignment(1., .5)
314 #self.table.attach(label, 0, 1, 1, 2, gtk.FILL)
315 #self.table.attach(self.urlEntry, 1, 2, 1, 2)
316 self.vbox.pack_start(self.table)
321 return self.nameEntry.get_text()
323 class DownloadBar(gtk.ProgressBar):
326 if hasattr (cls, 'class_init_done'):
329 cls.downloadbars = []
330 # Total number of jobs we are monitoring.
332 # Number of jobs complete (of those that we are monitoring).
337 cls.class_init_done = True
339 bus = dbus.SessionBus()
340 bus.add_signal_receiver(handler_function=cls.update_progress,
342 signal_name='UpdateProgress',
343 dbus_interface='org.marcoz.feedingit',
344 path='/org/marcoz/feedingit/update')
346 def __init__(self, parent):
349 gtk.ProgressBar.__init__(self)
351 self.downloadbars.append(weakref.ref (self))
353 self.__class__.update_bars()
356 def downloading(cls):
358 return cls.done != cls.total
361 def update_progress(cls, percent_complete,
362 completed, in_progress, queued,
363 bytes_downloaded, bytes_updated, bytes_per_second,
365 if not cls.downloadbars:
368 cls.total = completed + in_progress + queued
370 cls.progress = percent_complete / 100.
371 if cls.progress < 0: cls.progress = 0
372 if cls.progress > 1: cls.progress = 1
375 for ref in cls.downloadbars:
378 # The download bar disappeared.
379 cls.downloadbars.remove (ref)
381 bar.emit("download-done", feed_updated)
383 if in_progress == 0 and queued == 0:
384 for ref in cls.downloadbars:
387 # The download bar disappeared.
388 cls.downloadbars.remove (ref)
390 bar.emit("download-done", None)
396 def update_bars(cls):
397 # In preparation for i18n/l10n
399 return (a if n == 1 else b)
401 text = (N_('Updated %d of %d feeds ', 'Updated %d of %d feeds',
403 % (cls.done, cls.total))
405 for ref in cls.downloadbars:
408 # The download bar disappeared.
409 cls.downloadbars.remove (ref)
412 bar.set_fraction(cls.progress)
414 class SortList(hildon.StackableWindow):
415 def __init__(self, parent, listing, feedingit, after_closing, category=None):
416 hildon.StackableWindow.__init__(self)
417 self.set_transient_for(parent)
419 self.isEditingCategories = False
420 self.category = category
421 self.set_title(listing.getCategoryTitle(category))
423 self.isEditingCategories = True
424 self.set_title('Categories')
425 self.listing = listing
426 self.feedingit = feedingit
427 self.after_closing = after_closing
429 self.connect('destroy', lambda w: self.after_closing())
430 self.vbox2 = gtk.VBox(False, 2)
432 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
433 button.set_image(gtk.image_new_from_icon_name('keyboard_move_up', gtk.ICON_SIZE_BUTTON))
434 button.connect("clicked", self.buttonUp)
435 self.vbox2.pack_start(button, expand=False, fill=False)
437 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
438 button.set_image(gtk.image_new_from_icon_name('keyboard_move_down', gtk.ICON_SIZE_BUTTON))
439 button.connect("clicked", self.buttonDown)
440 self.vbox2.pack_start(button, expand=False, fill=False)
442 self.vbox2.pack_start(gtk.Label(), expand=True, fill=False)
444 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
445 button.set_image(gtk.image_new_from_icon_name('general_add', gtk.ICON_SIZE_BUTTON))
446 button.connect("clicked", self.buttonAdd)
447 self.vbox2.pack_start(button, expand=False, fill=False)
449 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
450 button.set_image(gtk.image_new_from_icon_name('general_information', gtk.ICON_SIZE_BUTTON))
451 button.connect("clicked", self.buttonEdit)
452 self.vbox2.pack_start(button, expand=False, fill=False)
454 button = hildon.GtkButton(gtk.HILDON_SIZE_FINGER_HEIGHT)
455 button.set_image(gtk.image_new_from_icon_name('general_delete', gtk.ICON_SIZE_BUTTON))
456 button.connect("clicked", self.buttonDelete)
457 self.vbox2.pack_start(button, expand=False, fill=False)
459 #button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
460 #button.set_label("Done")
461 #button.connect("clicked", self.buttonDone)
462 #self.vbox.pack_start(button)
463 self.hbox2= gtk.HBox(False, 10)
464 self.pannableArea = hildon.PannableArea()
465 self.treestore = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
466 self.treeview = gtk.TreeView(self.treestore)
467 self.hbox2.pack_start(self.pannableArea, expand=True)
469 self.hbox2.pack_end(self.vbox2, expand=False)
470 self.set_default_size(-1, 600)
473 menu = hildon.AppMenu()
474 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
475 button.set_label("Import from OPML")
476 button.connect("clicked", self.feedingit.button_import_clicked)
479 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
480 button.set_label("Export to OPML")
481 button.connect("clicked", self.feedingit.button_export_clicked)
483 self.set_app_menu(menu)
487 #self.connect("destroy", self.buttonDone)
489 def displayFeeds(self):
490 self.treeview.destroy()
491 self.treestore = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
492 self.treeview = gtk.TreeView()
494 self.treeview.get_selection().set_mode(gtk.SELECTION_SINGLE)
495 hildon.hildon_gtk_tree_view_set_ui_mode(self.treeview, gtk.HILDON_UI_MODE_EDIT)
497 self.treeview.append_column(gtk.TreeViewColumn('Feed Name', gtk.CellRendererText(), text = 0))
499 self.pannableArea.add(self.treeview)
503 def refreshList(self, selected=None, offset=0):
504 #rect = self.treeview.get_visible_rect()
505 #y = rect.y+rect.height
506 self.treestore = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
507 if self.isEditingCategories:
508 for key in self.listing.getListOfCategories():
509 item = self.treestore.append([self.listing.getCategoryTitle(key), key])
513 for key in self.listing.getListOfFeeds(category=self.category):
514 item = self.treestore.append([self.listing.getFeedTitle(key), key])
517 self.treeview.set_model(self.treestore)
518 if not selected == None:
519 self.treeview.get_selection().select_iter(selectedItem)
520 self.treeview.scroll_to_cell(self.treeview.get_model().get_path(selectedItem))
521 self.pannableArea.show_all()
523 def getSelectedItem(self):
524 (model, iter) = self.treeview.get_selection().get_selected()
527 return model.get_value(iter, 1)
529 def findIndex(self, key):
533 for row in self.treestore:
535 return (before, row.iter)
536 if key == list(row)[0]:
540 return (before, None)
542 def buttonUp(self, button):
543 key = self.getSelectedItem()
545 if self.isEditingCategories:
546 self.listing.moveCategoryUp(key)
548 self.listing.moveUp(key)
549 self.refreshList(key, -10)
551 def buttonDown(self, button):
552 key = self.getSelectedItem()
554 if self.isEditingCategories:
555 self.listing.moveCategoryDown(key)
557 self.listing.moveDown(key)
558 self.refreshList(key, 10)
560 def buttonDelete(self, button):
561 key = self.getSelectedItem()
563 message = 'Really remove this feed and its entries?'
564 dlg = hildon.hildon_note_new_confirmation(self, message)
567 if response == gtk.RESPONSE_OK:
568 if self.isEditingCategories:
569 self.listing.removeCategory(key)
571 self.listing.removeFeed(key)
574 def buttonEdit(self, button):
575 key = self.getSelectedItem()
577 if key == 'ArchivedArticles':
578 message = 'Cannot edit the archived articles feed.'
579 hildon.hildon_banner_show_information(self, '', message)
581 if self.isEditingCategories:
583 SortList(self.parent, self.listing, self.feedingit, None, category=key)
586 wizard = AddWidgetWizard(self, self.listing, self.listing.getFeedUrl(key), self.listing.getListOfCategories(), self.listing.getFeedTitle(key), True, currentCat=self.category)
589 (title, url, category) = wizard.getData()
591 self.listing.editFeed(key, title, url, category=category)
595 def buttonDone(self, *args):
598 def buttonAdd(self, button, urlIn="http://"):
599 if self.isEditingCategories:
600 wizard = AddCategoryWizard(self)
603 title = wizard.getData()
604 if (not title == ''):
605 self.listing.addCategory(title)
607 wizard = AddWidgetWizard(self, self.listing, urlIn, self.listing.getListOfCategories())
610 (title, url, category) = wizard.getData()
612 self.listing.addFeed(title, url, category=category)
617 class DisplayArticle(hildon.StackableWindow):
619 A Widget for displaying an article.
621 def __init__(self, article_id, feed, feed_key, articles, config, listing):
623 article_id - The identifier of the article to load.
625 feed - The feed object containing the article (an
626 rss_sqlite:Feed object).
628 feed_key - The feed's identifier.
630 articles - A list of articles from the feed to display.
631 Needed for selecting the next/previous article (article_next).
633 config - A configuration object (config:Config).
635 listing - The listing object (rss_sqlite:Listing) that
636 contains the feed and article.
638 hildon.StackableWindow.__init__(self)
640 self.article_id = None
642 self.feed_key = feed_key
643 self.articles = articles
645 self.listing = listing
647 self.set_title(self.listing.getFeedTitle(feed_key))
649 # Init the article display
650 self.view = WebView()
651 self.view.set_zoom_level(float(config.getArtFontSize())/10.)
652 self.view.connect("motion-notify-event", lambda w,ev: True)
653 self.view.connect('load-started', self.load_started)
654 self.view.connect('load-finished', self.load_finished)
655 self.view.connect('navigation-requested', self.navigation_requested)
656 self.view.connect("button_press_event", self.button_pressed)
657 self.gestureId = self.view.connect(
658 "button_release_event", self.button_released)
660 self.pannable_article = hildon.PannableArea()
661 self.pannable_article.add(self.view)
663 self.add(self.pannable_article)
665 self.pannable_article.show_all()
668 menu = hildon.AppMenu()
670 def menu_button(label, callback):
671 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
672 button.set_label(label)
673 button.connect("clicked", callback)
676 menu_button("Allow horizontal scrolling", self.horiz_scrolling_button)
677 menu_button("Open in browser", self.open_in_browser)
678 if feed_key == "ArchivedArticles":
680 "Remove from archived articles", self.remove_archive_button)
682 menu_button("Add to archived articles", self.archive_button)
684 self.set_app_menu(menu)
687 self.destroyId = self.connect("destroy", self.destroyWindow)
689 self.article_open(article_id)
691 def article_open(self, article_id):
693 Load the article with the specified id.
695 # If an article was open, close it.
696 if self.article_id is not None:
697 self.article_closed()
699 self.article_id = article_id
700 self.set_for_removal = False
701 self.loadedArticle = False
702 self.initial_article_load = True
704 contentLink = self.feed.getContentLink(self.article_id)
705 if contentLink.startswith("/home/user/"):
706 self.view.open("file://%s" % contentLink)
707 self.currentUrl = self.feed.getExternalLink(self.article_id)
709 self.view.load_html_string('This article has not been downloaded yet. Click <a href="%s">here</a> to view online.' % contentLink, contentLink)
710 self.currentUrl = str(contentLink)
712 self.feed.setEntryRead(self.article_id)
714 def article_closed(self):
716 The user has navigated away from the article. Execute any
719 if self.set_for_removal:
720 self.emit("article-deleted", self.article_id)
722 self.emit("article-closed", self.article_id)
725 def navigation_requested(self, wv, fr, req):
727 http://webkitgtk.org/reference/webkitgtk-webkitwebview.html#WebKitWebView-navigation-requested
730 fr - a WebKitWebFrame
731 req - WebKitNetworkRequest
733 if self.initial_article_load:
734 # Always initially load an article in the internal
736 self.initial_article_load = False
739 # When following a link, only use the internal browser if so
740 # configured. Otherwise, launch an external browser.
741 if self.config.getOpenInExternalBrowser():
742 self.open_in_browser(None, req.get_uri())
747 def load_started(self, *widget):
748 hildon.hildon_gtk_window_set_progress_indicator(self, 1)
750 def load_finished(self, *widget):
751 hildon.hildon_gtk_window_set_progress_indicator(self, 0)
752 frame = self.view.get_main_frame()
753 if self.loadedArticle:
754 self.currentUrl = frame.get_uri()
756 self.loadedArticle = True
758 def button_pressed(self, window, event):
760 The user pressed a "mouse button" (in our case, this means the
761 user likely started to drag with the finger).
763 We are only interested in whether the user performs a drag.
764 We record the starting position and when the user "releases
765 the button," we see how far the mouse moved.
767 self.coords = (event.x, event.y)
769 def button_released(self, window, event):
770 x = self.coords[0] - event.x
771 y = self.coords[1] - event.y
773 if (2*abs(y) < abs(x)):
775 self.article_next(forward=False)
777 self.article_next(forward=True)
779 # We handled the event. Don't propagate it further.
782 def article_next(self, forward=True):
784 Advance to the next (or, if forward is false, the previous)
792 id = self.feed.getNextId(id, forward)
800 if id in self.articles:
801 self.article_open(id)
804 def destroyWindow(self, *args):
805 self.article_closed()
806 self.disconnect(self.destroyId)
809 def horiz_scrolling_button(self, *widget):
810 self.pannable_article.disconnect(self.gestureId)
811 self.pannable_article.set_property("mov-mode", hildon.MOVEMENT_MODE_BOTH)
813 def archive_button(self, *widget):
814 # Call the listing.addArchivedArticle
815 self.listing.addArchivedArticle(self.feed_key, self.article_id)
817 def remove_archive_button(self, *widget):
818 self.set_for_removal = True
820 def open_in_browser(self, object, link=None):
822 Open the specified link using the system's browser. If not
823 link is specified, reopen the current page using the system's
827 link = self.currentUrl
829 open_in_browser(link)
831 class DisplayFeed(hildon.StackableWindow):
832 def __init__(self, listing, config):
833 hildon.StackableWindow.__init__(self)
834 self.connect('configure-event', self.on_configure_event)
835 self.connect("delete_event", self.delete_window)
836 self.connect("destroy", self.destroyWindow)
838 self.listing = listing
843 # If hide read articles is set, this is set to the set of
844 # unread articles at the time that feed is loaded. The last
845 # bit is important: when the user selects the next article,
846 # but then decides to move back, previous should select the
848 self.articles = list()
850 self.downloadDialog = False
852 #self.listing.setCurrentlyDisplayedFeed(self.key)
856 menu = hildon.AppMenu()
857 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
858 button.set_label("Update feed")
859 button.connect("clicked", self.button_update_clicked)
862 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
863 button.set_label("Mark all as read")
864 button.connect("clicked", self.buttonReadAllClicked)
867 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
868 button.set_label("Delete read articles")
869 button.connect("clicked", self.buttonPurgeArticles)
871 self.archived_article_buttons = [button]
873 self.set_app_menu(menu)
876 self.feedItems = gtk.ListStore(str, str)
878 self.feedList = hildon.GtkTreeView(gtk.HILDON_UI_MODE_NORMAL)
879 self.feedList.set_model(self.feedItems)
880 self.feedList.set_rules_hint(True)
881 self.feedList.set_grid_lines(gtk.TREE_VIEW_GRID_LINES_HORIZONTAL)
882 self.feedList.set_hover_selection(False)
883 self.feedList.connect('hildon-row-tapped',
884 self.on_feedList_row_activated)
886 self.markup_renderer = gtk.CellRendererText()
887 self.markup_renderer.set_property('wrap-mode', pango.WRAP_WORD_CHAR)
888 self.markup_renderer.set_property('background', bg_color) #"#333333")
889 (width, height) = self.get_size()
890 self.markup_renderer.set_property('wrap-width', width-20)
891 self.markup_renderer.set_property('ypad', 8)
892 self.markup_renderer.set_property('xpad', 5)
893 markup_column = gtk.TreeViewColumn('', self.markup_renderer, \
894 markup=FEED_COLUMN_MARKUP)
895 self.feedList.append_column(markup_column)
897 vbox = gtk.VBox(False, 10)
898 vbox.pack_start(self.feedList)
900 self.pannableFeed = hildon.PannableArea()
901 self.pannableFeed.set_property('hscrollbar-policy', gtk.POLICY_NEVER)
902 self.pannableFeed.add_with_viewport(vbox)
904 self.main_vbox = gtk.VBox(False, 0)
905 self.main_vbox.pack_start(self.pannableFeed)
907 self.add(self.main_vbox)
909 self.main_vbox.show_all()
911 def on_configure_event(self, window, event):
912 if getattr(self, 'markup_renderer', None) is None:
915 # Fix up the column width for wrapping the text when the window is
916 # resized (i.e. orientation changed)
917 self.markup_renderer.set_property('wrap-width', event.width-20)
918 it = self.feedItems.get_iter_first()
919 while it is not None:
920 markup = self.feedItems.get_value(it, FEED_COLUMN_MARKUP)
921 self.feedItems.set_value(it, FEED_COLUMN_MARKUP, markup)
922 it = self.feedItems.iter_next(it)
924 def delete_window(self, *args):
926 Prevent the window from being deleted.
932 except AttributeError:
936 self.listing.updateUnread(key)
937 self.emit("feed-closed", key)
939 self.feedItems.clear()
943 def destroyWindow(self, *args):
946 except AttributeError:
951 def fix_title(self, title):
952 return escape(unescape(title).replace("<em>","").replace("</em>","").replace("<nobr>","").replace("</nobr>","").replace("<wbr>",""))
954 def displayFeed(self, key=None):
956 Select and display a feed. If feed, title and key are None,
957 reloads the current feed.
962 except AttributeError:
966 self.listing.updateUnread(self.key)
967 self.emit("feed-closed", self.key)
969 self.feed = self.listing.getFeed(key)
970 self.feedTitle = self.listing.getFeedTitle(key)
973 self.set_title(self.feedTitle)
975 selection = self.feedList.get_selection()
976 if selection is not None:
977 selection.set_mode(gtk.SELECTION_NONE)
979 for b in self.archived_article_buttons:
980 if key == "ArchivedArticles":
985 #self.pannableFeed.set_property("mov-mode", hildon.MOVEMENT_MODE_BOTH)
986 hideReadArticles = self.config.getHideReadArticles()
988 articles = self.feed.getIds(onlyUnread=True)
990 articles = self.feed.getIds()
992 self.articles[:] = []
994 self.feedItems.clear()
997 isRead = self.feed.isEntryRead(id)
1000 if not ( isRead and hideReadArticles ):
1001 title = self.fix_title(self.feed.getTitle(id))
1002 self.articles.append(id)
1004 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), title)
1006 markup = ENTRY_TEMPLATE_UNREAD % (self.config.getFontSize(), title)
1008 self.feedItems.append((markup, id))
1011 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), "No Articles To Display")
1012 self.feedItems.append((markup, ""))
1017 self.pannableFeed.destroy()
1018 #self.remove(self.pannableFeed)
1020 def on_feedList_row_activated(self, treeview, path): #, column):
1021 if not self.articles:
1022 # There are not actually any articles. Ignore.
1025 selection = self.feedList.get_selection()
1026 selection.set_mode(gtk.SELECTION_SINGLE)
1027 self.feedList.get_selection().select_path(path)
1028 model = treeview.get_model()
1029 iter = model.get_iter(path)
1030 key = model.get_value(iter, FEED_COLUMN_KEY)
1031 # Emulate legacy "button_clicked" call via treeview
1032 gobject.idle_add(self.button_clicked, treeview, key)
1035 def button_clicked(self, button, index, previous=False, next=False):
1036 newDisp = DisplayArticle(index, self.feed, self.key, self.articles, self.config, self.listing)
1037 stack = hildon.WindowStack.get_default()
1040 stack.pop_and_push(1, newDisp, tmp)
1042 gobject.timeout_add(200, self.destroyArticle, tmp)
1047 if type(self.disp).__name__ == "DisplayArticle":
1048 gobject.timeout_add(200, self.destroyArticle, self.disp)
1052 self.disp.show_all()
1055 if self.key == "ArchivedArticles":
1056 self.ids.append(self.disp.connect("article-deleted", self.onArticleDeleted))
1057 self.ids.append(self.disp.connect("article-closed", self.onArticleClosed))
1059 def buttonPurgeArticles(self, *widget):
1061 self.feed.purgeReadArticles()
1062 #self.feed.saveFeed(CONFIGDIR)
1065 def destroyArticle(self, handle):
1066 handle.destroyWindow()
1068 def mark_item_read(self, key):
1069 it = self.feedItems.get_iter_first()
1070 while it is not None:
1071 k = self.feedItems.get_value(it, FEED_COLUMN_KEY)
1073 title = self.fix_title(self.feed.getTitle(key))
1074 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), title)
1075 self.feedItems.set_value(it, FEED_COLUMN_MARKUP, markup)
1077 it = self.feedItems.iter_next(it)
1079 def onArticleClosed(self, object, index):
1080 selection = self.feedList.get_selection()
1081 selection.set_mode(gtk.SELECTION_NONE)
1082 self.mark_item_read(index)
1084 def onArticleDeleted(self, object, index):
1086 self.feed.removeArticle(index)
1087 #self.feed.saveFeed(CONFIGDIR)
1091 def do_update_feed(self):
1092 self.listing.updateFeed (self.key, priority=-1)
1094 def button_update_clicked(self, button):
1095 gobject.idle_add(self.do_update_feed)
1097 def show_download_bar(self):
1098 if not type(self.downloadDialog).__name__=="DownloadBar":
1099 self.downloadDialog = DownloadBar(self.window)
1100 self.downloadDialog.connect("download-done", self.onDownloadDone)
1101 self.main_vbox.pack_end(self.downloadDialog,
1102 expand=False, fill=False)
1103 self.downloadDialog.show()
1105 def onDownloadDone(self, widget, feed):
1106 if feed is not None and hasattr(self, 'feed') and feed == self.feed:
1107 self.feed = self.listing.getFeed(self.key)
1111 self.downloadDialog.destroy()
1112 self.downloadDialog = False
1114 def buttonReadAllClicked(self, button):
1116 self.feed.markAllAsRead()
1117 it = self.feedItems.get_iter_first()
1118 while it is not None:
1119 k = self.feedItems.get_value(it, FEED_COLUMN_KEY)
1120 title = self.fix_title(self.feed.getTitle(k))
1121 markup = ENTRY_TEMPLATE % (self.config.getFontSize(), title)
1122 self.feedItems.set_value(it, FEED_COLUMN_MARKUP, markup)
1123 it = self.feedItems.iter_next(it)
1125 #for index in self.feed.getIds():
1126 # self.feed.setEntryRead(index)
1127 # self.mark_item_read(index)
1133 self.window = hildon.StackableWindow()
1134 hildon.hildon_gtk_window_set_progress_indicator(self.window, 1)
1136 self.config = Config(self.window, CONFIGDIR+"config.ini")
1139 self.orientation = FremantleRotation(__appname__, main_window=self.window, app=self)
1140 self.orientation.set_mode(self.config.getOrientation())
1141 except Exception, e:
1142 logger.warn("Could not start rotation manager: %s" % str(e))
1144 self.window.set_title(__appname__)
1145 self.mainVbox = gtk.VBox(False,10)
1147 if isfile(CONFIGDIR+"/feeds.db"):
1148 self.introLabel = gtk.Label("Loading...")
1150 self.introLabel = gtk.Label("Updating database to new format...\nThis can take several minutes.")
1152 self.mainVbox.pack_start(self.introLabel)
1154 self.window.add(self.mainVbox)
1155 self.window.show_all()
1156 gobject.idle_add(self.createWindow)
1158 def createWindow(self):
1160 self.listing = Listing(self.config, CONFIGDIR)
1162 self.downloadDialog = False
1164 self.introLabel.destroy()
1165 self.pannableListing = hildon.PannableArea()
1167 # The main area is a view consisting of an icon and two
1168 # strings. The view is bound to the Listing's database via a
1171 self.feedItems = gtk.TreeStore(gtk.gdk.Pixbuf, str, str)
1172 self.feedList = gtk.TreeView(self.feedItems)
1173 self.feedList.connect('row-activated', self.on_feedList_row_activated)
1175 self.pannableListing.add(self.feedList)
1177 icon_renderer = gtk.CellRendererPixbuf()
1178 icon_renderer.set_property('width', LIST_ICON_SIZE + 2*LIST_ICON_BORDER)
1179 icon_column = gtk.TreeViewColumn('', icon_renderer, \
1181 self.feedList.append_column(icon_column)
1183 markup_renderer = gtk.CellRendererText()
1184 markup_column = gtk.TreeViewColumn('', markup_renderer, \
1185 markup=COLUMN_MARKUP)
1186 self.feedList.append_column(markup_column)
1187 self.mainVbox.pack_start(self.pannableListing)
1188 self.mainVbox.show_all()
1190 self.displayListing()
1192 hildon.hildon_gtk_window_set_progress_indicator(self.window, 0)
1193 gobject.idle_add(self.late_init)
1195 def update_progress(self, percent_complete,
1196 completed, in_progress, queued,
1197 bytes_downloaded, bytes_updated, bytes_per_second,
1199 if (in_progress or queued) and not self.downloadDialog:
1200 self.downloadDialog = DownloadBar(self.window)
1201 self.downloadDialog.connect("download-done", self.onDownloadDone)
1202 self.mainVbox.pack_end(self.downloadDialog, expand=False, fill=False)
1203 self.downloadDialog.show()
1205 if self.__dict__.get ('disp', None):
1206 self.disp.show_download_bar ()
1208 def onDownloadDone(self, widget, feed):
1210 self.downloadDialog.destroy()
1211 self.downloadDialog = False
1212 self.displayListing()
1214 def late_init(self):
1215 # Finish building the GUI.
1216 menu = hildon.AppMenu()
1217 # Create a button and add it to the menu
1218 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1219 button.set_label("Update feeds")
1220 button.connect("clicked", self.button_update_clicked, "All")
1223 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1224 button.set_label("Mark all as read")
1225 button.connect("clicked", self.button_markAll)
1228 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1229 button.set_label("Add new feed")
1230 button.connect("clicked", lambda b: self.addFeed())
1233 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1234 button.set_label("Manage subscriptions")
1235 button.connect("clicked", self.button_organize_clicked)
1238 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1239 button.set_label("Settings")
1240 button.connect("clicked", self.button_preferences_clicked)
1243 button = hildon.GtkButton(gtk.HILDON_SIZE_AUTO)
1244 button.set_label("About")
1245 button.connect("clicked", self.button_about_clicked)
1248 self.window.set_app_menu(menu)
1251 # Initialize the DBus interface.
1252 self.dbusHandler = ServerObject(self)
1253 bus = dbus.SessionBus()
1254 bus.add_signal_receiver(handler_function=self.update_progress,
1256 signal_name='UpdateProgress',
1257 dbus_interface='org.marcoz.feedingit',
1258 path='/org/marcoz/feedingit/update')
1260 # Check whether auto-update is enabled.
1261 self.autoupdate = False
1262 #self.checkAutoUpdate()
1264 gobject.idle_add(self.build_feed_display)
1265 gobject.idle_add(self.check_for_woodchuck)
1267 def build_feed_display(self):
1268 if not hasattr(self, 'disp'):
1269 self.disp = DisplayFeed(self.listing, self.config)
1270 self.disp.connect("feed-closed", self.onFeedClosed)
1272 def check_for_woodchuck(self):
1273 if self.config.getAskedAboutWoodchuck():
1278 # It is already installed successfully.
1279 self.config.setAskedAboutWoodchuck(True)
1284 note = hildon.hildon_note_new_confirmation(
1286 "\nFeedingIt can use Woodchuck, a network transfer "
1287 + "daemon, to schedule transfers more intelligently.\n\n"
1288 + "Install Woodchuck? (This is recommended.)\n")
1289 note.set_button_texts("Install", "Cancel")
1290 note.add_button("Learn More", 42)
1293 response = gtk.Dialog.run(note)
1295 open_in_browser("http://hssl.cs.jhu.edu/~neal/woodchuck")
1302 if response == gtk.RESPONSE_OK:
1303 open_in_browser("http://maemo.org/downloads/product/raw/Maemo5/murmeltier?get_installfile")
1304 self.config.setAskedAboutWoodchuck(True)
1306 def button_markAll(self, button):
1307 for key in self.listing.getListOfFeeds():
1308 feed = self.listing.getFeed(key)
1309 feed.markAllAsRead()
1310 #for id in feed.getIds():
1311 # feed.setEntryRead(id)
1312 self.listing.updateUnread(key)
1313 self.displayListing()
1315 def button_about_clicked(self, button):
1316 HeAboutDialog.present(self.window, \
1326 def button_export_clicked(self, button):
1327 opml = ExportOpmlData(self.window, self.listing)
1329 def button_import_clicked(self, button):
1330 opml = GetOpmlData(self.window)
1331 feeds = opml.getData()
1332 for (title, url) in feeds:
1333 self.listing.addFeed(title, url)
1334 self.displayListing()
1336 def addFeed(self, urlIn="http://"):
1337 wizard = AddWidgetWizard(self.window, self.listing, urlIn, self.listing.getListOfCategories())
1340 (title, url, category) = wizard.getData()
1342 self.listing.addFeed(title, url, category=category)
1344 self.displayListing()
1346 def button_organize_clicked(self, button):
1347 def after_closing():
1348 self.displayListing()
1349 SortList(self.window, self.listing, self, after_closing)
1351 def do_update_feeds(self):
1352 for k in self.listing.getListOfFeeds():
1353 self.listing.updateFeed (k)
1355 def button_update_clicked(self, button, key):
1356 gobject.idle_add(self.do_update_feeds)
1358 def onDownloadsDone(self, *widget):
1359 self.downloadDialog.destroy()
1360 self.downloadDialog = False
1361 self.displayListing()
1363 def button_preferences_clicked(self, button):
1364 dialog = self.config.createDialog()
1365 dialog.connect("destroy", self.prefsClosed)
1367 def show_confirmation_note(self, parent, title):
1368 note = hildon.Note("confirmation", parent, "Are you sure you want to delete " + title +"?")
1370 retcode = gtk.Dialog.run(note)
1373 if retcode == gtk.RESPONSE_OK:
1378 def saveExpandedLines(self):
1379 self.expandedLines = []
1380 model = self.feedList.get_model()
1381 model.foreach(self.checkLine)
1383 def checkLine(self, model, path, iter, data = None):
1384 if self.feedList.row_expanded(path):
1385 self.expandedLines.append(path)
1387 def restoreExpandedLines(self):
1388 model = self.feedList.get_model()
1389 model.foreach(self.restoreLine)
1391 def restoreLine(self, model, path, iter, data = None):
1392 if path in self.expandedLines:
1393 self.feedList.expand_row(path, False)
1395 def displayListing(self):
1396 icon_theme = gtk.icon_theme_get_default()
1397 default_pixbuf = icon_theme.load_icon(ABOUT_ICON, LIST_ICON_SIZE, \
1398 gtk.ICON_LOOKUP_USE_BUILTIN)
1400 self.saveExpandedLines()
1402 self.feedItems.clear()
1403 hideReadFeed = self.config.getHideReadFeeds()
1404 order = self.config.getFeedSortOrder()
1406 categories = self.listing.getListOfCategories()
1407 if len(categories) > 1:
1408 showCategories = True
1410 showCategories = False
1412 for categoryId in categories:
1414 title = self.listing.getCategoryTitle(categoryId)
1415 keys = self.listing.getSortedListOfKeys(order, onlyUnread=hideReadFeed, category=categoryId)
1417 if showCategories and len(keys)>0:
1418 category = self.feedItems.append(None, (None, title, categoryId))
1419 #print "catID" + str(categoryId) + " " + str(self.category)
1420 if categoryId == self.category:
1422 expandedRow = category
1425 unreadItems = self.listing.getFeedNumberOfUnreadItems(key)
1426 title = xml.sax.saxutils.escape(self.listing.getFeedTitle(key))
1427 updateTime = self.listing.getFeedUpdateTime(key)
1428 subtitle = '%s / %d unread items' % (updateTime, unreadItems)
1430 markup = FEED_TEMPLATE_UNREAD % (title, subtitle)
1432 markup = FEED_TEMPLATE % (title, subtitle)
1435 icon_filename = self.listing.getFavicon(key)
1436 pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(icon_filename, \
1437 LIST_ICON_SIZE, LIST_ICON_SIZE)
1439 pixbuf = default_pixbuf
1442 self.feedItems.append(category, (pixbuf, markup, key))
1444 self.feedItems.append(None, (pixbuf, markup, key))
1447 self.restoreExpandedLines()
1450 # self.feedList.expand_row(self.feeItems.get_path(expandedRow), True)
1454 def on_feedList_row_activated(self, treeview, path, column):
1455 model = treeview.get_model()
1456 iter = model.get_iter(path)
1457 key = model.get_value(iter, COLUMN_KEY)
1460 #print "Key: " + str(key)
1462 self.category = catId
1463 if treeview.row_expanded(path):
1464 treeview.collapse_row(path)
1466 # treeview.expand_row(path, True)
1467 #treeview.collapse_all()
1468 #treeview.expand_row(path, False)
1469 #for i in range(len(path)):
1470 # self.feedList.expand_row(path[:i+1], False)
1471 #self.show_confirmation_note(self.window, "Working")
1477 def openFeed(self, key):
1479 self.build_feed_display()
1480 self.disp.displayFeed(key)
1482 def openArticle(self, key, id):
1485 self.disp.button_clicked(None, id)
1487 def onFeedClosed(self, object, key):
1488 gobject.idle_add(self.displayListing)
1490 def quit(self, *args):
1495 self.window.connect("destroy", self.quit)
1498 def prefsClosed(self, *widget):
1500 self.orientation.set_mode(self.config.getOrientation())
1503 self.displayListing()
1504 self.checkAutoUpdate()
1506 def checkAutoUpdate(self, *widget):
1507 interval = int(self.config.getUpdateInterval()*3600000)
1508 if self.config.isAutoUpdateEnabled():
1509 if self.autoupdate == False:
1510 self.autoupdateId = gobject.timeout_add(interval, self.automaticUpdate)
1511 self.autoupdate = interval
1512 elif not self.autoupdate == interval:
1513 # If auto-update is enabled, but not at the right frequency
1514 gobject.source_remove(self.autoupdateId)
1515 self.autoupdateId = gobject.timeout_add(interval, self.automaticUpdate)
1516 self.autoupdate = interval
1518 if not self.autoupdate == False:
1519 gobject.source_remove(self.autoupdateId)
1520 self.autoupdate = False
1522 def automaticUpdate(self, *widget):
1523 # Need to check for internet connection
1524 # If no internet connection, try again in 10 minutes:
1525 # gobject.timeout_add(int(5*3600000), self.automaticUpdate)
1526 #file = open("/home/user/.feedingit/feedingit_widget.log", "a")
1527 #from time import localtime, strftime
1528 #file.write("App: %s\n" % strftime("%a, %d %b %Y %H:%M:%S +0000", localtime()))
1530 self.button_update_clicked(None, None)
1533 def getStatus(self):
1535 for key in self.listing.getListOfFeeds():
1536 if self.listing.getFeedNumberOfUnreadItems(key) > 0:
1537 status += self.listing.getFeedTitle(key) + ": \t" + str(self.listing.getFeedNumberOfUnreadItems(key)) + " Unread Items\n"
1539 status = "No unread items"
1542 def grabFocus(self):
1543 self.window.present()
1545 if __name__ == "__main__":
1547 debugging.init(dot_directory=".feedingit", program_name="feedingit")
1549 gobject.signal_new("feed-closed", DisplayFeed, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1550 gobject.signal_new("article-closed", DisplayArticle, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1551 gobject.signal_new("article-deleted", DisplayArticle, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1552 gobject.signal_new("download-done", DownloadBar, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
1553 gobject.threads_init()
1554 if not isdir(CONFIGDIR):
1558 logger.error("Error: Can't create configuration directory")
1559 from sys import exit