[PATCH 2/5] notmuch-report: Rename from nmbug-status
authorW. Trevor King <wking@tremily.us>
Sat, 2 Jan 2016 06:08:02 +0000 (22:08 +1600)
committerW. Trevor King <wking@tremily.us>
Sat, 20 Aug 2016 21:50:19 +0000 (14:50 -0700)
7a/9a58a895a7643ff592a48b67084d3f7c51b4e9 [new file with mode: 0644]

diff --git a/7a/9a58a895a7643ff592a48b67084d3f7c51b4e9 b/7a/9a58a895a7643ff592a48b67084d3f7c51b4e9
new file mode 100644 (file)
index 0000000..c6e787b
--- /dev/null
@@ -0,0 +1,935 @@
+Return-Path: <wking@tremily.us>\r
+X-Original-To: notmuch@notmuchmail.org\r
+Delivered-To: notmuch@notmuchmail.org\r
+Received: from localhost (localhost [127.0.0.1])\r
+ by arlo.cworth.org (Postfix) with ESMTP id B483C6DE1B58\r
+ for <notmuch@notmuchmail.org>; Fri,  1 Jan 2016 22:10:42 -0800 (PST)\r
+X-Virus-Scanned: Debian amavisd-new at cworth.org\r
+X-Amavis-Alert: BAD HEADER SECTION, Duplicate header field: "References"\r
+X-Spam-Flag: NO\r
+X-Spam-Score: 0.04\r
+X-Spam-Level: \r
+X-Spam-Status: No, score=0.04 tagged_above=-999 required=5 tests=[AWL=0.041,\r
+ DKIM_SIGNED=0.1, DKIM_VALID=-0.1, RCVD_IN_DNSWL_NONE=-0.0001,\r
+ SPF_PASS=-0.001] autolearn=disabled\r
+Received: from arlo.cworth.org ([127.0.0.1])\r
+ by localhost (arlo.cworth.org [127.0.0.1]) (amavisd-new, port 10024)\r
+ with ESMTP id neE8vqetwSAG for <notmuch@notmuchmail.org>;\r
+ Fri,  1 Jan 2016 22:10:39 -0800 (PST)\r
+Received: from resqmta-po-04v.sys.comcast.net (resqmta-po-04v.sys.comcast.net\r
+ [96.114.154.163])\r
+ by arlo.cworth.org (Postfix) with ESMTPS id 4765F6DE1B50\r
+ for <notmuch@notmuchmail.org>; Fri,  1 Jan 2016 22:10:02 -0800 (PST)\r
+Received: from resomta-po-19v.sys.comcast.net ([96.114.154.243])\r
+ by resqmta-po-04v.sys.comcast.net with comcast\r
+ id 0uA11s0015FMDhs01uA1rC; Sat, 02 Jan 2016 06:10:01 +0000\r
+Received: from mail.tremily.us ([73.221.72.168])\r
+ by resomta-po-19v.sys.comcast.net with comcast\r
+ id 0u7z1s00L3dr3C901u80VP; Sat, 02 Jan 2016 06:08:01 +0000\r
+Received: from ullr.tremily.us (unknown [192.168.10.7])\r
+ by mail.tremily.us (Postfix) with ESMTPS id 6ED401B2F57F;\r
+ Fri,  1 Jan 2016 22:07:59 -0800 (PST)\r
+Received: (nullmailer pid 15181 invoked by uid 1000);\r
+ Sat, 02 Jan 2016 06:08:07 -0000\r
+From: "W. Trevor King" <wking@tremily.us>\r
+To: notmuch@notmuchmail.org\r
+Cc: David Bremner <david@tethera.net>,\r
+ Tomi Ollila <tomi.ollila@iki.fi>, Jani Nikula <jani@nikula.org>,\r
+ Carl Worth <cworth@cworth.org>, "W. Trevor King" <wking@tremily.us>\r
+Subject: [PATCH 2/5] notmuch-report: Rename from nmbug-status\r
+Date: Fri,  1 Jan 2016 22:08:02 -0800\r
+Message-Id:\r
+ <7df3f39aa67ed3862b937c7bf047a59d913e9c44.1451714099.git.wking@tremily.us>\r
+X-Mailer: git-send-email 2.1.0.60.g85f0837\r
+In-Reply-To: <cover.1451714099.git.wking@tremily.us>\r
+References: <cover.1451714099.git.wking@tremily.us>\r
+In-Reply-To: <cover.1451714099.git.wking@tremily.us>\r
+References: <cover.1451714099.git.wking@tremily.us>\r
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comcast.net;\r
+ s=q20140121; t=1451715001;\r
+ bh=b+eqQb79fXv+mPmYMNt7f5OjvZ4+EPolsU093hd980c=;\r
+ h=Received:Received:Received:Received:From:To:Subject:Date:\r
+ Message-Id;\r
+ b=pA4yTDI161iiniYaUP+XOFA4BsADTNzuzbQ2ktl3DzWNOqDzNT3lHfl3241hAVZaL\r
+ dQQSgQXW4iIakjEE2IpFYS5bAgG66uukBP5ndleopd3FtQuhbhDv0S02BqtqU6IM3Y\r
+ zuRjkzqPsVpbSwqudOusiOQtkCn2hoE25e4rDpoOZUF2to1RVUd9u6N5bBGA8RlcV1\r
+ Iz0T8+ev5ImEGfXuEZqqRMjJ87TFfos1UzfoK8uaBu6rhKD3D2MN1c3H3hFb3CXOBX\r
+ nCZtUyHqSnkP7XcZezzbSfiGoMWgvE5VGvF2pHReSxOvmJgMwZap65ErCB09X4jptK\r
+ ywBmS/HtBZkqg==\r
+X-BeenThere: notmuch@notmuchmail.org\r
+X-Mailman-Version: 2.1.20\r
+Precedence: list\r
+List-Id: "Use and development of the notmuch mail system."\r
+ <notmuch.notmuchmail.org>\r
+List-Unsubscribe: <https://notmuchmail.org/mailman/options/notmuch>,\r
+ <mailto:notmuch-request@notmuchmail.org?subject=unsubscribe>\r
+List-Archive: <http://notmuchmail.org/pipermail/notmuch/>\r
+List-Post: <mailto:notmuch@notmuchmail.org>\r
+List-Help: <mailto:notmuch-request@notmuchmail.org?subject=help>\r
+List-Subscribe: <https://notmuchmail.org/mailman/listinfo/notmuch>,\r
+ <mailto:notmuch-request@notmuchmail.org?subject=subscribe>\r
+X-List-Received-Date: Sat, 02 Jan 2016 06:10:42 -0000\r
+\r
+This script generates reports based on notmuch queries, and doesn't\r
+really have anything to do with nmbug, except for sharing the NMBGIT\r
+environment variable.\r
+---\r
+ devel/nmbug/nmbug-status   | 419 ---------------------------------------------\r
+ devel/nmbug/notmuch-report | 419 +++++++++++++++++++++++++++++++++++++++++++++\r
+ 2 files changed, 419 insertions(+), 419 deletions(-)\r
+ delete mode 100755 devel/nmbug/nmbug-status\r
+ create mode 100755 devel/nmbug/notmuch-report\r
+\r
+diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status\r
+deleted file mode 100755\r
+index 22e3b5b..0000000\r
+--- a/devel/nmbug/nmbug-status\r
++++ /dev/null\r
+@@ -1,419 +0,0 @@\r
+-#!/usr/bin/python\r
+-#\r
+-# Copyright (c) 2011-2012 David Bremner <david@tethera.net>\r
+-#\r
+-# dependencies\r
+-#       - python 2.6 for json\r
+-#       - argparse; either python 2.7, or install separately\r
+-#\r
+-# This program is free software: you can redistribute it and/or modify\r
+-# it under the terms of the GNU General Public License as published by\r
+-# the Free Software Foundation, either version 3 of the License, or\r
+-# (at your option) any later version.\r
+-#\r
+-# This program is distributed in the hope that it will be useful,\r
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of\r
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\r
+-# GNU General Public License for more details.\r
+-#\r
+-# You should have received a copy of the GNU General Public License\r
+-# along with this program.  If not, see http://www.gnu.org/licenses/ .\r
+-\r
+-"""Generate HTML for one or more notmuch searches.\r
+-\r
+-Messages matching each search are grouped by thread.  Each message\r
+-that contains both a subject and message-id will have the displayed\r
+-subject link to the Gmane view of the message.\r
+-"""\r
+-\r
+-from __future__ import print_function\r
+-from __future__ import unicode_literals\r
+-\r
+-import codecs\r
+-import collections\r
+-import datetime\r
+-import email.utils\r
+-try:  # Python 3\r
+-    from urllib.parse import quote\r
+-except ImportError:  # Python 2\r
+-    from urllib import quote\r
+-import json\r
+-import argparse\r
+-import os\r
+-import re\r
+-import sys\r
+-import subprocess\r
+-import xml.sax.saxutils\r
+-\r
+-\r
+-_ENCODING = 'UTF-8'\r
+-_PAGES = {}\r
+-\r
+-\r
+-if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier\r
+-    class _OrderedDict (dict):\r
+-        "Just enough of a stub to get through Page._get_threads"\r
+-        def __init__(self, *args, **kwargs):\r
+-            super(_OrderedDict, self).__init__(*args, **kwargs)\r
+-            self._keys = []  # record key order\r
+-\r
+-        def __setitem__(self, key, value):\r
+-            super(_OrderedDict, self).__setitem__(key, value)\r
+-            self._keys.append(key)\r
+-\r
+-        def values(self):\r
+-            for key in self._keys:\r
+-                yield self[key]\r
+-\r
+-\r
+-    collections.OrderedDict = _OrderedDict\r
+-\r
+-\r
+-class ConfigError (Exception):\r
+-    """Errors with config file usage\r
+-    """\r
+-    pass\r
+-\r
+-\r
+-def read_config(path=None, encoding=None):\r
+-    "Read config from json file"\r
+-    if not encoding:\r
+-        encoding = _ENCODING\r
+-    if path:\r
+-        try:\r
+-            with open(path, 'rb') as f:\r
+-                config_bytes = f.read()\r
+-        except IOError as e:\r
+-            raise ConfigError('Could not read config from {}'.format(path))\r
+-    else:\r
+-        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))\r
+-        branch = 'config'\r
+-        filename = 'status-config.json'\r
+-\r
+-        # read only the first line from the pipe\r
+-        sha1_bytes = subprocess.Popen(\r
+-            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],\r
+-            stdout=subprocess.PIPE).stdout.readline()\r
+-        sha1 = sha1_bytes.decode(encoding).rstrip()\r
+-        if not sha1:\r
+-            raise ConfigError(\r
+-                ("No local branch '{branch}' in {nmbgit}.  "\r
+-                 'Checkout a local {branch} branch or explicitly set --config.'\r
+-                ).format(branch=branch, nmbgit=nmbhome))\r
+-\r
+-        p = subprocess.Popen(\r
+-            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',\r
+-             '{}:{}'.format(sha1, filename)],\r
+-            stdout=subprocess.PIPE)\r
+-        config_bytes, err = p.communicate()\r
+-        status = p.wait()\r
+-        if status != 0:\r
+-            raise ConfigError(\r
+-                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "\r
+-                 'Add the file or explicitly set --config.'\r
+-                ).format(filename=filename, branch=branch, nmbgit=nmbhome))\r
+-\r
+-    config_json = config_bytes.decode(encoding)\r
+-    try:\r
+-        return json.loads(config_json)\r
+-    except ValueError as e:\r
+-        if not path:\r
+-            path = "{} in branch '{}' of {}".format(\r
+-                filename, branch, nmbhome)\r
+-        raise ConfigError(\r
+-            'Could not parse JSON from the config file {}:\n{}'.format(\r
+-                path, e))\r
+-\r
+-\r
+-class Thread (list):\r
+-    def __init__(self):\r
+-        self.running_data = {}\r
+-\r
+-\r
+-class Page (object):\r
+-    def __init__(self, header=None, footer=None):\r
+-        self.header = header\r
+-        self.footer = footer\r
+-\r
+-    def write(self, database, views, stream=None):\r
+-        if not stream:\r
+-            try:  # Python 3\r
+-                byte_stream = sys.stdout.buffer\r
+-            except AttributeError:  # Python 2\r
+-                byte_stream = sys.stdout\r
+-            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)\r
+-        self._write_header(views=views, stream=stream)\r
+-        for view in views:\r
+-            self._write_view(database=database, view=view, stream=stream)\r
+-        self._write_footer(views=views, stream=stream)\r
+-\r
+-    def _write_header(self, views, stream):\r
+-        if self.header:\r
+-            stream.write(self.header)\r
+-\r
+-    def _write_footer(self, views, stream):\r
+-        if self.footer:\r
+-            stream.write(self.footer)\r
+-\r
+-    def _write_view(self, database, view, stream):\r
+-        # sort order, default to oldest-first\r
+-        sort_key = view.get('sort', 'oldest-first')\r
+-        # dynamically accept all values in Query.SORT\r
+-        sort_attribute = sort_key.upper().replace('-', '_')\r
+-        try:\r
+-            sort = getattr(notmuch.Query.SORT, sort_attribute)\r
+-        except AttributeError:\r
+-            raise ConfigError('Invalid sort setting for {}: {!r}'.format(\r
+-                view['title'], sort_key))\r
+-        if 'query-string' not in view:\r
+-            query = view['query']\r
+-            view['query-string'] = ' and '.join(query)\r
+-        q = notmuch.Query(database, view['query-string'])\r
+-        q.set_sort(sort)\r
+-        threads = self._get_threads(messages=q.search_messages())\r
+-        self._write_view_header(view=view, stream=stream)\r
+-        self._write_threads(threads=threads, stream=stream)\r
+-\r
+-    def _get_threads(self, messages):\r
+-        threads = collections.OrderedDict()\r
+-        for message in messages:\r
+-            thread_id = message.get_thread_id()\r
+-            if thread_id in threads:\r
+-                thread = threads[thread_id]\r
+-            else:\r
+-                thread = Thread()\r
+-                threads[thread_id] = thread\r
+-            thread.running_data, display_data = self._message_display_data(\r
+-                running_data=thread.running_data, message=message)\r
+-            thread.append(display_data)\r
+-        return list(threads.values())\r
+-\r
+-    def _write_view_header(self, view, stream):\r
+-        pass\r
+-\r
+-    def _write_threads(self, threads, stream):\r
+-        for thread in threads:\r
+-            for message_display_data in thread:\r
+-                stream.write(\r
+-                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'\r
+-                     '{message-id-term:>72}\n'\r
+-                     ).format(**message_display_data))\r
+-            if thread != threads[-1]:\r
+-                stream.write('\n')\r
+-\r
+-    def _message_display_data(self, running_data, message):\r
+-        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')\r
+-        data = {}\r
+-        for header in headers:\r
+-            if header == 'thread-id':\r
+-                value = message.get_thread_id()\r
+-            elif header == 'message-id':\r
+-                value = message.get_message_id()\r
+-                data['message-id-term'] = 'id:"{0}"'.format(value)\r
+-            elif header == 'date':\r
+-                value = str(datetime.datetime.utcfromtimestamp(\r
+-                    message.get_date()).date())\r
+-            else:\r
+-                value = message.get_header(header)\r
+-            if header == 'from':\r
+-                (value, addr) = email.utils.parseaddr(value)\r
+-                if not value:\r
+-                    value = addr.split('@')[0]\r
+-            data[header] = value\r
+-        next_running_data = data.copy()\r
+-        for header, value in data.items():\r
+-            if header in ['message-id', 'subject']:\r
+-                continue\r
+-            if value == running_data.get(header, None):\r
+-                data[header] = ''\r
+-        return (next_running_data, data)\r
+-\r
+-\r
+-class HtmlPage (Page):\r
+-    _slug_regexp = re.compile('\W+')\r
+-\r
+-    def _write_header(self, views, stream):\r
+-        super(HtmlPage, self)._write_header(views=views, stream=stream)\r
+-        stream.write('<ul>\n')\r
+-        for view in views:\r
+-            if 'id' not in view:\r
+-                view['id'] = self._slug(view['title'])\r
+-            stream.write(\r
+-                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))\r
+-        stream.write('</ul>\n')\r
+-\r
+-    def _write_view_header(self, view, stream):\r
+-        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))\r
+-        stream.write('<p>\n')\r
+-        if 'comment' in view:\r
+-            stream.write(view['comment'])\r
+-            stream.write('\n')\r
+-        for line in [\r
+-                'The view is generated from the following query:',\r
+-                '</p>',\r
+-                '<p>',\r
+-                '  <code>',\r
+-                view['query-string'],\r
+-                '  </code>',\r
+-                '</p>',\r
+-                ]:\r
+-            stream.write(line)\r
+-            stream.write('\n')\r
+-\r
+-    def _write_threads(self, threads, stream):\r
+-        if not threads:\r
+-            return\r
+-        stream.write('<table>\n')\r
+-        for thread in threads:\r
+-            stream.write('  <tbody>\n')\r
+-            for message_display_data in thread:\r
+-                stream.write((\r
+-                    '    <tr class="message-first">\n'\r
+-                    '      <td>{date}</td>\n'\r
+-                    '      <td><code>{message-id-term}</code></td>\n'\r
+-                    '    </tr>\n'\r
+-                    '    <tr class="message-last">\n'\r
+-                    '      <td>{from}</td>\n'\r
+-                    '      <td>{subject}</td>\n'\r
+-                    '    </tr>\n'\r
+-                    ).format(**message_display_data))\r
+-            stream.write('  </tbody>\n')\r
+-            if thread != threads[-1]:\r
+-                stream.write(\r
+-                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')\r
+-        stream.write('</table>\n')\r
+-\r
+-    def _message_display_data(self, *args, **kwargs):\r
+-        running_data, display_data = super(\r
+-            HtmlPage, self)._message_display_data(\r
+-                *args, **kwargs)\r
+-        if 'subject' in display_data and 'message-id' in display_data:\r
+-            d = {\r
+-                'message-id': quote(display_data['message-id']),\r
+-                'subject': xml.sax.saxutils.escape(display_data['subject']),\r
+-                }\r
+-            display_data['subject'] = (\r
+-                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'\r
+-                ).format(**d)\r
+-        for key in ['message-id', 'from']:\r
+-            if key in display_data:\r
+-                display_data[key] = xml.sax.saxutils.escape(display_data[key])\r
+-        return (running_data, display_data)\r
+-\r
+-    def _slug(self, string):\r
+-        return self._slug_regexp.sub('-', string)\r
+-\r
+-parser = argparse.ArgumentParser(description=__doc__)\r
+-parser.add_argument('--text', help='output plain text format',\r
+-                    action='store_true')\r
+-parser.add_argument('--config', help='load config from given file',\r
+-                    metavar='PATH')\r
+-parser.add_argument('--list-views', help='list views',\r
+-                    action='store_true')\r
+-parser.add_argument('--get-query', help='get query for view',\r
+-                    metavar='VIEW')\r
+-\r
+-args = parser.parse_args()\r
+-\r
+-try:\r
+-    config = read_config(path=args.config)\r
+-except ConfigError as e:\r
+-    print(e, file=sys.stderr)\r
+-    sys.exit(1)\r
+-\r
+-header_template = config['meta'].get('header', '''<!DOCTYPE html>\r
+-<html lang="en">\r
+-<head>\r
+-  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />\r
+-  <title>{title}</title>\r
+-  <style media="screen" type="text/css">\r
+-    table {{\r
+-      border-spacing: 0;\r
+-    }}\r
+-    tr.message-first td {{\r
+-      padding-top: {inter_message_padding};\r
+-    }}\r
+-    tr.message-last td {{\r
+-      padding-bottom: {inter_message_padding};\r
+-    }}\r
+-    td {{\r
+-      padding-left: {border_radius};\r
+-      padding-right: {border_radius};\r
+-    }}\r
+-    tr:first-child td:first-child {{\r
+-      border-top-left-radius: {border_radius};\r
+-    }}\r
+-    tr:first-child td:last-child {{\r
+-      border-top-right-radius: {border_radius};\r
+-    }}\r
+-    tr:last-child td:first-child {{\r
+-      border-bottom-left-radius: {border_radius};\r
+-    }}\r
+-    tr:last-child td:last-child {{\r
+-      border-bottom-right-radius: {border_radius};\r
+-    }}\r
+-    tbody:nth-child(4n+1) tr td {{\r
+-      background-color: #ffd96e;\r
+-    }}\r
+-    tbody:nth-child(4n+3) tr td {{\r
+-      background-color: #bce;\r
+-    }}\r
+-    hr {{\r
+-      border: 0;\r
+-      height: 1px;\r
+-      color: #ccc;\r
+-      background-color: #ccc;\r
+-    }}\r
+-  </style>\r
+-</head>\r
+-<body>\r
+-<h2>{title}</h2>\r
+-{blurb}\r
+-</p>\r
+-<h3>Views</h3>\r
+-''')\r
+-\r
+-footer_template = config['meta'].get('footer', '''\r
+-<hr>\r
+-<p>Generated: {datetime}\r
+-</body>\r
+-</html>\r
+-''')\r
+-\r
+-now = datetime.datetime.utcnow()\r
+-context = {\r
+-    'date': now,\r
+-    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),\r
+-    'title': config['meta']['title'],\r
+-    'blurb': config['meta']['blurb'],\r
+-    'encoding': _ENCODING,\r
+-    'inter_message_padding': '0.25em',\r
+-    'border_radius': '0.5em',\r
+-    }\r
+-\r
+-_PAGES['text'] = Page()\r
+-_PAGES['html'] = HtmlPage(\r
+-    header=header_template.format(**context),\r
+-    footer=footer_template.format(**context),\r
+-    )\r
+-\r
+-if args.list_views:\r
+-    for view in config['views']:\r
+-        print(view['title'])\r
+-    sys.exit(0)\r
+-elif args.get_query != None:\r
+-    for view in config['views']:\r
+-        if args.get_query == view['title']:\r
+-            print(' and '.join(view['query']))\r
+-    sys.exit(0)\r
+-else:\r
+-    # only import notmuch if needed\r
+-    import notmuch\r
+-\r
+-if args.text:\r
+-    page = _PAGES['text']\r
+-else:\r
+-    page = _PAGES['html']\r
+-\r
+-db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
+-page.write(database=db, views=config['views'])\r
+diff --git a/devel/nmbug/notmuch-report b/devel/nmbug/notmuch-report\r
+new file mode 100755\r
+index 0000000..22e3b5b\r
+--- /dev/null\r
++++ b/devel/nmbug/notmuch-report\r
+@@ -0,0 +1,419 @@\r
++#!/usr/bin/python\r
++#\r
++# Copyright (c) 2011-2012 David Bremner <david@tethera.net>\r
++#\r
++# dependencies\r
++#       - python 2.6 for json\r
++#       - argparse; either python 2.7, or install separately\r
++#\r
++# This program is free software: you can redistribute it and/or modify\r
++# it under the terms of the GNU General Public License as published by\r
++# the Free Software Foundation, either version 3 of the License, or\r
++# (at your option) any later version.\r
++#\r
++# This program is distributed in the hope that it will be useful,\r
++# but WITHOUT ANY WARRANTY; without even the implied warranty of\r
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\r
++# GNU General Public License for more details.\r
++#\r
++# You should have received a copy of the GNU General Public License\r
++# along with this program.  If not, see http://www.gnu.org/licenses/ .\r
++\r
++"""Generate HTML for one or more notmuch searches.\r
++\r
++Messages matching each search are grouped by thread.  Each message\r
++that contains both a subject and message-id will have the displayed\r
++subject link to the Gmane view of the message.\r
++"""\r
++\r
++from __future__ import print_function\r
++from __future__ import unicode_literals\r
++\r
++import codecs\r
++import collections\r
++import datetime\r
++import email.utils\r
++try:  # Python 3\r
++    from urllib.parse import quote\r
++except ImportError:  # Python 2\r
++    from urllib import quote\r
++import json\r
++import argparse\r
++import os\r
++import re\r
++import sys\r
++import subprocess\r
++import xml.sax.saxutils\r
++\r
++\r
++_ENCODING = 'UTF-8'\r
++_PAGES = {}\r
++\r
++\r
++if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier\r
++    class _OrderedDict (dict):\r
++        "Just enough of a stub to get through Page._get_threads"\r
++        def __init__(self, *args, **kwargs):\r
++            super(_OrderedDict, self).__init__(*args, **kwargs)\r
++            self._keys = []  # record key order\r
++\r
++        def __setitem__(self, key, value):\r
++            super(_OrderedDict, self).__setitem__(key, value)\r
++            self._keys.append(key)\r
++\r
++        def values(self):\r
++            for key in self._keys:\r
++                yield self[key]\r
++\r
++\r
++    collections.OrderedDict = _OrderedDict\r
++\r
++\r
++class ConfigError (Exception):\r
++    """Errors with config file usage\r
++    """\r
++    pass\r
++\r
++\r
++def read_config(path=None, encoding=None):\r
++    "Read config from json file"\r
++    if not encoding:\r
++        encoding = _ENCODING\r
++    if path:\r
++        try:\r
++            with open(path, 'rb') as f:\r
++                config_bytes = f.read()\r
++        except IOError as e:\r
++            raise ConfigError('Could not read config from {}'.format(path))\r
++    else:\r
++        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))\r
++        branch = 'config'\r
++        filename = 'status-config.json'\r
++\r
++        # read only the first line from the pipe\r
++        sha1_bytes = subprocess.Popen(\r
++            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],\r
++            stdout=subprocess.PIPE).stdout.readline()\r
++        sha1 = sha1_bytes.decode(encoding).rstrip()\r
++        if not sha1:\r
++            raise ConfigError(\r
++                ("No local branch '{branch}' in {nmbgit}.  "\r
++                 'Checkout a local {branch} branch or explicitly set --config.'\r
++                ).format(branch=branch, nmbgit=nmbhome))\r
++\r
++        p = subprocess.Popen(\r
++            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',\r
++             '{}:{}'.format(sha1, filename)],\r
++            stdout=subprocess.PIPE)\r
++        config_bytes, err = p.communicate()\r
++        status = p.wait()\r
++        if status != 0:\r
++            raise ConfigError(\r
++                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "\r
++                 'Add the file or explicitly set --config.'\r
++                ).format(filename=filename, branch=branch, nmbgit=nmbhome))\r
++\r
++    config_json = config_bytes.decode(encoding)\r
++    try:\r
++        return json.loads(config_json)\r
++    except ValueError as e:\r
++        if not path:\r
++            path = "{} in branch '{}' of {}".format(\r
++                filename, branch, nmbhome)\r
++        raise ConfigError(\r
++            'Could not parse JSON from the config file {}:\n{}'.format(\r
++                path, e))\r
++\r
++\r
++class Thread (list):\r
++    def __init__(self):\r
++        self.running_data = {}\r
++\r
++\r
++class Page (object):\r
++    def __init__(self, header=None, footer=None):\r
++        self.header = header\r
++        self.footer = footer\r
++\r
++    def write(self, database, views, stream=None):\r
++        if not stream:\r
++            try:  # Python 3\r
++                byte_stream = sys.stdout.buffer\r
++            except AttributeError:  # Python 2\r
++                byte_stream = sys.stdout\r
++            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)\r
++        self._write_header(views=views, stream=stream)\r
++        for view in views:\r
++            self._write_view(database=database, view=view, stream=stream)\r
++        self._write_footer(views=views, stream=stream)\r
++\r
++    def _write_header(self, views, stream):\r
++        if self.header:\r
++            stream.write(self.header)\r
++\r
++    def _write_footer(self, views, stream):\r
++        if self.footer:\r
++            stream.write(self.footer)\r
++\r
++    def _write_view(self, database, view, stream):\r
++        # sort order, default to oldest-first\r
++        sort_key = view.get('sort', 'oldest-first')\r
++        # dynamically accept all values in Query.SORT\r
++        sort_attribute = sort_key.upper().replace('-', '_')\r
++        try:\r
++            sort = getattr(notmuch.Query.SORT, sort_attribute)\r
++        except AttributeError:\r
++            raise ConfigError('Invalid sort setting for {}: {!r}'.format(\r
++                view['title'], sort_key))\r
++        if 'query-string' not in view:\r
++            query = view['query']\r
++            view['query-string'] = ' and '.join(query)\r
++        q = notmuch.Query(database, view['query-string'])\r
++        q.set_sort(sort)\r
++        threads = self._get_threads(messages=q.search_messages())\r
++        self._write_view_header(view=view, stream=stream)\r
++        self._write_threads(threads=threads, stream=stream)\r
++\r
++    def _get_threads(self, messages):\r
++        threads = collections.OrderedDict()\r
++        for message in messages:\r
++            thread_id = message.get_thread_id()\r
++            if thread_id in threads:\r
++                thread = threads[thread_id]\r
++            else:\r
++                thread = Thread()\r
++                threads[thread_id] = thread\r
++            thread.running_data, display_data = self._message_display_data(\r
++                running_data=thread.running_data, message=message)\r
++            thread.append(display_data)\r
++        return list(threads.values())\r
++\r
++    def _write_view_header(self, view, stream):\r
++        pass\r
++\r
++    def _write_threads(self, threads, stream):\r
++        for thread in threads:\r
++            for message_display_data in thread:\r
++                stream.write(\r
++                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'\r
++                     '{message-id-term:>72}\n'\r
++                     ).format(**message_display_data))\r
++            if thread != threads[-1]:\r
++                stream.write('\n')\r
++\r
++    def _message_display_data(self, running_data, message):\r
++        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')\r
++        data = {}\r
++        for header in headers:\r
++            if header == 'thread-id':\r
++                value = message.get_thread_id()\r
++            elif header == 'message-id':\r
++                value = message.get_message_id()\r
++                data['message-id-term'] = 'id:"{0}"'.format(value)\r
++            elif header == 'date':\r
++                value = str(datetime.datetime.utcfromtimestamp(\r
++                    message.get_date()).date())\r
++            else:\r
++                value = message.get_header(header)\r
++            if header == 'from':\r
++                (value, addr) = email.utils.parseaddr(value)\r
++                if not value:\r
++                    value = addr.split('@')[0]\r
++            data[header] = value\r
++        next_running_data = data.copy()\r
++        for header, value in data.items():\r
++            if header in ['message-id', 'subject']:\r
++                continue\r
++            if value == running_data.get(header, None):\r
++                data[header] = ''\r
++        return (next_running_data, data)\r
++\r
++\r
++class HtmlPage (Page):\r
++    _slug_regexp = re.compile('\W+')\r
++\r
++    def _write_header(self, views, stream):\r
++        super(HtmlPage, self)._write_header(views=views, stream=stream)\r
++        stream.write('<ul>\n')\r
++        for view in views:\r
++            if 'id' not in view:\r
++                view['id'] = self._slug(view['title'])\r
++            stream.write(\r
++                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))\r
++        stream.write('</ul>\n')\r
++\r
++    def _write_view_header(self, view, stream):\r
++        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))\r
++        stream.write('<p>\n')\r
++        if 'comment' in view:\r
++            stream.write(view['comment'])\r
++            stream.write('\n')\r
++        for line in [\r
++                'The view is generated from the following query:',\r
++                '</p>',\r
++                '<p>',\r
++                '  <code>',\r
++                view['query-string'],\r
++                '  </code>',\r
++                '</p>',\r
++                ]:\r
++            stream.write(line)\r
++            stream.write('\n')\r
++\r
++    def _write_threads(self, threads, stream):\r
++        if not threads:\r
++            return\r
++        stream.write('<table>\n')\r
++        for thread in threads:\r
++            stream.write('  <tbody>\n')\r
++            for message_display_data in thread:\r
++                stream.write((\r
++                    '    <tr class="message-first">\n'\r
++                    '      <td>{date}</td>\n'\r
++                    '      <td><code>{message-id-term}</code></td>\n'\r
++                    '    </tr>\n'\r
++                    '    <tr class="message-last">\n'\r
++                    '      <td>{from}</td>\n'\r
++                    '      <td>{subject}</td>\n'\r
++                    '    </tr>\n'\r
++                    ).format(**message_display_data))\r
++            stream.write('  </tbody>\n')\r
++            if thread != threads[-1]:\r
++                stream.write(\r
++                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')\r
++        stream.write('</table>\n')\r
++\r
++    def _message_display_data(self, *args, **kwargs):\r
++        running_data, display_data = super(\r
++            HtmlPage, self)._message_display_data(\r
++                *args, **kwargs)\r
++        if 'subject' in display_data and 'message-id' in display_data:\r
++            d = {\r
++                'message-id': quote(display_data['message-id']),\r
++                'subject': xml.sax.saxutils.escape(display_data['subject']),\r
++                }\r
++            display_data['subject'] = (\r
++                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'\r
++                ).format(**d)\r
++        for key in ['message-id', 'from']:\r
++            if key in display_data:\r
++                display_data[key] = xml.sax.saxutils.escape(display_data[key])\r
++        return (running_data, display_data)\r
++\r
++    def _slug(self, string):\r
++        return self._slug_regexp.sub('-', string)\r
++\r
++parser = argparse.ArgumentParser(description=__doc__)\r
++parser.add_argument('--text', help='output plain text format',\r
++                    action='store_true')\r
++parser.add_argument('--config', help='load config from given file',\r
++                    metavar='PATH')\r
++parser.add_argument('--list-views', help='list views',\r
++                    action='store_true')\r
++parser.add_argument('--get-query', help='get query for view',\r
++                    metavar='VIEW')\r
++\r
++args = parser.parse_args()\r
++\r
++try:\r
++    config = read_config(path=args.config)\r
++except ConfigError as e:\r
++    print(e, file=sys.stderr)\r
++    sys.exit(1)\r
++\r
++header_template = config['meta'].get('header', '''<!DOCTYPE html>\r
++<html lang="en">\r
++<head>\r
++  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />\r
++  <title>{title}</title>\r
++  <style media="screen" type="text/css">\r
++    table {{\r
++      border-spacing: 0;\r
++    }}\r
++    tr.message-first td {{\r
++      padding-top: {inter_message_padding};\r
++    }}\r
++    tr.message-last td {{\r
++      padding-bottom: {inter_message_padding};\r
++    }}\r
++    td {{\r
++      padding-left: {border_radius};\r
++      padding-right: {border_radius};\r
++    }}\r
++    tr:first-child td:first-child {{\r
++      border-top-left-radius: {border_radius};\r
++    }}\r
++    tr:first-child td:last-child {{\r
++      border-top-right-radius: {border_radius};\r
++    }}\r
++    tr:last-child td:first-child {{\r
++      border-bottom-left-radius: {border_radius};\r
++    }}\r
++    tr:last-child td:last-child {{\r
++      border-bottom-right-radius: {border_radius};\r
++    }}\r
++    tbody:nth-child(4n+1) tr td {{\r
++      background-color: #ffd96e;\r
++    }}\r
++    tbody:nth-child(4n+3) tr td {{\r
++      background-color: #bce;\r
++    }}\r
++    hr {{\r
++      border: 0;\r
++      height: 1px;\r
++      color: #ccc;\r
++      background-color: #ccc;\r
++    }}\r
++  </style>\r
++</head>\r
++<body>\r
++<h2>{title}</h2>\r
++{blurb}\r
++</p>\r
++<h3>Views</h3>\r
++''')\r
++\r
++footer_template = config['meta'].get('footer', '''\r
++<hr>\r
++<p>Generated: {datetime}\r
++</body>\r
++</html>\r
++''')\r
++\r
++now = datetime.datetime.utcnow()\r
++context = {\r
++    'date': now,\r
++    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),\r
++    'title': config['meta']['title'],\r
++    'blurb': config['meta']['blurb'],\r
++    'encoding': _ENCODING,\r
++    'inter_message_padding': '0.25em',\r
++    'border_radius': '0.5em',\r
++    }\r
++\r
++_PAGES['text'] = Page()\r
++_PAGES['html'] = HtmlPage(\r
++    header=header_template.format(**context),\r
++    footer=footer_template.format(**context),\r
++    )\r
++\r
++if args.list_views:\r
++    for view in config['views']:\r
++        print(view['title'])\r
++    sys.exit(0)\r
++elif args.get_query != None:\r
++    for view in config['views']:\r
++        if args.get_query == view['title']:\r
++            print(' and '.join(view['query']))\r
++    sys.exit(0)\r
++else:\r
++    # only import notmuch if needed\r
++    import notmuch\r
++\r
++if args.text:\r
++    page = _PAGES['text']\r
++else:\r
++    page = _PAGES['html']\r
++\r
++db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
++page.write(database=db, views=config['views'])\r
+-- \r
+2.1.0.60.g85f0837\r
+\r