[PATCH v2 10/20] nmbug-status: Add Page and HtmlPage for modular rendering
authorW. Trevor King <wking@tremily.us>
Mon, 10 Feb 2014 18:40:31 +0000 (10:40 +1600)
committerW. Trevor King <wking@tremily.us>
Fri, 7 Nov 2014 17:59:52 +0000 (09:59 -0800)
ba/1b2949368d1beb26556bdb53bb38f900ff6a53 [new file with mode: 0644]

diff --git a/ba/1b2949368d1beb26556bdb53bb38f900ff6a53 b/ba/1b2949368d1beb26556bdb53bb38f900ff6a53
new file mode 100644 (file)
index 0000000..4092872
--- /dev/null
@@ -0,0 +1,445 @@
+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