Re: [PATCH 2/5] notmuch-report: Rename from nmbug-status
authorJani Nikula <jani@nikula.org>
Sun, 3 Jan 2016 16:19:50 +0000 (18:19 +0200)
committerW. Trevor King <wking@tremily.us>
Sat, 20 Aug 2016 21:50:20 +0000 (14:50 -0700)
c2/d757867984e3344bc44f03da0c5b2f4868ec6c [new file with mode: 0644]

diff --git a/c2/d757867984e3344bc44f03da0c5b2f4868ec6c b/c2/d757867984e3344bc44f03da0c5b2f4868ec6c
new file mode 100644 (file)
index 0000000..772b670
--- /dev/null
@@ -0,0 +1,952 @@
+Return-Path: <jani@nikula.org>\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 BA0416DE17F3\r
+ for <notmuch@notmuchmail.org>; Sun,  3 Jan 2016 08:20:46 -0800 (PST)\r
+X-Virus-Scanned: Debian amavisd-new at cworth.org\r
+X-Spam-Flag: NO\r
+X-Spam-Score: -0.548\r
+X-Spam-Level: \r
+X-Spam-Status: No, score=-0.548 tagged_above=-999 required=5 tests=[AWL=0.172,\r
+  DKIM_SIGNED=0.1, DKIM_VALID=-0.1, RCVD_IN_DNSWL_LOW=-0.7,\r
+ RCVD_IN_MSPIKE_H3=-0.01, RCVD_IN_MSPIKE_WL=-0.01] 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 0tUlmOA8t7mZ for <notmuch@notmuchmail.org>;\r
+ Sun,  3 Jan 2016 08:20:44 -0800 (PST)\r
+Received: from mail-wm0-f45.google.com (mail-wm0-f45.google.com\r
+ [74.125.82.45]) by arlo.cworth.org (Postfix) with ESMTPS id 8CAA56DE18EA for\r
+ <notmuch@notmuchmail.org>; Sun,  3 Jan 2016 08:20:43 -0800 (PST)\r
+Received: by mail-wm0-f45.google.com with SMTP id f206so117347195wmf.0\r
+ for <notmuch@notmuchmail.org>; Sun, 03 Jan 2016 08:20:43 -0800 (PST)\r
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r
+ d=nikula-org.20150623.gappssmtp.com; s=20150623;\r
+ h=from:to:cc:subject:in-reply-to:references:user-agent:date\r
+ :message-id:mime-version:content-type;\r
+ bh=ujIh41SRvnfSEpPuxJQUZTOcgAI8EnnqCiJpRqtprOE=;\r
+ b=Kl0nlqzxjphgJAMuqHuJWo96ks78nq8BMKjl4yDCZqzyntsDe7dBgTh/vhqNn2+UiA\r
+ 8tYzM1nIjGroUB2c+u8xCEmP6ULcC1WSySF0Ceu4Vv7D0q/EVAMF4Xm2piqCSSo85kfS\r
+ jFOPdEPwoCtnC3Qvc+xtjsql6A7GGH208zMJbljke8olAfka8Jurkh12+sixFkxoNMHI\r
+ tjPdLZjMNsXNc51/z+OFxkF50TR0V5uaH8TQGqxQTdVwtX7L8yx0oAvaz6X9nXvf/QBl\r
+ MsF3KFl1Tg5CE7oyn8QhLaeoA+K+a9Mkjxzw8El78U0TJSx1+QrlBL0IDfVuig6gxYC/\r
+ DIbw==\r
+X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;\r
+ d=1e100.net; s=20130820;\r
+ h=x-gm-message-state:from:to:cc:subject:in-reply-to:references\r
+ :user-agent:date:message-id:mime-version:content-type;\r
+ bh=ujIh41SRvnfSEpPuxJQUZTOcgAI8EnnqCiJpRqtprOE=;\r
+ b=A1vk7GHLi5ZWlrBO6/s7JJMaAHjPdah5OKa9R0CpPEfYk6O+J5hX4nbY3oTx3N7xAU\r
+ FBxisP89jwsYRHUNobIKqhL6uTlVONSkzcuTe8R6CIJsOMWb3fcjxgK8lB14wkA9DMNd\r
+ 9ZyzPqpcGjLM+qG1mpkUkj3kfQfBCytIcHKHvQzGBHX8wY0UqbkVGSNVy2SRRyH/zGi1\r
+ ipxCAj0OQbWVOZ4wXFyVnJj6qQ8Kf1NljGXsVVDl0BsoEgw7swgMVlOCU81FXnS4jHXS\r
+ EiA3IyW95ABsKRqv1bZ4Y0s3U4H7xSuWLZc8lCVJVUCi5xpnIyEtozV86ptvdegdorsK\r
+ BAUA==\r
+X-Gm-Message-State: ALoCoQl4DSuAqv9FxjTO+U5OM65or6e19XvqjzkPNQys1C5grkiw35vgkiF5lHA/a646d3D4np1py9yRd3WvqmozO1KFjvRRlA==\r
+X-Received: by 10.28.102.5 with SMTP id a5mr44951081wmc.85.1451838041907;\r
+ Sun, 03 Jan 2016 08:20:41 -0800 (PST)\r
+Received: from localhost (mobile-access-bceec9-49.dhcp.inet.fi.\r
+ [188.238.201.49])\r
+ by smtp.gmail.com with ESMTPSA id z127sm26444878wme.2.2016.01.03.08.20.40\r
+ (version=TLSv1/SSLv3 cipher=OTHER);\r
+ Sun, 03 Jan 2016 08:20:41 -0800 (PST)\r
+From: Jani Nikula <jani@nikula.org>\r
+To: "W. Trevor King" <wking@tremily.us>, notmuch@notmuchmail.org\r
+Cc: David Bremner <david@tethera.net>, Tomi Ollila <tomi.ollila@iki.fi>,\r
+ Carl Worth <cworth@cworth.org>, "W. Trevor King" <wking@tremily.us>\r
+Subject: Re: [PATCH 2/5] notmuch-report: Rename from nmbug-status\r
+In-Reply-To:\r
+ <7df3f39aa67ed3862b937c7bf047a59d913e9c44.1451714099.git.wking@tremily.us>\r
+References: <cover.1451714099.git.wking@tremily.us>\r
+ <7df3f39aa67ed3862b937c7bf047a59d913e9c44.1451714099.git.wking@tremily.us>\r
+User-Agent: Notmuch/0.21+34~ge1fb729 (http://notmuchmail.org) Emacs/24.4.1\r
+ (x86_64-pc-linux-gnu)\r
+Date: Sun, 03 Jan 2016 18:19:50 +0200\r
+Message-ID: <874meuws7t.fsf@nikula.org>\r
+MIME-Version: 1.0\r
+Content-Type: text/plain\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: Sun, 03 Jan 2016 16:20:46 -0000\r
+\r
+On Sat, 02 Jan 2016, "W. Trevor King" <wking@tremily.us> wrote:\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
+FYI, at least with git format-patch you can add -M option to detect\r
+renames, and the resulting patch will be trivial to review.\r
+\r
+BR,\r
+Jani.\r
+\r
+\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