alot/db/thread.py

Summary

Maintainability
C
1 day
Test Coverage
# Copyright (C) 2011-2012  Patrick Totzke <patricktotzke@gmail.com>
# This file is released under the GNU GPL, version 3 or a later revision.
# For further details see the COPYING file
from datetime import datetime

from .message import Message
from ..settings.const import settings


class Thread:
    """
    A wrapper around a notmuch mailthread (:class:`notmuch2.Thread`)
    that ensures persistence of the thread: It can be safely read multiple
    times, its manipulation is done via a :class:`alot.db.DBManager` and it can
    directly provide contained messages as :class:`~alot.db.message.Message`.
    """

    def __init__(self, dbman, thread):
        """
        :param dbman: db manager that is used for further lookups
        :type dbman: :class:`~alot.db.DBManager`
        :param thread: the wrapped thread
        :type thread: :class:`notmuch2.Thread`
        """
        self._dbman = dbman
        self._authors = None
        self._id = thread.threadid
        self._messages = {}
        self._tags = set()

        self.refresh(thread)

    def refresh(self, thread=None):
        """refresh thread metadata from the index"""
        if not thread:
            with self._dbman._with_notmuch_thread(self._id) as thread:
                self._refresh(thread)
        else:
            self._refresh(thread)

    def _refresh(self, thread):
        self._total_messages = len(thread)
        self._notmuch_authors_string = thread.authors

        subject_type = settings.get('thread_subject')
        if subject_type == 'notmuch':
            subject = thread.subject
        elif subject_type == 'oldest':
            try:
                first_msg = list(thread.toplevel())[0]
                subject = first_msg.header('subject')
            except (IndexError, LookupError):
                subject = ''
        self._subject = subject

        self._authors = None
        ts = thread.first

        try:
            self._oldest_date = datetime.fromtimestamp(ts)
        except ValueError:  # year is out of range
            self._oldest_date = None
        try:
            timestamp = thread.last
            self._newest_date = datetime.fromtimestamp(timestamp)
        except ValueError:  # year is out of range
            self._newest_date = None

        self._tags = {t for t in thread.tags}
        self._messages = {}  # this maps messages to its children
        self._toplevel_messages = []

    def __str__(self):
        return "thread:%s: %s" % (self._id, self.get_subject())

    def get_thread_id(self):
        """returns id of this thread"""
        return self._id

    def get_tags(self, intersection=False):
        """
        returns tagsstrings attached to this thread

        :param intersection: return tags present in all contained messages
                             instead of in at least one (union)
        :type intersection: bool
        :rtype: set of str
        """
        tags = set(list(self._tags))
        if intersection:
            for m in self.get_messages().keys():
                tags = tags.intersection(set(m.get_tags()))
        return tags

    def add_tags(self, tags, afterwards=None, remove_rest=False):
        """
        add `tags` to all messages in this thread

        .. note::

            This only adds the requested operation to this objects
            :class:`DBManager's <alot.db.DBManager>` write queue.
            You need to call :meth:`DBManager.flush <alot.db.DBManager.flush>`
            to actually write out.

        :param tags: a list of tags to be added
        :type tags: list of str
        :param afterwards: callback that gets called after successful
                           application of this tagging operation
        :type afterwards: callable
        :param remove_rest: remove all other tags
        :type remove_rest: bool
        """
        def myafterwards():
            if remove_rest:
                self._tags = set(tags)
            else:
                self._tags = self._tags.union(tags)
            if callable(afterwards):
                afterwards()

        self._dbman.tag('thread:' + self._id, tags, afterwards=myafterwards,
                        remove_rest=remove_rest)

    def remove_tags(self, tags, afterwards=None):
        """
        remove `tags` (list of str) from all messages in this thread

        .. note::

            This only adds the requested operation to this objects
            :class:`DBManager's <alot.db.DBManager>` write queue.
            You need to call :meth:`DBManager.flush <alot.db.DBManager.flush>`
            to actually write out.

        :param tags: a list of tags to be added
        :type tags: list of str
        :param afterwards: callback that gets called after successful
                           application of this tagging operation
        :type afterwards: callable
        """
        rmtags = set(tags).intersection(self._tags)
        if rmtags:

            def myafterwards():
                self._tags = self._tags.difference(tags)
                if callable(afterwards):
                    afterwards()
            self._dbman.untag('thread:' + self._id, tags, myafterwards)
            self._tags = self._tags.difference(rmtags)

    def get_authors(self):
        """
        returns a list of authors (name, addr) of the messages.
        The authors are ordered by msg date and unique (by name/addr).

        :rtype: list of (str, str)
        """
        if self._authors is None:
            # Sort messages with date first (by date ascending), and those
            # without a date last.
            msgs = sorted(self.get_messages().keys(),
                          key=lambda m: m.get_date() or datetime.max)

            orderby = settings.get('thread_authors_order_by')
            self._authors = []
            if orderby == 'latest_message':
                for m in msgs:
                    pair = m.get_author()
                    if pair in self._authors:
                        self._authors.remove(pair)
                    self._authors.append(pair)
            else:  # i.e. first_message
                for m in msgs:
                    pair = m.get_author()
                    if pair not in self._authors:
                        self._authors.append(pair)

        return self._authors

    def get_authors_string(self, own_accts=None, replace_own=None):
        """
        returns a string of comma-separated authors
        Depending on settings, it will substitute "me" for author name if
        address is user's own.

        :param own_accts: list of own accounts to replace
        :type own_accts: list of :class:`Account`
        :param replace_own: whether or not to actually do replacement
        :type replace_own: bool
        :rtype: str
        """
        if replace_own is None:
            replace_own = settings.get('thread_authors_replace_me')
        if replace_own:
            if own_accts is None:
                own_accts = settings.get_accounts()
            authorslist = []
            for aname, aaddress in self.get_authors():
                for account in own_accts:
                    if account.matches_address(aaddress):
                        aname = settings.get('thread_authors_me')
                        break
                if not aname:
                    aname = aaddress
                if aname not in authorslist:
                    authorslist.append(aname)
            return ', '.join(authorslist)
        else:
            return self._notmuch_authors_string

    def get_subject(self):
        """returns subject string"""
        return self._subject

    def get_toplevel_messages(self):
        """
        returns all toplevel messages contained in this thread.
        This are all the messages without a parent message
        (identified by 'in-reply-to' or 'references' header.

        :rtype: list of :class:`~alot.db.message.Message`
        """
        if not self._messages:
            self.get_messages()
        return self._toplevel_messages

    def get_messages(self):
        """
        returns all messages in this thread as dict mapping all contained
        messages to their direct responses.

        :rtype: dict mapping :class:`~alot.db.message.Message` to a list of
                :class:`~alot.db.message.Message`.
        """
        if not self._messages:  # if not already cached
            with self._dbman._with_notmuch_thread(self._id) as thread:

                def accumulate(acc, msg):
                    M = Message(self._dbman, msg, thread=self)
                    acc[M] = []
                    for m in msg.replies():
                        acc[M].append(accumulate(acc, m))
                    return M

                self._messages = {}
                for m in thread.toplevel():
                    self._toplevel_messages.append(accumulate(self._messages,
                                                              m))
        return self._messages

    def get_replies_to(self, msg):
        """
        returns all replies to the given message contained in this thread.

        :param msg: parent message to look up
        :type msg: :class:`~alot.db.message.Message`
        :returns: list of :class:`~alot.db.message.Message` or `None`
        """
        mid = msg.get_message_id()
        msg_hash = self.get_messages()
        for m in msg_hash.keys():
            if m.get_message_id() == mid:
                return msg_hash[m]
        return None

    def get_newest_date(self):
        """
        returns date header of newest message in this thread as
        :class:`~datetime.datetime`
        """
        return self._newest_date

    def get_oldest_date(self):
        """
        returns date header of oldest message in this thread as
        :class:`~datetime.datetime`
        """
        return self._oldest_date

    def get_total_messages(self):
        """returns number of contained messages"""
        return self._total_messages

    def matches(self, query):
        """
        Check if this thread matches the given notmuch query.

        :param query: The query to check against
        :type query: string
        :returns: True if this thread matches the given query, False otherwise
        :rtype: bool
        """
        thread_query = 'thread:{tid} AND {subquery}'.format(tid=self._id,
                                                            subquery=query)
        num_matches = self._dbman.count_messages(thread_query)
        return num_matches > 0