feed list view and feed/category event handling fixes
[straw.git] / src / lib / FeedCategoryList.py
blob5d74596bbb498c37acb8320a6efbd571cf869d9b
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 gobject
22 import feeds
23 from StringIO import StringIO
24 import Config
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(gobject.GObject):
36 __gsignals__ = {
37 'added' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
38 'deleted' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)),
39 'category-changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
40 'pseudo-changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ())
43 def __init__(self):
44 gobject.GObject.__init__(self)
45 # have to define this here so the titles can be translated
46 PSEUDO_TITLES = {PSEUDO_ALL_KEY: _('All'),
47 PSEUDO_UNCATEGORIZED_KEY: _('Uncategorized')}
48 self._all_category = PseudoCategory(PSEUDO_TITLES[PSEUDO_ALL_KEY],
49 PSEUDO_ALL_KEY)
50 self._un_category = PseudoCategory(
51 PSEUDO_TITLES[PSEUDO_UNCATEGORIZED_KEY], PSEUDO_UNCATEGORIZED_KEY)
52 self._user_categories = []
53 self._pseudo_categories = (self._all_category, self._un_category)
54 self._loading = False
55 self._feedlist = feeds.get_instance()
56 self._feedlist.connect('deleted', self.feed_deleted)
57 self._feedlist.connect('updated', self.feed_created)
58 self._feedlist.connect('imported', self.feeds_imported)
60 def load_data(self):
61 """Loads categories from config.
62 Data format: [[{'title': '', 'subscription': {}, 'pseudo': pseudo key},
63 {'id', feed_id1, 'from_subscription': bool} ...], ...]
64 """
65 cats = Config.get_instance().categories or []
66 categorized = {}
67 for c in cats:
68 head = c[0]
69 tail = c[1:]
70 pseudo_key = head.get('pseudo', None)
71 if pseudo_key == PSEUDO_ALL_KEY:
72 fc = self.all_category
73 elif pseudo_key == PSEUDO_UNCATEGORIZED_KEY:
74 fc = self.un_category
75 else:
76 fc = FeedCategory(head['title'])
77 sub = head.get('subscription', None)
78 if sub is not None:
79 fc.subscription = undump_subscription(sub)
80 for f in tail:
81 # backwards compatibility for stuff saved with versions <= 0.23
82 if type(f) is int:
83 fid = f
84 from_sub = False
85 else:
86 fid = f['id']
87 from_sub = f['from_subscription']
88 feed = self._feedlist.get_feed_with_id(fid)
89 if feed and not pseudo_key: # we deal with pseudos later
90 if feed in fc.feeds:
91 error.log("%s (%d) was already in %s, skipping" % (str(feed), fid, str(fc)))
92 continue
93 fc.append_feed(feed, from_sub)
94 categorized[feed] = True
95 # User categories: connect pseudos later
96 if not pseudo_key:
97 fc.connect('changed', self.category_changed)
98 self._user_categories.append(fc)
99 # just in case we've missed any feeds, go through the list
100 # and add to the pseudocategories. cache the feed list of all_category
101 # so we don't get a function call (and a list comprehension loop
102 # inside it) on each feed. it should be ok here, there are no
103 # duplicates in feedlist. right?
104 pseudos_changed = False
105 all_feeds = self.all_category.feeds
106 for f in self._feedlist:
107 if f not in all_feeds:
108 self.all_category.append_feed(f, False)
109 pseudos_changed = True
110 uf = categorized.get(f, None)
111 if uf is None:
112 self.un_category.append_feed(f, False)
113 pseudos_changed = True
114 if pseudos_changed:
115 self.save_data()
116 for cat in self.pseudo_categories:
117 cat.connect('changed', self.pseudo_category_changed)
119 def save_data(self):
120 Config.get_instance().categories = [
121 cat.dump() for cat in self]
123 def pseudo_category_changed(self, signal):
124 self.save_data()
125 self.emit('pseudo-changed')
127 def category_changed(self, signal):
128 if signal.feed is not None:
129 uncategorized = True
130 for cat in self.user_categories:
131 if signal.feed in cat.feeds:
132 uncategorized = False
133 break
134 if uncategorized:
135 self.un_category.append_feed(signal.feed, False)
136 else:
137 try:
138 self.un_category.remove_feed(signal.feed)
139 except ValueError:
140 pass
141 self.save_data()
142 self.emit('category-changed')
144 def feed_deleted(self, feedlist, feed):
145 for c in self:
146 try:
147 c.remove_feed(feed)
148 except ValueError:
149 pass
151 def feed_created(self, feedlist, value, category, index):
152 if category and category not in self.pseudo_categories:
153 if index:
154 category.insert_feed(index, value, False)
155 else:
156 category.append_feed(value, False)
157 else:
158 self.un_category.append_feed(value, False)
159 self.all_category.append_feed(value, False)
161 def feeds_imported(self, feedlist, feeds, category, from_sub):
162 if category and category not in self.pseudo_categories:
163 category.extend_feed(feeds, from_sub)
164 else:
165 print 'feed imported ', feeds
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.connect('changed', self.category_changed)
218 self._user_categories.append(category)
219 auxlist = [(x.title.lower(),x) for x in self._user_categories]
220 auxlist.sort()
221 self._user_categories = [x[1] for x in auxlist]
222 self.save_data()
223 self.emit('added', category)
225 def remove_category(self, category):
226 for feed in category.feeds:
227 category.remove_feed(feed)
228 self._user_categories.remove(category)
229 self.save_data()
230 self.emit('deleted', category)
232 # It might be good to have a superclass FeedCategorySubscription or something
233 # so we could support different formats. However, I don't know of any other
234 # relevant format used for this purpose, so that can be done later if needed.
235 # Of course, they could also just implement the same interface.
236 class OPMLCategorySubscription(gobject.GObject):
237 REFRESH_DEFAULT = -1
239 __gsignals__ = {
240 'changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()),
241 'updated' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ())
244 def __init__(self, location=None):
245 gobject.GObject.__init__(self)
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('changed')
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('changed')
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('changed')
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('changed')
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('changed')
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('changed')
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('updated')
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(gobject.GObject):
382 __gsignals__ = {
383 'changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
384 (gobject.TYPE_PYOBJECT,))
387 def __init__(self, title=""):
388 gobject.GObject.__init__(self)
389 self.feedlist = []
390 self._title = title
391 self._subscription = None
393 @apply
394 def title():
395 doc = ""
396 def fget(self):
397 return self._title
398 def fset(self, title):
399 self._title = title
400 self.emit('changed', None)
401 return property(**locals())
403 @apply
404 def subscription():
405 doc = ""
406 def fget(self):
407 return self._subscription
408 def fset(self, sub):
409 self._subscription = sub
410 self._subscription.connect('changed', self._subscription_changed)
411 self._subscription.connect('updated', self._subscription_contents_updated)
412 self.emit('changed', None)
413 return property(**locals())
415 def read_contents_from_subscription(self):
416 if self.subscription is None:
417 return
418 subfeeds = self.subscription.contents
419 sfdict = dict(subfeeds)
420 feedlist = feeds.get_instance()
421 current = dict([(feed.location, feed) for feed in self.feeds])
422 allfeeds = dict([(feed.location, feed) for feed in feedlist])
423 common, toadd, toremove = utils.listdiff(sfdict.keys(), current.keys())
424 existing, nonexisting, ignore = utils.listdiff(
425 toadd, allfeeds.keys())
427 newfeeds = [feeds.Feed.create_new_feed(sfdict[f], f) for f in nonexisting]
428 feedlist.extend(self.feedlist, newfeeds, from_sub=True) # will call extend_feed
429 self.extend_feed([allfeeds[f] for f in existing], True)
431 for f in toremove:
432 index = self.index_feed(allfeeds[f])
433 member = self.feedlist[index]
434 if member.from_subscription:
435 self.remove_feed(allfeeds[f])
436 return
438 def _subscription_changed(self, *args):
439 self.emit('changed', None)
441 def _subscription_contents_updated(self, *args):
442 self.read_contents_from_subscription()
444 def __str__(self):
445 return "FeedCategory %s" % self.title
447 def __hash__(self):
448 return hash(id(self))
450 def append(self, value):
451 error.log("warning, probably should be using append_feed?")
452 self.feedlist.append(value)
453 self.emit('changed', value.feed)
455 def append_feed(self, value, from_sub):
456 self.feedlist.append(CategoryMember(value, from_sub))
457 self.emit('changed', value)
459 def extend_feed(self, values, from_sub):
460 self.feedlist.extend([CategoryMember(v, from_sub) for v in values])
461 self.emit('changed', None)
463 def insert(self, index, value):
464 error.log("warning, probably should be using insert_feed?")
465 self.feedlist.insert(index, value)
466 self.emit('changed', value.feed)
468 def insert_feed(self, index, value, from_sub):
469 self.feedlist.insert(index, CategoryMember(value, from_sub))
470 self.emit('changed', value)
472 def remove(self, value):
473 self.feedlist.remove(value)
474 self.emit('changed', value.feed)
476 def remove_feed(self, value):
477 for index, member in enumerate(self.feedlist):
478 if member.feed is value:
479 del self.feedlist[index]
480 break
481 else:
482 raise ValueError(value)
483 self.emit('changed', value)
485 def reverse(self):
486 self.feedlist.reverse()
487 self.emit('changed') # reverse=True))
489 def index_feed(self, value):
490 for index, f in enumerate(self.feedlist):
491 if self.feedlist[index].feed is value:
492 return index
493 raise ValueError(value)
495 def _sort_dsu(self, seq):
496 aux_list = [(x.feed.title.lower(), x) for x in seq]
497 aux_list.sort(lambda a,b:locale.strcoll(a[0],b[0]))
498 return [x[1] for x in aux_list]
500 def sort(self, indices=None):
501 if not indices or len(indices) == 1:
502 self.feedlist[:] = self._sort_dsu(self.feedlist)
503 else:
504 items = self._sort_dsu(indices)
505 for i,x in enumerate(items):
506 list.__setitem__(self.feedlist, indices[i], items[i])
507 self.emit('changed')
509 def move_feed(self, source, target):
510 if target > source:
511 target -= 1
512 if target == source:
513 return
514 t = self[source]
515 del self[source]
516 self.feedlist.insert(target, t)
517 self.emit('changed')
519 def dump(self):
520 head = {'title': self.title}
521 if self.subscription is not None:
522 head['subscription'] = self.subscription.dump()
523 return [head] + [
524 {'id': f.feed.id, 'from_subscription': f.from_subscription}
525 for f in self.feedlist]
527 @property
528 def feeds(self):
529 return [f.feed for f in self.feedlist]
531 def __eq__(self, ob):
532 if isinstance(ob, types.NoneType):
533 return 0
534 elif isinstance(ob, FeedCategory):
535 return self.title == ob.title and list.__eq__(self.feedlist, ob)
536 else:
537 raise NotImplementedError
539 def __contains__(self, item):
540 error.log("warning, should probably be querying the feeds property instead?")
541 return list.__contains__(self.feedlist, item)
543 class PseudoCategory(FeedCategory):
544 def __init__(self, title="", key=None):
545 if key not in (PSEUDO_ALL_KEY, PSEUDO_UNCATEGORIZED_KEY):
546 raise ValueError, "Invalid key"
547 FeedCategory.__init__(self, title)
548 self._pseudo_key = key
550 def __str__(self):
551 return "PseudoCategory %s" % self.title
553 def dump(self):
554 return [{'pseudo': self._pseudo_key, 'title': ''}] + [
555 {'id': f.feed.id, 'from_subscription': False} for f in self]
557 def append_feed(self, feed, from_sub):
558 assert not from_sub
559 FeedCategory.append_feed(self, feed, False)
561 def insert_feed(self, index, feed, from_sub):
562 assert not from_sub
563 FeedCategory.insert_feed(self, index, feed, False)
565 fclist = None
567 def get_instance():
568 global fclist
569 if fclist is None:
570 fclist = FeedCategoryList()
571 return fclist