2119024749aff2773b7f7d619f01d45ba96fee5d
[canto-curses.git] / canto_curses / tagcore.py
1 # -*- coding: utf-8 -*-
2 #Canto-curses - ncurses RSS reader
3 #   Copyright (C) 2014 Jack Miller <jack@codezen.org>
4 #
5 #   This program is free software; you can redistribute it and/or modify
6 #   it under the terms of the GNU General Public License version 2 as 
7 #   published by the Free Software Foundation.
8
9 from canto_next.rwlock import RWLock
10 from canto_next.hooks import call_hook, on_hook
11
12 from .subthread import SubThread
13 from .locks import config_lock
14 from .config import config, story_needed_attrs
15
16 import traceback
17 import logging
18
19 log = logging.getLogger("TAGCORE")
20
21 alltagcores = []
22
23 class TagCore(list):
24     def __init__(self, tag):
25         list.__init__(self)
26         self.tag = tag
27
28         self.changes = False
29         self.was_reset = False
30
31         self.lock = RWLock("lock: %s" % tag)
32         alltagcores.append(self)
33
34     # change functions must be called holding lock
35
36     def ack_changes(self):
37         self.changes = False
38
39     def changed(self):
40         self.changes = True
41
42     def add_items(self, ids):
43         self.lock.acquire_write()
44
45         added = []
46         for id in ids:
47             self.append(id)
48             added.append(id)
49
50         call_hook("curses_items_added", [ self, added ] )
51
52         self.changed()
53         self.lock.release_write()
54
55     def remove_items(self, ids):
56         self.lock.acquire_write()
57
58         removed = []
59
60         # Copy self so we can remove from self
61         # without screwing up iteration.
62
63         for idx, id in enumerate(self[:]):
64             if id in ids:
65                 log.debug("removing: %s", id)
66
67                 list.remove(self, id)
68                 removed.append(id)
69
70         call_hook("curses_items_removed", [ self, removed ] )
71
72         self.changed()
73         self.lock.release_write()
74
75     # Remove all stories from this tag.
76
77     def reset(self):
78
79         # Tag should be sorted on sync if we were reset, regardless of whether
80         # a sync was done when the tag was empty, so keep track of this and
81         # the Tag object will clear it on sync.
82
83         self.was_reset = True
84
85         self.lock.acquire_write()
86
87         if len(self):
88             call_hook("curses_items_removed", [ self, self[:] ])
89         del self[:]
90
91         self.changed()
92         self.lock.release_write()
93
94 class TagUpdater(SubThread):
95     def init(self, backend):
96         SubThread.init(self, backend)
97
98         self.item_tag = None
99         self.item_buf = []
100         self.item_removes = []
101         self.item_adds = []
102
103         self.attributes = {}
104         self.lock = RWLock("tagupdater")
105
106         # Response counters
107         self.discard = 0
108         self.still_updating = 0
109
110         self.start_pthread()
111
112         # Setup automatic attributes.
113
114         # We know we're going to want at least these attributes for
115         # all stories, as they're part of the fallback format string.
116
117         self.needed_attrs = [ "title", "canto-state", "canto-tags", "link", "enclosures" ]
118
119         tsa = config.get_opt("taglist.search_attributes")
120
121         for attrlist in [ story_needed_attrs, tsa ]:
122             for sa in attrlist:
123                 if sa not in self.needed_attrs:
124                     self.needed_attrs.append(sa)
125
126         self.write("AUTOATTR", self.needed_attrs)
127
128         # Lock config_lock so that strtags doesn't change and we miss
129         # tags.
130
131         config_lock.acquire_read()
132
133         strtags = config.get_var("strtags")
134
135         # Request initial information, instantiate TagCores()
136
137         self.write("WATCHTAGS", strtags)
138         for tag in strtags:
139             self.on_new_tag(tag)
140
141         on_hook("curses_new_tag", self.on_new_tag)
142         on_hook("curses_del_tag", self.on_del_tag)
143         on_hook("curses_stories_removed", self.on_stories_removed)
144         on_hook("curses_def_opt_change", self.on_def_opt_change)
145
146         config_lock.release_read()
147
148     def on_new_tag(self, tag):
149         self.write("WATCHTAGS", [ tag ])
150         self.prot_tagchange(tag)
151         call_hook("curses_new_tagcore", [ TagCore(tag) ])
152
153     def on_del_tag(self, tag):
154         for tagcore in alltagcores:
155             if tagcore.tag == tag:
156                 tagcore.reset()
157                 call_hook("curses_del_tagcore", [ tagcore ])
158                 return
159
160     # Once they've been removed from the GUI, their attributes can be forgotten
161     def on_stories_removed(self, tag, items):
162         tagcore = None
163         for tc in alltagcores:
164             if tc.tag == tag.tag:
165                 tagcore = tc
166                 break
167         else:
168             log.warn("Couldn't find tagcore for removed story tag %s" % tag.tag)
169
170         self.lock.acquire_write()
171         for item in items:
172             if tagcore and item.id in tc:
173                 log.debug("%s still in tagcore, not removing", item.id)
174                 continue
175             if item.id in self.attributes:
176                 del self.attributes[item.id]
177         self.lock.release_write()
178
179     # Changes to global filters should force a full refresh.
180
181     def on_def_opt_change(self, defaults):
182         if 'global_transform' in defaults:
183             log.debug("global_transform changed, forcing reset + update")
184             self.reset(True)
185             self.update()
186
187     def prot_attributes(self, d):
188         if self.discard:
189             return
190
191         # Update attributes, and then notify everyone to grab new content.
192         self.lock.acquire_write()
193
194         for key in d.keys():
195             if key in self.attributes:
196
197                 # If we're updating, we want to create a whole new dict object
198                 # so that our stories dicts don't get updated without a sync
199
200                 cp = self.attributes[key].copy()
201                 cp.update(d[key])
202                 self.attributes[key] = cp
203             else:
204                 self.attributes[key] = d[key]
205         self.lock.release_write()
206
207         call_hook("curses_attributes", [ self.attributes ])
208
209     def prot_items(self, updates):
210         if self.discard:
211             return
212
213         # Daemon should now only return with one tag in an items response
214
215         tag = list(updates.keys())[0]
216
217         if self.item_tag == None or self.item_tag.tag != tag:
218             self.item_tag = None
219             self.item_buf = []
220             self.item_removes = []
221             self.item_adds = []
222             for have_tag in alltagcores:
223                 if have_tag.tag == tag:
224                     self.item_tag = have_tag
225                     break
226
227             # Shouldn't happen
228             else:
229                 return
230
231         self.item_buf.extend(updates[tag])
232
233         # Add new items.
234         for id in updates[tag]:
235             if id not in self.item_tag:
236                 self.item_adds.append(id)
237
238     def prot_itemsdone(self, empty):
239         if self.item_tag == None:
240             return
241
242         if self.discard:
243             self.item_tag = None
244             self.item_buf = []
245             self.item_removes = []
246             self.item_adds = []
247             return
248
249         if self.item_adds:
250             self.item_tag.add_items(self.item_adds)
251
252         # Eliminate discarded items. This has to be done here, so we have
253         # access to all of the items given in the multiple ITEM responses.
254
255         for id in self.item_tag:
256             if id not in self.item_buf:
257                 self.item_removes.append(id)
258
259         if self.item_removes:
260             self.item_tag.remove_items(self.item_removes)
261
262         self.item_tag = None
263         self.item_buf = []
264         self.item_removes = []
265         self.item_adds = []
266
267         if self.still_updating:
268             self.still_updating -= 1
269             if not self.still_updating:
270                 log.debug("Calling curses_update_complete")
271                 call_hook("curses_update_complete", [])
272
273     def prot_tagchange(self, tag):
274         self.write("ITEMS", [ tag ])
275
276     def prot_pong(self, args):
277         self.discard -= 1
278
279     # The following is the external interface to tagupdater.
280
281     def update(self):
282         strtags = config.get_var("strtags")
283         for tag in strtags:
284             self.write("ITEMS", [ tag ])
285             self.still_updating += 1
286
287     def reset(self, force=False):
288         if self.still_updating and not force:
289             log.debug("Not initiating refresh, update still in progress")
290             return False
291
292         for tag in alltagcores:
293             tag.reset()
294         self.discard += 1
295         self.write("PING", [])
296         return True
297
298     def transform(self, name, transform):
299         self.write("TRANSFORM", { name : transform })
300         self.reset(True)
301
302     # Writes are already serialized, so in the meantime, we protect
303     # self.attributes and self.needed_attrs with our lock.
304
305     def get_attributes(self, id):
306         r = {}
307         self.lock.acquire_read()
308         if id in self.attributes:
309             r = self.attributes[id]
310         self.lock.release_read()
311         return r
312
313     # This takes a fat argument because callers need to be able to curry
314     # together multiple sets so stuff like 'item-state read *' don't generate
315     # thousands of SETATTRIBUTES calls and take forever
316
317     def set_attributes(self, arg):
318         self.lock.acquire_write()
319         self.write("SETATTRIBUTES", arg)
320         self.lock.release_write()
321
322     def request_attributes(self, id, attrs):
323         self.write("ATTRIBUTES", { id : attrs })
324
325     def need_attributes(self, id, attrs):
326         self.lock.acquire_write()
327
328         needed = self.needed_attrs[:]
329         updated = False
330
331         for attr in attrs:
332             if attr not in needed:
333                 needed.append(attr)
334                 updated = True
335
336         if updated:
337             self.needed_attrs = needed
338             self.write("AUTOATTR", self.needed_attrs)
339
340         self.lock.release_write()
341
342         # Even if we didn't update this time, make sure we attempt to get this
343         # id's new needed attributes.
344
345         self.write("ATTRIBUTES", { id : needed })
346
347 tag_updater = TagUpdater()