1# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> 2# This file is released under the GNU GPL, version 3 or a later revision. 3# For further details see the COPYING file 4from datetime import datetime 5 6from .message import Message 7from ..settings.const import settings 8 9 10class Thread: 11 """ 12 A wrapper around a notmuch mailthread (:class:`notmuch.database.Thread`) 13 that ensures persistence of the thread: It can be safely read multiple 14 times, its manipulation is done via a :class:`alot.db.DBManager` and it can 15 directly provide contained messages as :class:`~alot.db.message.Message`. 16 """ 17 18 def __init__(self, dbman, thread): 19 """ 20 :param dbman: db manager that is used for further lookups 21 :type dbman: :class:`~alot.db.DBManager` 22 :param thread: the wrapped thread 23 :type thread: :class:`notmuch.database.Thread` 24 """ 25 self._dbman = dbman 26 self._authors = None 27 self._id = thread.get_thread_id() 28 self._messages = {} 29 self._tags = set() 30 31 self.refresh(thread) 32 33 def refresh(self, thread=None): 34 """refresh thread metadata from the index""" 35 if not thread: 36 thread = self._dbman._get_notmuch_thread(self._id) 37 38 self._total_messages = thread.get_total_messages() 39 self._notmuch_authors_string = thread.get_authors() 40 41 subject_type = settings.get('thread_subject') 42 if subject_type == 'notmuch': 43 subject = thread.get_subject() 44 elif subject_type == 'oldest': 45 try: 46 first_msg = list(thread.get_toplevel_messages())[0] 47 subject = first_msg.get_header('subject') 48 except IndexError: 49 subject = '' 50 self._subject = subject 51 52 self._authors = None 53 ts = thread.get_oldest_date() 54 55 try: 56 self._oldest_date = datetime.fromtimestamp(ts) 57 except ValueError: # year is out of range 58 self._oldest_date = None 59 try: 60 timestamp = thread.get_newest_date() 61 self._newest_date = datetime.fromtimestamp(timestamp) 62 except ValueError: # year is out of range 63 self._newest_date = None 64 65 self._tags = {t for t in thread.get_tags()} 66 self._messages = {} # this maps messages to its children 67 self._toplevel_messages = [] 68 69 def __str__(self): 70 return "thread:%s: %s" % (self._id, self.get_subject()) 71 72 def get_thread_id(self): 73 """returns id of this thread""" 74 return self._id 75 76 def get_tags(self, intersection=False): 77 """ 78 returns tagsstrings attached to this thread 79 80 :param intersection: return tags present in all contained messages 81 instead of in at least one (union) 82 :type intersection: bool 83 :rtype: set of str 84 """ 85 tags = set(list(self._tags)) 86 if intersection: 87 for m in self.get_messages().keys(): 88 tags = tags.intersection(set(m.get_tags())) 89 return tags 90 91 def add_tags(self, tags, afterwards=None, remove_rest=False): 92 """ 93 add `tags` to all messages in this thread 94 95 .. note:: 96 97 This only adds the requested operation to this objects 98 :class:`DBManager's <alot.db.DBManager>` write queue. 99 You need to call :meth:`DBManager.flush <alot.db.DBManager.flush>` 100 to actually write out. 101 102 :param tags: a list of tags to be added 103 :type tags: list of str 104 :param afterwards: callback that gets called after successful 105 application of this tagging operation 106 :type afterwards: callable 107 :param remove_rest: remove all other tags 108 :type remove_rest: bool 109 """ 110 def myafterwards(): 111 if remove_rest: 112 self._tags = set(tags) 113 else: 114 self._tags = self._tags.union(tags) 115 if callable(afterwards): 116 afterwards() 117 118 self._dbman.tag('thread:' + self._id, tags, afterwards=myafterwards, 119 remove_rest=remove_rest) 120 121 def remove_tags(self, tags, afterwards=None): 122 """ 123 remove `tags` (list of str) from all messages in this thread 124 125 .. note:: 126 127 This only adds the requested operation to this objects 128 :class:`DBManager's <alot.db.DBManager>` write queue. 129 You need to call :meth:`DBManager.flush <alot.db.DBManager.flush>` 130 to actually write out. 131 132 :param tags: a list of tags to be added 133 :type tags: list of str 134 :param afterwards: callback that gets called after successful 135 application of this tagging operation 136 :type afterwards: callable 137 """ 138 rmtags = set(tags).intersection(self._tags) 139 if rmtags: 140 141 def myafterwards(): 142 self._tags = self._tags.difference(tags) 143 if callable(afterwards): 144 afterwards() 145 self._dbman.untag('thread:' + self._id, tags, myafterwards) 146 self._tags = self._tags.difference(rmtags) 147 148 def get_authors(self): 149 """ 150 returns a list of authors (name, addr) of the messages. 151 The authors are ordered by msg date and unique (by name/addr). 152 153 :rtype: list of (str, str) 154 """ 155 if self._authors is None: 156 # Sort messages with date first (by date ascending), and those 157 # without a date last. 158 msgs = sorted(self.get_messages().keys(), 159 key=lambda m: m.get_date() or datetime.max) 160 161 orderby = settings.get('thread_authors_order_by') 162 self._authors = [] 163 if orderby == 'latest_message': 164 for m in msgs: 165 pair = m.get_author() 166 if pair in self._authors: 167 self._authors.remove(pair) 168 self._authors.append(pair) 169 else: # i.e. first_message 170 for m in msgs: 171 pair = m.get_author() 172 if pair not in self._authors: 173 self._authors.append(pair) 174 175 return self._authors 176 177 def get_authors_string(self, own_accts=None, replace_own=None): 178 """ 179 returns a string of comma-separated authors 180 Depending on settings, it will substitute "me" for author name if 181 address is user's own. 182 183 :param own_accts: list of own accounts to replace 184 :type own_accts: list of :class:`Account` 185 :param replace_own: whether or not to actually do replacement 186 :type replace_own: bool 187 :rtype: str 188 """ 189 if replace_own is None: 190 replace_own = settings.get('thread_authors_replace_me') 191 if replace_own: 192 if own_accts is None: 193 own_accts = settings.get_accounts() 194 authorslist = [] 195 for aname, aaddress in self.get_authors(): 196 for account in own_accts: 197 if account.matches_address(aaddress): 198 aname = settings.get('thread_authors_me') 199 break 200 if not aname: 201 aname = aaddress 202 if aname not in authorslist: 203 authorslist.append(aname) 204 return ', '.join(authorslist) 205 else: 206 return self._notmuch_authors_string 207 208 def get_subject(self): 209 """returns subject string""" 210 return self._subject 211 212 def get_toplevel_messages(self): 213 """ 214 returns all toplevel messages contained in this thread. 215 This are all the messages without a parent message 216 (identified by 'in-reply-to' or 'references' header. 217 218 :rtype: list of :class:`~alot.db.message.Message` 219 """ 220 if not self._messages: 221 self.get_messages() 222 return self._toplevel_messages 223 224 def get_messages(self): 225 """ 226 returns all messages in this thread as dict mapping all contained 227 messages to their direct responses. 228 229 :rtype: dict mapping :class:`~alot.db.message.Message` to a list of 230 :class:`~alot.db.message.Message`. 231 """ 232 if not self._messages: # if not already cached 233 query = self._dbman.query('thread:' + self._id) 234 thread = next(query.search_threads()) 235 236 def accumulate(acc, msg): 237 M = Message(self._dbman, msg, thread=self) 238 acc[M] = [] 239 r = msg.get_replies() 240 if r is not None: 241 for m in r: 242 acc[M].append(accumulate(acc, m)) 243 return M 244 245 self._messages = {} 246 for m in thread.get_toplevel_messages(): 247 self._toplevel_messages.append(accumulate(self._messages, m)) 248 return self._messages 249 250 def get_replies_to(self, msg): 251 """ 252 returns all replies to the given message contained in this thread. 253 254 :param msg: parent message to look up 255 :type msg: :class:`~alot.db.message.Message` 256 :returns: list of :class:`~alot.db.message.Message` or `None` 257 """ 258 mid = msg.get_message_id() 259 msg_hash = self.get_messages() 260 for m in msg_hash.keys(): 261 if m.get_message_id() == mid: 262 return msg_hash[m] 263 return None 264 265 def get_newest_date(self): 266 """ 267 returns date header of newest message in this thread as 268 :class:`~datetime.datetime` 269 """ 270 return self._newest_date 271 272 def get_oldest_date(self): 273 """ 274 returns date header of oldest message in this thread as 275 :class:`~datetime.datetime` 276 """ 277 return self._oldest_date 278 279 def get_total_messages(self): 280 """returns number of contained messages""" 281 return self._total_messages 282 283 def matches(self, query): 284 """ 285 Check if this thread matches the given notmuch query. 286 287 :param query: The query to check against 288 :type query: string 289 :returns: True if this thread matches the given query, False otherwise 290 :rtype: bool 291 """ 292 thread_query = 'thread:{tid} AND {subquery}'.format(tid=self._id, 293 subquery=query) 294 num_matches = self._dbman.count_messages(thread_query) 295 return num_matches > 0 296