--- /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 olra.theworths.org (Postfix) with ESMTP id 71399429E48\r
+ for <notmuch@notmuchmail.org>; Mon, 10 Feb 2014 10:44:47 -0800 (PST)\r
+X-Virus-Scanned: Debian amavisd-new at olra.theworths.org\r
+X-Amavis-Alert: BAD HEADER SECTION, Duplicate header field: "References"\r
+X-Spam-Flag: NO\r
+X-Spam-Score: 0\r
+X-Spam-Level: \r
+X-Spam-Status: No, score=0 tagged_above=-999 required=5\r
+ tests=[DKIM_SIGNED=0.1, DKIM_VALID=-0.1, RCVD_IN_DNSWL_NONE=-0.0001]\r
+ autolearn=disabled\r
+Received: from olra.theworths.org ([127.0.0.1])\r
+ by localhost (olra.theworths.org [127.0.0.1]) (amavisd-new, port 10024)\r
+ with ESMTP id XIb5YElx0E7d for <notmuch@notmuchmail.org>;\r
+ Mon, 10 Feb 2014 10:44:38 -0800 (PST)\r
+Received: from qmta10.westchester.pa.mail.comcast.net\r
+ (qmta10.westchester.pa.mail.comcast.net [76.96.62.17])\r
+ by olra.theworths.org (Postfix) with ESMTP id 2FF42431FC2\r
+ for <notmuch@notmuchmail.org>; Mon, 10 Feb 2014 10:43:57 -0800 (PST)\r
+Received: from omta06.westchester.pa.mail.comcast.net ([76.96.62.51])\r
+ by qmta10.westchester.pa.mail.comcast.net with comcast\r
+ id Qgop1n00w16LCl05AijxfE; Mon, 10 Feb 2014 18:43:57 +0000\r
+Received: from odin.tremily.us ([24.18.63.50])\r
+ by omta06.westchester.pa.mail.comcast.net with comcast\r
+ id Qihw1n00W152l3L3SihxNg; Mon, 10 Feb 2014 18:41:57 +0000\r
+Received: from mjolnir.tremily.us (unknown [192.168.0.140])\r
+ by odin.tremily.us (Postfix) with ESMTPS id 65FA710167B5;\r
+ Mon, 10 Feb 2014 10:41:56 -0800 (PST)\r
+Received: (nullmailer pid 1265 invoked by uid 1000);\r
+ Mon, 10 Feb 2014 18:40:45 -0000\r
+From: "W. Trevor King" <wking@tremily.us>\r
+To: notmuch@notmuchmail.org\r
+Subject: [PATCH v2 10/20] nmbug-status: Add Page and HtmlPage for modular\r
+ rendering\r
+Date: Mon, 10 Feb 2014 10:40:31 -0800\r
+Message-Id:\r
+ <4ae79f5279eb5deb6910f3c6a14a9188eb7b2fc2.1392056624.git.wking@tremily.us>\r
+X-Mailer: git-send-email 1.8.5.2.8.g0f6c0d1\r
+In-Reply-To: <cover.1392056624.git.wking@tremily.us>\r
+References: <cover.1392056624.git.wking@tremily.us>\r
+In-Reply-To: <cover.1392056624.git.wking@tremily.us>\r
+References: <cover.1392056624.git.wking@tremily.us>\r
+DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comcast.net;\r
+ s=q20121106; t=1392057837;\r
+ bh=7NpFzPKSstZ4e7uW6icWrX83XMnvv+sJVSqQkH1URbU=;\r
+ h=Received:Received:Received:Received:From:To:Subject:Date:\r
+ Message-Id;\r
+ b=UkKYnMntJWej+Dxk5975/rx1n5t/RMzEExPUk9YC77sPbavjbDUWBvpCKIPgp3MaH\r
+ sQA/XLaPCpEgXfuZteJV3GXOR4nRNVQG+AqxtE2fLwXNiGMenW3qpHNPrNFAFagGZz\r
+ no15dklgodO3CqWQjxYUDiICVeps9eH+ySDCn9wzpYDyWCbgJ/TP0jZCaeMwCHPSpH\r
+ z+8LkjIR02eSuyb1uERjwrI5aQYuPc4uJMUEMW8ne8AmEe6iwMqoOmuk6K/DT9n65v\r
+ MXu+7TBdL/GCfMtJiEgPrMP6BIF0kFoVynZc9xO40gCqLwmj0x6O0oJh8NnTg/wOoG\r
+ KCJ8MEKTldJXw==\r
+Cc: Tomi Ollila <tomi.ollila@iki.fi>\r
+X-BeenThere: notmuch@notmuchmail.org\r
+X-Mailman-Version: 2.1.13\r
+Precedence: list\r
+List-Id: "Use and development of the notmuch mail system."\r
+ <notmuch.notmuchmail.org>\r
+List-Unsubscribe: <http://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: <http://notmuchmail.org/mailman/listinfo/notmuch>,\r
+ <mailto:notmuch-request@notmuchmail.org?subject=subscribe>\r
+X-List-Received-Date: Mon, 10 Feb 2014 18:44:47 -0000\r
+\r
+I was having trouble understanding the logic of the longish print_view\r
+function, so I refactored the output generation into modular bits.\r
+The basic text rendering is handled by Page, which has enough hooks\r
+that HtmlPage can borrow the logic and slot-in HTML generators.\r
+\r
+By modularizing the logic it should also be easier to build other\r
+renderers if folks want to customize the layout for other projects.\r
+\r
+Timezones\r
+=========\r
+\r
+This commit has not effect on the output, except that some dates have\r
+been converted from the sender's timezone to UTC due to:\r
+\r
+ - val = m.get_header(header)\r
+ - ...\r
+ - if header == 'date':\r
+ - val = str.join(' ', val.split(None)[1:4])\r
+ - val = str(datetime.datetime.strptime(val, '%d %b %Y').date())\r
+ ...\r
+ + value = str(datetime.datetime.utcfromtimestamp(\r
+ + message.get_date()).date())\r
+\r
+I also tweaked the HTML header date to be utcnow instead of the local\r
+now() to make all times independent of the generator's local time.\r
+This matches Gmane, which converts all Date headers to UTC (although\r
+they use a 'GMT' suffix). Notmuch uses\r
+g_mime_utils_header_decode_date to calculate the UTC timestamps, but\r
+uses a NULL tz_offset which drops the information we'd need to get\r
+back to the sender's local time [1]. With the generator's local time\r
+arbitrarily different from the sender's and viewer's local time,\r
+sticking with UTC seems the best bet.\r
+\r
+[1]: https://developer.gnome.org/gmime/stable/gmime-gmime-utils.html#g-mime-utils-header-decode-date\r
+---\r
+ devel/nmbug/nmbug-status | 292 +++++++++++++++++++++++++++--------------------\r
+ 1 file changed, 171 insertions(+), 121 deletions(-)\r
+\r
+diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status\r
+index 22b6b10..6aa2583 100755\r
+--- a/devel/nmbug/nmbug-status\r
++++ b/devel/nmbug/nmbug-status\r
+@@ -5,10 +5,13 @@\r
+ # dependencies\r
+ # - python 2.6 for json\r
+ # - argparse; either python 2.7, or install separately\r
++# - collections.OrderedDict; python 2.7\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
+ import locale\r
+@@ -24,6 +27,7 @@ import subprocess\r
+ \r
+ \r
+ _ENCODING = locale.getpreferredencoding() or sys.getdefaultencoding()\r
++_PAGES = {}\r
+ \r
+ \r
+ def read_config(path=None, encoding=None):\r
+@@ -50,104 +54,175 @@ def read_config(path=None, encoding=None):\r
+ return json.load(fp)\r
+ \r
+ \r
+-class Thread:\r
+- def __init__(self, last, lines):\r
+- self.last = last\r
+- self.lines = lines\r
+-\r
+- def join_utf8_with_newlines(self):\r
+- return '\n'.join( (line.encode('utf-8') for line in self.lines) )\r
+-\r
+-\r
+-def output_with_separator(threadlist, sep):\r
+- outputs = (thread.join_utf8_with_newlines() for thread in threadlist)\r
+- print(sep.join(outputs))\r
+-\r
+-\r
+-def print_view(database, title, query, comment,\r
+- headers=('date', 'from', 'subject')):\r
+-\r
+- query_string = ' and '.join(query)\r
+- q_new = notmuch.Query(database, query_string)\r
+- q_new.set_sort(notmuch.Query.SORT.OLDEST_FIRST)\r
+-\r
+- last_thread_id = ''\r
+- threads = {}\r
+- threadlist = []\r
+- out = {}\r
+- last = None\r
+- lines = None\r
+-\r
+- if output_format == 'html':\r
+- print('<h3><a name="%s" />%s</h3>' % (title, title))\r
+- print(comment)\r
+- print('The view is generated from the following query:')\r
+- print('<blockquote>')\r
+- print(query_string)\r
+- print('</blockquote>')\r
+- print('<table>\n')\r
+-\r
+- for m in q_new.search_messages():\r
+-\r
+- thread_id = m.get_thread_id()\r
+-\r
+- if thread_id != last_thread_id:\r
+- if threads.has_key(thread_id):\r
+- last = threads[thread_id].last\r
+- lines = threads[thread_id].lines\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='UTF-8')(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
++ 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(notmuch.Query.SORT.OLDEST_FIRST)\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
+- last = {}\r
+- lines = []\r
+- thread = Thread(last, lines)\r
++ thread = Thread()\r
+ threads[thread_id] = thread\r
+- for h in headers:\r
+- last[h] = ''\r
+- threadlist.append(thread)\r
+- last_thread_id = thread_id\r
+-\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
+- val = m.get_header(header)\r
+-\r
+- if header == 'date':\r
+- val = str.join(' ', val.split(None)[1:4])\r
+- val = str(datetime.datetime.strptime(val, '%d %b %Y').date())\r
+- elif header == 'from':\r
+- (val, addr) = email.utils.parseaddr(val)\r
+- if val == '':\r
+- val = addr.split('@')[0]\r
+-\r
+- if header != 'subject' and last[header] == val:\r
+- out[header] = ''\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
+- out[header] = val\r
+- last[header] = val\r
+-\r
+- mid = m.get_message_id()\r
+- out['id'] = 'id:"%s"' % mid\r
+-\r
+- if output_format == 'html':\r
+-\r
+- out['subject'] = '<a href="http://mid.gmane.org/%s">%s</a>' % (\r
+- quote(mid), out['subject'])\r
+-\r
+- lines.append(' <tr><td>%s' % out['date'])\r
+- lines.append('</td><td>%s' % out['id'])\r
+- lines.append('</td></tr>')\r
+- lines.append(' <tr><td>%s' % out['from'])\r
+- lines.append('</td><td>%s' % out['subject'])\r
+- lines.append('</td></tr>')\r
+- else:\r
+- lines.append('%(date)-10.10s %(from)-20.20s %(subject)-40.40s\n%(id)72s' % out)\r
+-\r
+- if output_format == 'html':\r
+- output_with_separator(threadlist,\r
+- '\n<tr><td colspan="2"><br /></td></tr>\n')\r
+- print('</table>')\r
+- else:\r
+- output_with_separator(threadlist, '\n\n')\r
+-\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
++ 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
++ stream.write(\r
++ '<li><a href="#{title}">{title}</a></li>\n'.format(**view))\r
++ stream.write('</ul>\n')\r
++\r
++ def _write_view_header(self, view, stream):\r
++ stream.write('<h3><a name="{title}" />{title}</h3>\n'.format(**view))\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
++ '<blockquote>',\r
++ view['query-string'],\r
++ '</blockquote>',\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
++ for message_display_data in thread:\r
++ stream.write((\r
++ '<tr><td>{date}\n'\r
++ '</td><td>{message-id-term}\n'\r
++ '</td></tr>\n'\r
++ '<tr><td>{from}\n'\r
++ '</td><td>{subject}\n'\r
++ '</td></tr>\n'\r
++ ).format(**message_display_data))\r
++ if thread != threads[-1]:\r
++ stream.write('<tr><td colspan="2"><br /></td></tr>\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': display_data['subject'],\r
++ }\r
++ display_data['subject'] = (\r
++ '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'\r
++ ).format(**d)\r
++ return (running_data, display_data)\r
++\r
++\r
++_PAGES['text'] = Page()\r
++_PAGES['html'] = HtmlPage(\r
++ header='''<?xml version="1.0" encoding="utf-8" ?>\r
++<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\r
++<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\r
++<head>\r
++<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />\r
++<title>Notmuch Patches</title>\r
++</head>\r
++<body>\r
++<h2>Notmuch Patches</h2>\r
++Generated: {date}<br />\r
++For more infomation see <a href="http://notmuchmail.org/nmbug">nmbug</a>\r
++<h3>Views</h3>\r
++'''.format(date=datetime.datetime.utcnow().date()),\r
++ footer='</body>\n</html>\n',\r
++ )\r
+ \r
+-# parse command line arguments\r
+ \r
+ parser = argparse.ArgumentParser()\r
+ parser.add_argument('--text', help='output plain text format',\r
+@@ -177,34 +252,9 @@ else:\r
+ import notmuch\r
+ \r
+ if args.text:\r
+- output_format = 'text'\r
++ page = _PAGES['text']\r
+ else:\r
+- output_format = 'html'\r
+-\r
+-# main program\r
++ page = _PAGES['html']\r
+ \r
+ db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
+-\r
+-if output_format == 'html':\r
+- print('''<?xml version="1.0" encoding="utf-8" ?>\r
+-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\r
+-<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\r
+-<head>\r
+-<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />\r
+-<title>Notmuch Patches</title>\r
+-</head>\r
+-<body>\r
+-<h2>Notmuch Patches</h2>\r
+-Generated: {date}<br />\r
+-For more infomation see <a href="http://notmuchmail.org/nmbug">nmbug</a>\r
+-<h3>Views</h3>\r
+-<ul>'''.format(date=datetime.datetime.utcnow().date()))\r
+- for view in config['views']:\r
+- print('<li><a href="#%(title)s">%(title)s</a></li>' % view)\r
+- print('</ul>')\r
+-\r
+-for view in config['views']:\r
+- print_view(database=db, **view)\r
+-\r
+-if output_format == 'html':\r
+- print('</body>\n</html>')\r
++page.write(database=db, views=config['views'])\r
+-- \r
+1.8.5.2.8.g0f6c0d1\r
+\r