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