--- /dev/null
+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