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