iteration 1 - Use gobject for events in feed, summaryitem and feedlist
[straw.git] / src / lib / FeedCategoryList.py
blob33c6418423141695d6d81068974099e178d97b9d
1 """ FeedCategoryList.py
3 Module for feed categories and category subscriptions.
4 """
5 __copyright__ = "Copyright (c) 2002-2005 Free Software Foundation, Inc."
6 __license__ = """ GNU General Public License
8 Straw is free software; you can redistribute it and/or modify it under the
9 terms of the GNU General Public License as published by the Free Software
10 Foundation; either version 2 of the License, or (at your option) any later
11 version.
13 Straw is distributed in the hope that it will be useful, but WITHOUT ANY
14 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
15 A PARTICULAR PURPOSE. See the GNU General Public License for more details.
17 You should have received a copy of the GNU General Public License along with
18 this program; if not, write to the Free Software Foundation, Inc., 59 Temple
19 Place - Suite 330, Boston, MA 02111-1307, USA. """
21 import feeds
22 from StringIO import StringIO
23 import Config
24 import Event
25 import OPMLImport
26 import error
27 import locale
28 import utils
29 import types
31 PSEUDO_ALL_KEY = 'ALL'
32 PSEUDO_UNCATEGORIZED_KEY = 'UNCATEGORIZED'
34 class FeedCategoryList(object, Event.SignalEmitter):
35 def __init__(self):
36 Event.SignalEmitter.__init__(self)
37 self.initialize_slots(Event.FeedCategoryChangedSignal,
38 Event.FeedCategorySortedSignal,
39 Event.FeedCategoryAddedSignal,
40 Event.FeedCategoryRemovedSignal,
41 Event.FeedCategoryListLoadedSignal)
42 # have to define this here so the titles can be translated
43 PSEUDO_TITLES = {PSEUDO_ALL_KEY: _('All'),
44 PSEUDO_UNCATEGORIZED_KEY: _('Uncategorized')}
45 self._all_category = PseudoCategory(PSEUDO_TITLES[PSEUDO_ALL_KEY],
46 PSEUDO_ALL_KEY)
47 self._un_category = PseudoCategory(
48 PSEUDO_TITLES[PSEUDO_UNCATEGORIZED_KEY], PSEUDO_UNCATEGORIZED_KEY)
49 self._user_categories = []
50 self._pseudo_categories = (self._all_category, self._un_category)
51 self._loading = False
52 self._feedlist = feeds.get_instance()
53 self._feedlist.connect('deleted', self.feed_deleted)
54 self._feedlist.connect('updated', self.feed_created)
55 self._feedlist.connect('imported', self.feeds_imported)
57 def load_data(self):
58 """Loads categories from config.
59 Data format: [[{'title': '', 'subscription': {}, 'pseudo': pseudo key},
60 {'id', feed_id1, 'from_subscription': bool} ...], ...]
61 """
62 cats = Config.get_instance().categories or []
63 categorized = {}
64 for c in cats:
65 head = c[0]
66 tail = c[1:]
67 pseudo_key = head.get('pseudo', None)
68 if pseudo_key == PSEUDO_ALL_KEY:
69 fc = self.all_category
70 elif pseudo_key == PSEUDO_UNCATEGORIZED_KEY:
71 fc = self.un_category
72 else:
73 fc = FeedCategory(head['title'])
74 sub = head.get('subscription', None)
75 if sub is not None:
76 fc.subscription = undump_subscription(sub)
77 for f in tail:
78 # backwards compatibility for stuff saved with versions <= 0.23
79 if type(f) is int:
80 fid = f
81 from_sub = False
82 else:
83 fid = f['id']
84 from_sub = f['from_subscription']
85 feed = self._feedlist.get_feed_with_id(fid)
86 if feed is not None:
87 if feed in fc.feeds:
88 error.log("%s (%d) was already in %s, skipping" % (str(feed), fid, str(fc)))
89 continue
91 fc.append_feed(feed, from_sub)
92 categorized[feed] = True
93 # User categories: connect pseudos later
94 if not pseudo_key:
95 fc.signal_connect(Event.FeedCategoryChangedSignal,
96 self.category_changed)
97 self._user_categories.append(fc)
98 # just in case we've missed any feeds, go through the list
99 # and add to the pseudocategories. cache the feed list of all_category
100 # so we don't get a function call (and a list comprehension loop
101 # inside it) on each feed. it should be ok here, there are no
102 # duplicates in feedlist. right?
103 pseudos_changed = False
104 all_feeds = self.all_category.feeds
105 for f in self._feedlist:
106 if f not in all_feeds:
107 self.all_category.append_feed(f, False)
108 pseudos_changed = True
109 uf = categorized.get(f, None)
110 if uf is None:
111 self.un_category.append_feed(f, False)
112 pseudos_changed = True
113 if pseudos_changed:
114 self.save_data()
115 for cat in self.pseudo_categories:
116 cat.signal_connect(
117 Event.FeedCategoryChangedSignal, self.pseudo_category_changed)
118 self.emit_signal(Event.FeedCategoryListLoadedSignal(self))
120 def save_data(self):
121 Config.get_instance().categories = [
122 cat.dump() for cat in self]
124 def pseudo_category_changed(self, signal):
125 self.save_data()
126 self.emit_signal(signal)
128 def category_changed(self, signal):
129 if signal.feed is not None:
130 uncategorized = True
131 for cat in self.user_categories:
132 if signal.feed in cat.feeds:
133 uncategorized = False
134 break
135 if uncategorized:
136 self.un_category.append_feed(signal.feed, False)
137 else:
138 try:
139 self.un_category.remove_feed(signal.feed)
140 except ValueError:
141 pass
142 self.save_data()
143 self.emit_signal(signal)
145 def feed_deleted(self, feedlist, feed):
146 for c in self:
147 try:
148 c.remove_feed(feed)
149 except ValueError:
150 pass
152 def feed_created(self, feedlist, value, category, index):
153 if category and category not in self.pseudo_categories:
154 if index:
155 category.insert_feed(index, value, False)
156 else:
157 category.append_feed(value, False)
158 else:
159 self.un_category.append_feed(value, False)
160 self.all_category.append_feed(value, False)
162 def feeds_imported(self, feedlist, feeds, category, from_sub):
163 if category and category not in self.pseudo_categories:
164 category.extend_feed(feeds, from_sub)
165 else:
166 self.un_category.extend_feed(feeds, from_sub)
167 self.all_category.extend_feed(feeds, from_sub)
168 return
170 @property
171 def user_categories(self):
172 return self._user_categories
174 @property
175 def pseudo_categories(self):
176 return self._pseudo_categories
178 @property
179 def all_categories(self):
180 return self.pseudo_categories + tuple(self.user_categories)
182 @property
183 def all_category(self):
184 return self._all_category
186 @property
187 def un_category(self):
188 return self._un_category
190 class CategoryIterator:
191 def __init__(self, fclist):
192 self._fclist = fclist
193 self._index = -1
195 def __iter__(self):
196 return self
198 def _next(self):
199 self._index += 1
200 i = self._index
201 uclen = len(self._fclist.user_categories)
202 if i < uclen:
203 return self._fclist.user_categories[i]
204 elif i < uclen + len(self._fclist.pseudo_categories):
205 return self._fclist.pseudo_categories[i - uclen]
206 else:
207 raise StopIteration
209 def next(self):
210 v = self._next()
211 return v
213 def __iter__(self):
214 return self.CategoryIterator(self)
216 def add_category(self, category):
217 category.signal_connect(Event.FeedCategoryChangedSignal,
218 self.category_changed)
219 self._user_categories.append(category)
220 auxlist = [(x.title.lower(),x) for x in self._user_categories]
221 auxlist.sort()
222 self._user_categories = [x[1] for x in auxlist]
223 self.emit_signal(Event.FeedCategoryAddedSignal(self, category))
224 self.save_data()
226 def remove_category(self, category):
227 for feed in category.feeds:
228 category.remove_feed(feed)
229 category.signal_disconnect(Event.FeedCategoryChangedSignal,
230 self.category_changed)
231 self._user_categories.remove(category)
232 self.emit_signal(Event.FeedCategoryRemovedSignal(self, category))
233 self.save_data()
235 # It might be good to have a superclass FeedCategorySubscription or something
236 # so we could support different formats. However, I don't know of any other
237 # relevant format used for this purpose, so that can be done later if needed.
238 # Of course, they could also just implement the same interface.
239 class OPMLCategorySubscription(object, Event.SignalEmitter):
240 REFRESH_DEFAULT = -1
242 def __init__(self, location=None):
243 Event.SignalEmitter.__init__(self)
244 self.initialize_slots(Event.FeedCategoryChangedSignal,
245 Event.SubscriptionContentsUpdatedSignal)
246 self._location = location
247 self._username = None
248 self._password = None
249 self._contents = None
250 self._frequency = OPMLCategorySubscription.REFRESH_DEFAULT
251 self._last_poll = 0
252 self._error = None
254 @apply
255 def location():
256 doc = ""
257 def fget(self):
258 return self._location
259 def fset(self, location):
260 self._location = location
261 self.emit_signal(Event.FeedCategoryChangedSignal(self))
262 return property(**locals())
264 @apply
265 def username():
266 doc = ""
267 def fget(self):
268 return self._username
269 def fset(self, username):
270 self._username = username
271 self.emit_signal(Event.FeedCategoryChangedSignal(self))
272 return property(**locals())
274 @apply
275 def password():
276 doc = ""
277 def fget(self):
278 return self._password
279 def fset(self):
280 self._password = password
281 self.emit_signal(Event.FeedCategoryChangedSignal(self))
282 return property(**locals())
284 @apply
285 def frequency():
286 doc = ""
287 def fget(self):
288 return self._frequency
289 def fset(self, freq):
290 self._frequency = freq
291 self.emit_signal(Event.FeedCategoryChangedSignal(self))
292 return property(**locals())
294 @apply
295 def last_poll():
296 doc = ""
297 def fget(self):
298 return self._last_poll
299 def fset(self, last_poll):
300 self._last_poll = last_poll
301 self.emit_signal(Event.FeedCategoryChangedSignal(self))
302 return property(**locals())
304 @apply
305 def error():
306 doc = ""
307 def fget(self):
308 return self._error
309 def fset(self, error):
310 self._error = error
311 self.emit_signal(Event.FeedCategoryChangedSignal(self))
312 return property(**locals())
314 def parse(self, data):
315 datastream = StringIO(data)
316 entries = OPMLImport.read(datastream)
317 contents = [(e.url, e.text) for e in entries]
318 updated = contents == self._contents
319 self._contents = contents
320 if updated:
321 self.emit_signal(Event.SubscriptionContentsUpdatedSignal(self))
322 return
324 @property
325 def contents(self):
326 return self._contents
328 @classmethod
329 def undump(klass, dictionary):
330 sub = klass()
331 sub.location = dictionary.get('location')
332 sub.username = dictionary.get('username')
333 sub.password = dictionary.get('password')
334 sub.frequency = dictionary.get(
335 'frequency', OPMLCategorySubscription.REFRESH_DEFAULT)
336 sub.last_poll = dictionary.get('last_poll', 0)
337 sub.error = dictionary.get('error')
338 return sub
340 def dump(self):
341 return {'type': 'opml',
342 'location': self.location,
343 'username': self.username,
344 'password': self.password,
345 'frequency': self.frequency,
346 'last_poll': self.last_poll,
347 'error': self.error}
349 def undump_subscription(dictionary):
350 try:
351 if dictionary.get('type') == 'opml':
352 return OPMLCategorySubscription.undump(dictionary)
353 except Exception, e:
354 error.log("exception while undumping subscription: " + str(e))
355 raise
357 class CategoryMember(object):
358 def __init__(self, feed=None, from_sub=False):
359 self._feed = feed
360 self._from_subscription = from_sub
362 @apply
363 def feed():
364 doc = ""
365 def fget(self):
366 return self._feed
367 def fset(self, feed):
368 self._feed = feed
369 return property(**locals())
371 @apply
372 def from_subscription():
373 doc = ""
374 def fget(self):
375 return self._from_subscription
376 def fset(self, p):
377 self._from_subscription = p
378 return property(**locals())
380 class FeedCategory(list, Event.SignalEmitter):
381 def __init__(self, title=""):
382 Event.SignalEmitter.__init__(self)
383 self.initialize_slots(Event.FeedCategoryChangedSignal,
384 Event.FeedCategorySortedSignal)
385 self._title = title
386 self._subscription = None
388 @apply
389 def title():
390 doc = ""
391 def fget(self):
392 return self._title
393 def fset(self, title):
394 self._title = title
395 self.emit_signal(Event.FeedCategoryChangedSignal(self))
396 return property(**locals())
398 @apply
399 def subscription():
400 doc = ""
401 def fget(self):
402 return self._subscription
403 def fset(self, sub):
404 if self._subscription:
405 self._subscription.signal_disconnect(
406 Event.FeedCategoryChangedSignal, self._subscription_changed)
407 self._subscription.signal_disconnect(
408 Event.SubscriptionContentsUpdatedSignal,
409 self._subscription_contents_updated)
410 if sub:
411 sub.signal_connect(Event.FeedCategoryChangedSignal,
412 self._subscription_changed)
413 sub.signal_connect(Event.SubscriptionContentsUpdatedSignal,
414 self._subscription_contents_updated)
415 self._subscription = sub
416 self.emit_signal(Event.FeedCategoryChangedSignal(self))
417 return property(**locals())
419 def read_contents_from_subscription(self):
420 if self.subscription is None:
421 return
422 subfeeds = self.subscription.contents
423 sfdict = dict(subfeeds)
424 feedlist = feeds.get_instance()
425 current = dict([(feed.location, feed) for feed in self.feeds])
426 allfeeds = dict([(feed.location, feed) for feed in feedlist])
427 common, toadd, toremove = utils.listdiff(sfdict.keys(), current.keys())
428 existing, nonexisting, ignore = utils.listdiff(
429 toadd, allfeeds.keys())
431 newfeeds = [feeds.Feed.create_new_feed(sfdict[f], f) for f in nonexisting]
432 feedlist.extend(self, newfeeds, from_sub=True) # will call extend_feed
433 self.extend_feed([allfeeds[f] for f in existing], True)
435 for f in toremove:
436 index = self.index_feed(allfeeds[f])
437 member = self[index]
438 if member.from_subscription:
439 self.remove_feed(allfeeds[f])
440 return
442 def _subscription_changed(self, signal):
443 self.emit_signal(Event.FeedCategoryChangedSignal(self))
445 def _subscription_contents_updated(self, signal):
446 self.read_contents_from_subscription()
448 def __str__(self):
449 return "FeedCategory %s" % self.title
451 def __hash__(self):
452 return hash(id(self))
454 def append(self, value):
455 error.log("warning, probably should be using append_feed?")
456 list.append(self, value)
457 self.emit_signal(Event.FeedCategoryChangedSignal(self, feed=value.feed))
459 def append_feed(self, value, from_sub):
460 list.append(self, CategoryMember(value, from_sub))
461 self.emit_signal(Event.FeedCategoryChangedSignal(self, feed=value))
463 def extend_feed(self, values, from_sub):
464 list.extend(self, [CategoryMember(v, from_sub) for v in values])
465 self.emit_signal(Event.FeedCategoryChangedSignal(self))
467 def insert(self, index, value):
468 error.log("warning, probably should be using insert_feed?")
469 list.insert(self, index, value)
470 self.emit_signal(Event.FeedCategoryChangedSignal(self, feed=value.feed))
472 def insert_feed(self, index, value, from_sub):
473 list.insert(self, index, CategoryMember(value, from_sub))
474 self.emit_signal(Event.FeedCategoryChangedSignal(self, feed=value))
476 def remove(self, value):
477 list.remove(self, value)
478 self.emit_signal(Event.FeedCategoryChangedSignal(self, feed=value.feed))
480 def remove_feed(self, value):
481 for index, member in enumerate(self):
482 if member.feed is value:
483 del self[index]
484 break
485 else:
486 raise ValueError(value)
487 self.emit_signal(Event.FeedCategoryChangedSignal(self, feed=value))
489 def reverse(self):
490 list.reverse(self)
491 self.emit_signal(Event.FeedCategorySortedSignal(self,
492 reverse=True))
494 def index_feed(self, value):
495 for index, f in enumerate(self):
496 if self[index].feed is value:
497 return index
498 raise ValueError(value)
500 def _sort_dsu(self, seq):
501 aux_list = [(x.feed.title.lower(), x) for x in seq]
502 aux_list.sort(lambda a,b:locale.strcoll(a[0],b[0]))
503 return [x[1] for x in aux_list]
505 def sort(self, indices=None):
506 if not indices or len(indices) == 1:
507 self[:] = self._sort_dsu(self)
508 else:
509 items = self._sort_dsu(indices)
510 for i,x in enumerate(items):
511 list.__setitem__(self, indices[i], items[i])
512 self.emit_signal(Event.FeedCategorySortedSignal(self))
514 def move_feed(self, source, target):
515 if target > source:
516 target -= 1
517 if target == source:
518 return
519 t = self[source]
520 del self[source]
521 list.insert(self, target, t)
522 self.emit_signal(Event.FeedCategoryChangedSignal(self))
524 def dump(self):
525 head = {'title': self.title}
526 if self.subscription is not None:
527 head['subscription'] = self.subscription.dump()
528 return [head] + [
529 {'id': f.feed.id, 'from_subscription': f.from_subscription}
530 for f in self]
532 @property
533 def feeds(self):
534 return [f.feed for f in self]
536 def __eq__(self, ob):
537 if isinstance(ob, types.NoneType):
538 return 0
539 elif isinstance(ob, FeedCategory):
540 return self.title == ob.title and list.__eq__(self, ob)
541 else:
542 raise NotImplementedError
544 def __contains__(self, item):
545 error.log("warning, should probably be querying the feeds property instead?")
546 return list.__contains__(self, item)
548 class PseudoCategory(FeedCategory):
549 def __init__(self, title="", key=None):
550 if key not in (PSEUDO_ALL_KEY, PSEUDO_UNCATEGORIZED_KEY):
551 raise ValueError, "Invalid key"
552 FeedCategory.__init__(self, title)
553 self._pseudo_key = key
555 def __str__(self):
556 return "PseudoCategory %s" % self.title
558 def dump(self):
559 return [{'pseudo': self._pseudo_key, 'title': ''}] + [
560 {'id': f.feed.id, 'from_subscription': False} for f in self]
562 def append_feed(self, feed, from_sub):
563 assert not from_sub
564 FeedCategory.append_feed(self, feed, False)
566 def insert_feed(self, index, feed, from_sub):
567 assert not from_sub
568 FeedCategory.insert_feed(self, index, feed, False)
570 fclist = None
572 def get_instance():
573 global fclist
574 if fclist is None:
575 fclist = FeedCategoryList()
576 return fclist