feed list view and feed/category event handling fixes
[straw.git] / src / lib / feeds.py
blob27feca6863f479e4d9808414b281ff12cc3995b0
1 import locale
2 import gobject
3 import FeedDataRouter
4 import ItemStore
5 import Config
6 from error import log
8 class FeedList(gobject.GObject):
10 __gsignals__ = {
11 'changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
12 'updated' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
13 (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT, gobject.TYPE_INT,)),
14 'imported' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
15 (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT,
16 gobject.TYPE_BOOLEAN,)),
17 'deleted' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,))
20 def __init__(self, init_seq = []):
21 gobject.GObject.__init__(self)
22 self.feedlist = []
23 self._loading = False
25 def __iter__(self):
26 return iter(self.feedlist)
28 def load_data(self):
29 def _load(feedlist, parent):
30 for df in feedlist:
31 if isinstance(df, list):
32 _load(df[1:], parent)
33 else:
34 f = Feed.create_empty_feed()
35 f.undump(df)
36 self.append(parent, f)
37 self._loading = True
38 feedlist = Config.get_instance().feeds
39 if feedlist:
40 _load(feedlist, None)
41 self._loading = False
42 self.emit('changed')
44 def connect_signals(self, ob):
45 ob.connect('changed', self.feed_detail_changed)
47 # these signals are forwarded so that listeners who just want to
48 # listen for a specific event regardless of what feed it came from can
49 # just connect to this feedlist instead of connecting to the
50 # individual feeds.
51 #ob.signal_connect(Event.AllItemsReadSignal, self._forward_signal)
52 #ob.signal_connect(Event.ItemReadSignal, self._forward_signal)
53 #ob.signal_connect(Event.ItemsAddedSignal, self._forward_signal)
54 #ob.signal_connect(Event.FeedPolledSignal, self._forward_signal)
55 #ob.signal_connect(Event.FeedStatusChangedSignal, self._forward_signal)
56 #ob.signal_connect(Event.FeedErrorStatusChangedSignal, self._forward_signal)
58 def __setitem__(self, key, value):
59 self.feedlist.__setitem__(key, value)
60 self.connect_signals(value)
61 self.save_feeds_and_notify(True)
63 def extend(self, parent, values, from_sub=False):
64 list.extend(self.feedlist, values)
65 for f in values:
66 f.parent = parent
67 self.connect_signals(f)
68 self.save_feeds()
69 self.emit('imported', values, parent, from_sub)
71 def append(self, parent, value):
72 self.feedlist.append(value)
73 value.parent = parent
74 self.connect_signals(value)
75 self.save_feeds()
76 self.emit('updated', value, parent, -1)
78 def insert(self, index, parent, value):
79 self.feedlist.insert(index, value)
80 value.parent = parent
81 self.connect_signals(value)
82 self.save_feeds()
83 self.emit('updated', value, parent, index)
85 def index(self, feed):
86 return self.feedlist.index(feed)
88 def reorder(self, move, delta):
89 k = self.feedlist[:]
90 move = list(move)
91 move.sort()
92 if delta > 0:
93 move.reverse()
94 if move[0] == 0 and delta < 0 or move[-1] == (len(self.feedlist) - 1) and delta > 0:
95 return
96 for m in move:
97 k[m + delta], k[m] = k[m], k[m + delta]
98 for i in range(len(k)):
99 list.__setitem__(self.feedlist, i, k[i])
100 self.save_feeds()
101 self.emit('changed')
103 def __delitem__(self, value):
104 feed = self.feedlist[value]
105 list.__delitem__(self.feedlist, value)
106 feed.delete_all_items()
107 self.save_feeds()
108 self.emit('deleted', feed)
110 def save_feeds(self):
111 if not self._loading:
112 config = Config.get_instance()
113 config.feeds = [f.dump() for f in self.feedlist]
114 return
116 def feed_detail_changed(self, feed):
117 self.save_feeds()
118 self.emit('changed') # XXXX send the feed as well?
120 def _sort_dsu(self, seq):
121 aux_list = [(x.title, x) for x in seq]
122 aux_list.sort(lambda a,b:locale.strcoll(a[0],b[0]))
123 return [x[1] for x in aux_list]
125 def sort(self, indices = None):
126 if not indices or len(indices) == 1:
127 self[:] = self._sort_dsu(self)
128 else:
129 items = self._sort_dsu(indices)
130 for i,x in enumerate(items):
131 list.__setitem__(self, indices[i], items[i])
132 self.save_feeds()
133 self.emit('changed')
135 def __hash__(self):
136 h = 0
137 for item in self.feedlist:
138 h ^= hash(item)
139 return h
141 def get_feed_with_id(self, id):
142 for f in self.flatten_list():
143 if f.id == id:
144 return f
145 return None
147 def flatten_list(self, ob=None):
148 if ob is None:
149 ob = self.feedlist
150 l = []
151 for o in ob:
152 if isinstance(o, list):
153 l = l + self.flatten_list(o)
154 else:
155 l.append(o)
156 return l
158 feedlist_instance = None
160 def get_instance():
161 global feedlist_instance
162 if feedlist_instance is None:
163 feedlist_instance = FeedList()
164 return feedlist_instance
166 class Feed(gobject.GObject):
167 "A Feed object stores information set by user about a RSS feed."
169 DEFAULT = -1
170 STATUS_IDLE = 0
171 STATUS_POLLING = 1
173 __slots__ = ('_title', '_location', '_username', '_password', '_parsed',
174 '__save_fields', '_items', '_slots',
175 '_id', '_channel_description',
176 '_channel_title', '_channel_link', '_channel_copyright',
177 'channel_lbd', 'channel_editor', 'channel_webmaster',
178 'channel_creator','_error', '_process_status', 'router', 'sticky', '_parent',
179 '_items_stored', '_poll_freq', '_last_poll','_n_items_unread')
181 __save_fields = (('_title', ""), ('_location', ""), ('_username', ""),
182 ('_password', ""), ('_id', ""),
183 ('_channel_description', ""), ('_channel_title', ""),
184 ('_channel_link', ""), ('_channel_copyright', ""),
185 ('channel_creator', ""), ('_error', None),
186 ('_items_stored', DEFAULT),
187 ('_poll_freq', DEFAULT),
188 ('_last_poll', 0),
189 ('_n_items_unread',0))
192 __gsignals__ = {
193 'changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
194 'poll-done' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
195 'items-updated' :(gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
196 (gobject.TYPE_PYOBJECT,)),
197 'items-read' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
198 (gobject.TYPE_PYOBJECT,)),
199 'items-deleted' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
200 (gobject.TYPE_PYOBJECT,)),
201 'item-order-changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,())
205 # use one of the factory functions below instead of this directly
206 def __init__(self, title="", location="", username="", password=""):
207 gobject.GObject.__init__(self)
208 self._title = title
209 self._channel_description = ""
210 self._channel_title = ""
211 self._channel_link = ""
212 self._channel_copyright = ""
213 self.channel_lbd = None
214 self.channel_editor = ""
215 self.channel_webmaster = ""
216 self.channel_creator = ""
217 self._location = location
218 self._username = username
219 self._password = password
220 self._parsed = None
221 self._error = None
222 self._n_items_unread = 0
223 self._process_status = self.STATUS_IDLE
224 self.router = FeedDataRouter.FeedDataRouter(self)
225 self._parent = None
226 self._items_stored = Feed.DEFAULT
227 self._poll_freq = Feed.DEFAULT
228 self._last_poll = 0
229 self._items = set()
230 self._items_loaded = False
231 return
233 def __str__(self):
234 return "Feed '%s' from %s" % (self._title, self._location)
236 @property
237 def id(self):
238 return self._id
240 @apply
241 def parsed():
242 doc = "A ParsedSummary object generated from the summary file"
243 def fget(self):
244 return self._parsed
245 def fset(self, parsed):
246 self._parsed = parsed
247 return property(**locals())
249 @apply
250 def title():
251 doc = "The title of this Feed (as defined by user)"
252 def fget(self):
253 text = ''
254 if self._title:
255 text = self._title
256 return text
257 def fset(self, title):
258 if self._title != title:
259 self._title = title
260 self.emit('changed')
261 return property(**locals())
263 @apply
264 def access_info():
265 doc = "A tuple of location, username, password"
266 def fget(self):
267 return (self._location, self._username, self._password)
268 def fset(self, (location,username,password)):
269 self._location = location
270 self._username = username
271 self._password = password
272 self.emit('changed')
273 return property(**locals())
275 @apply
276 def location():
277 doc = ""
278 def fget(self):
279 return self._location
280 def fset(self, location):
281 if self._location != location:
282 self._location = location
283 self.emit('changed')
284 return property(**locals())
286 @apply
287 def channel_title():
288 doc = ""
289 def fget(self):
290 text = ''
291 if self._channel_title:
292 text = self._channel_title
293 return text
294 def fset(self, t):
295 changed = self._channel_title != t
296 self._channel_title = t
297 if changed:
298 self.emit('changed')
299 return property(**locals())
301 @apply
302 def channel_description():
303 doc = ""
304 def fget(self):
305 text = ''
306 if self._channel_description:
307 text = self._channel_description
308 return text
309 def fset(self, t):
310 changed = self._channel_description != t
311 self._channel_description = t
312 if changed:
313 self.emit('changed')
314 return property(**locals())
316 @apply
317 def channel_link():
318 doc = ""
319 def fget(self):
320 return self._channel_link
321 def fset(self, t):
322 changed = self._channel_link != t
323 self._channel_link = t
324 if changed:
325 self.emit('changed')
326 return property(**locals())
328 @apply
329 def channel_copyright():
330 doc = ""
331 def fget(self):
332 return self._channel_copyright
333 def fset(self, t):
334 changed = self._channel_copyright != t
335 self._channel_copyright = t
336 if changed:
337 self.emit('changed')
338 return property(**locals())
340 @apply
341 def number_of_items_stored():
342 doc = ""
343 def fget(self):
344 return self._items_stored
345 def fset(self, num=None):
346 if self._items_stored != num:
347 self._items_stored = num
348 return property(**locals())
350 @apply
351 def poll_frequency():
352 doc = ""
353 def fget(self):
354 return self._poll_freq
355 def fset(self, freq):
356 if self._poll_freq != freq:
357 self._poll_freq = freq
358 return property(**locals())
360 @apply
361 def last_poll():
362 doc = ""
363 def fget(self):
364 return self._last_poll
365 def fset(self, time):
366 if self._last_poll != time:
367 self._last_poll = time
368 return property(**locals())
370 @apply
371 def number_of_unread():
372 doc = "the number of unread items for this feed"
373 def fget(self):
374 return self._n_items_unread
375 def fset(self, n):
376 self._n_items_unread = n
377 self.emit('changed')
378 return property(**locals())
380 @apply
381 def error():
382 doc = ""
383 def fget(self):
384 return self._error
385 def fset(self, error):
386 if self._error != error:
387 self._error = error
388 self.emit('changed')
389 return property(**locals())
391 @apply
392 def process_status():
393 doc = ""
394 def fget(self):
395 return self._process_status
396 def fset(self, status):
397 if status != self._process_status:
398 self._process_status = status
399 self.emit('changed')
400 return property(**locals())
402 @apply
403 def parent():
404 doc = ""
405 def fget(self):
406 return self._parent
407 def fset(self, parent):
408 self._parent = parent
409 return property(**locals())
411 @property
412 def next_refresh(self):
413 """ return the feed's next refresh (time)"""
414 nr = None
415 if self._poll_freq == self.DEFAULT:
416 increment = Config.get_instance().poll_frequency
417 else:
418 increment = self._poll_freq
419 if increment > 0:
420 nr = self.last_poll + increment
421 return nr
423 def dump(self):
424 fl = {}
425 for f, default in self.__save_fields:
426 fl[f] = self.__getattribute__(f)
427 return fl
429 def undump(self, dict):
430 for f, default in self.__save_fields:
431 self.__setattr__(f, dict.get(f, default))
432 return
434 def poll_done(self):
435 self.emit('poll-done')
437 # FeedItems stuff
438 def _sort(self, items):
440 Sorts the given items according to the sort order
442 items = sorted(items, cmp=self._cmp)
443 # XXX
444 #if self._items.sort_order:
445 # items.reverse()
446 return items
448 def _cmp(self, a, b):
450 Comparator method to compare items based on the item's pub_date attribute
452 If an item doesn't have a pub_date, it uses title and prioritizes the
453 unread items
455 try:
456 return cmp(a.pub_date, b.pub_date)
457 except AttributeError:
458 return locale.strcoll(a.title, b.title) and not a.seen or not b.seen
460 def add_items(self, items):
461 config = Config.get_instance()
462 cutpoint = self.number_of_items_stored
463 if cutpoint == Feed.DEFAULT:
464 cutpoint = config.number_of_items_stored
465 items = set(sorted(items, cmp=self._cmp))
466 maxid = reduce(max, [item.id for item in self._items])
467 for item in items:
468 maxid += 1
469 item.id = maxid
470 item.feed = self
471 items.updated(item)
472 self._items = self._items.union(items)
473 self.restore_items(self._items)
474 self.emit('items-updated', items)
476 def restore_items(self, items):
477 olditems = []
478 config = Config.get_instance()
479 cutpoint = self.number_of_items_stored
480 if cutpoint == Feed.DEFAULT:
481 cutpoint = config.number_of_items_stored
482 items = self._sort(items)
483 for idx, item in enumerate(items):
484 item.feed = self
485 if item.sticky or not item.seen or idx <= cutpoint:
486 # forward sticky signal? XXX
487 self._items.add(item)
488 continue
489 else:
490 item.clean_up()
491 olditems.append(item)
492 #self.number_of_items_stored = len(self.items)
493 print "\told: ", len(olditems)
494 print "\tnew: ", len(items)
495 if olditems:
496 self.emit('items-deleted', olditems)
497 return
499 def delete_all_items(self):
500 self.emit('items-deleted', self._items)
502 @property
503 def items(self):
504 if not self._items_loaded:
505 self.load_contents()
506 return self._items
508 def mark_all_items_as_read(self):
509 unread = [item for item in self._items if not item.seen]
510 self.emit('items-read', unread)
512 def load_contents(self):
513 if self._items_loaded:
514 return False
515 itemstore = ItemStore.get_instance()
516 items = itemstore.read_feed_items(self)
517 print "feed.load_contents->items: ", len(items)
518 if items:
519 self.restore_items(items)
520 self._items_loaded = True
521 return self._items_loaded
523 def unload_contents(self):
525 Unloads the items by disconnecting the signals and reinitialising the
526 instance variables
528 TODO: unload seems to lose some circular references. garbage collector
529 will find them, though, so maybe it's not a problem.
531 if not self._items_loaded:
532 return
533 self._items.clear()
534 self._items_loaded = False
536 @classmethod
537 def create_new_feed(klass, title, location="", username="", password=""):
538 f = klass()
539 f._title = title
540 f._location = location
541 f._id = Config.get_instance().next_feed_id_seq()
542 f._username = username
543 f._password = password
544 return f
546 @classmethod
547 def create_empty_feed(klass):
548 f = klass()
549 return f