[PATCH] RFC: all deleting all properties with a given key
[notmuch-archives.git] / 7a / 9a58a895a7643ff592a48b67084d3f7c51b4e9
1 Return-Path: <wking@tremily.us>\r
2 X-Original-To: notmuch@notmuchmail.org\r
3 Delivered-To: notmuch@notmuchmail.org\r
4 Received: from localhost (localhost [127.0.0.1])\r
5  by arlo.cworth.org (Postfix) with ESMTP id B483C6DE1B58\r
6  for <notmuch@notmuchmail.org>; Fri,  1 Jan 2016 22:10:42 -0800 (PST)\r
7 X-Virus-Scanned: Debian amavisd-new at cworth.org\r
8 X-Amavis-Alert: BAD HEADER SECTION, Duplicate header field: "References"\r
9 X-Spam-Flag: NO\r
10 X-Spam-Score: 0.04\r
11 X-Spam-Level: \r
12 X-Spam-Status: No, score=0.04 tagged_above=-999 required=5 tests=[AWL=0.041,\r
13  DKIM_SIGNED=0.1, DKIM_VALID=-0.1, RCVD_IN_DNSWL_NONE=-0.0001,\r
14  SPF_PASS=-0.001] autolearn=disabled\r
15 Received: from arlo.cworth.org ([127.0.0.1])\r
16  by localhost (arlo.cworth.org [127.0.0.1]) (amavisd-new, port 10024)\r
17  with ESMTP id neE8vqetwSAG for <notmuch@notmuchmail.org>;\r
18  Fri,  1 Jan 2016 22:10:39 -0800 (PST)\r
19 Received: from resqmta-po-04v.sys.comcast.net (resqmta-po-04v.sys.comcast.net\r
20  [96.114.154.163])\r
21  by arlo.cworth.org (Postfix) with ESMTPS id 4765F6DE1B50\r
22  for <notmuch@notmuchmail.org>; Fri,  1 Jan 2016 22:10:02 -0800 (PST)\r
23 Received: from resomta-po-19v.sys.comcast.net ([96.114.154.243])\r
24  by resqmta-po-04v.sys.comcast.net with comcast\r
25  id 0uA11s0015FMDhs01uA1rC; Sat, 02 Jan 2016 06:10:01 +0000\r
26 Received: from mail.tremily.us ([73.221.72.168])\r
27  by resomta-po-19v.sys.comcast.net with comcast\r
28  id 0u7z1s00L3dr3C901u80VP; Sat, 02 Jan 2016 06:08:01 +0000\r
29 Received: from ullr.tremily.us (unknown [192.168.10.7])\r
30  by mail.tremily.us (Postfix) with ESMTPS id 6ED401B2F57F;\r
31  Fri,  1 Jan 2016 22:07:59 -0800 (PST)\r
32 Received: (nullmailer pid 15181 invoked by uid 1000);\r
33  Sat, 02 Jan 2016 06:08:07 -0000\r
34 From: "W. Trevor King" <wking@tremily.us>\r
35 To: notmuch@notmuchmail.org\r
36 Cc: David Bremner <david@tethera.net>,\r
37  Tomi Ollila <tomi.ollila@iki.fi>, Jani Nikula <jani@nikula.org>,\r
38  Carl Worth <cworth@cworth.org>, "W. Trevor King" <wking@tremily.us>\r
39 Subject: [PATCH 2/5] notmuch-report: Rename from nmbug-status\r
40 Date: Fri,  1 Jan 2016 22:08:02 -0800\r
41 Message-Id:\r
42  <7df3f39aa67ed3862b937c7bf047a59d913e9c44.1451714099.git.wking@tremily.us>\r
43 X-Mailer: git-send-email 2.1.0.60.g85f0837\r
44 In-Reply-To: <cover.1451714099.git.wking@tremily.us>\r
45 References: <cover.1451714099.git.wking@tremily.us>\r
46 In-Reply-To: <cover.1451714099.git.wking@tremily.us>\r
47 References: <cover.1451714099.git.wking@tremily.us>\r
48 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=comcast.net;\r
49  s=q20140121; t=1451715001;\r
50  bh=b+eqQb79fXv+mPmYMNt7f5OjvZ4+EPolsU093hd980c=;\r
51  h=Received:Received:Received:Received:From:To:Subject:Date:\r
52  Message-Id;\r
53  b=pA4yTDI161iiniYaUP+XOFA4BsADTNzuzbQ2ktl3DzWNOqDzNT3lHfl3241hAVZaL\r
54  dQQSgQXW4iIakjEE2IpFYS5bAgG66uukBP5ndleopd3FtQuhbhDv0S02BqtqU6IM3Y\r
55  zuRjkzqPsVpbSwqudOusiOQtkCn2hoE25e4rDpoOZUF2to1RVUd9u6N5bBGA8RlcV1\r
56  Iz0T8+ev5ImEGfXuEZqqRMjJ87TFfos1UzfoK8uaBu6rhKD3D2MN1c3H3hFb3CXOBX\r
57  nCZtUyHqSnkP7XcZezzbSfiGoMWgvE5VGvF2pHReSxOvmJgMwZap65ErCB09X4jptK\r
58  ywBmS/HtBZkqg==\r
59 X-BeenThere: notmuch@notmuchmail.org\r
60 X-Mailman-Version: 2.1.20\r
61 Precedence: list\r
62 List-Id: "Use and development of the notmuch mail system."\r
63  <notmuch.notmuchmail.org>\r
64 List-Unsubscribe: <https://notmuchmail.org/mailman/options/notmuch>,\r
65  <mailto:notmuch-request@notmuchmail.org?subject=unsubscribe>\r
66 List-Archive: <http://notmuchmail.org/pipermail/notmuch/>\r
67 List-Post: <mailto:notmuch@notmuchmail.org>\r
68 List-Help: <mailto:notmuch-request@notmuchmail.org?subject=help>\r
69 List-Subscribe: <https://notmuchmail.org/mailman/listinfo/notmuch>,\r
70  <mailto:notmuch-request@notmuchmail.org?subject=subscribe>\r
71 X-List-Received-Date: Sat, 02 Jan 2016 06:10:42 -0000\r
72 \r
73 This script generates reports based on notmuch queries, and doesn't\r
74 really have anything to do with nmbug, except for sharing the NMBGIT\r
75 environment variable.\r
76 ---\r
77  devel/nmbug/nmbug-status   | 419 ---------------------------------------------\r
78  devel/nmbug/notmuch-report | 419 +++++++++++++++++++++++++++++++++++++++++++++\r
79  2 files changed, 419 insertions(+), 419 deletions(-)\r
80  delete mode 100755 devel/nmbug/nmbug-status\r
81  create mode 100755 devel/nmbug/notmuch-report\r
82 \r
83 diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status\r
84 deleted file mode 100755\r
85 index 22e3b5b..0000000\r
86 --- a/devel/nmbug/nmbug-status\r
87 +++ /dev/null\r
88 @@ -1,419 +0,0 @@\r
89 -#!/usr/bin/python\r
90 -#\r
91 -# Copyright (c) 2011-2012 David Bremner <david@tethera.net>\r
92 -#\r
93 -# dependencies\r
94 -#       - python 2.6 for json\r
95 -#       - argparse; either python 2.7, or install separately\r
96 -#\r
97 -# This program is free software: you can redistribute it and/or modify\r
98 -# it under the terms of the GNU General Public License as published by\r
99 -# the Free Software Foundation, either version 3 of the License, or\r
100 -# (at your option) any later version.\r
101 -#\r
102 -# This program is distributed in the hope that it will be useful,\r
103 -# but WITHOUT ANY WARRANTY; without even the implied warranty of\r
104 -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\r
105 -# GNU General Public License for more details.\r
106 -#\r
107 -# You should have received a copy of the GNU General Public License\r
108 -# along with this program.  If not, see http://www.gnu.org/licenses/ .\r
109 -\r
110 -"""Generate HTML for one or more notmuch searches.\r
111 -\r
112 -Messages matching each search are grouped by thread.  Each message\r
113 -that contains both a subject and message-id will have the displayed\r
114 -subject link to the Gmane view of the message.\r
115 -"""\r
116 -\r
117 -from __future__ import print_function\r
118 -from __future__ import unicode_literals\r
119 -\r
120 -import codecs\r
121 -import collections\r
122 -import datetime\r
123 -import email.utils\r
124 -try:  # Python 3\r
125 -    from urllib.parse import quote\r
126 -except ImportError:  # Python 2\r
127 -    from urllib import quote\r
128 -import json\r
129 -import argparse\r
130 -import os\r
131 -import re\r
132 -import sys\r
133 -import subprocess\r
134 -import xml.sax.saxutils\r
135 -\r
136 -\r
137 -_ENCODING = 'UTF-8'\r
138 -_PAGES = {}\r
139 -\r
140 -\r
141 -if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier\r
142 -    class _OrderedDict (dict):\r
143 -        "Just enough of a stub to get through Page._get_threads"\r
144 -        def __init__(self, *args, **kwargs):\r
145 -            super(_OrderedDict, self).__init__(*args, **kwargs)\r
146 -            self._keys = []  # record key order\r
147 -\r
148 -        def __setitem__(self, key, value):\r
149 -            super(_OrderedDict, self).__setitem__(key, value)\r
150 -            self._keys.append(key)\r
151 -\r
152 -        def values(self):\r
153 -            for key in self._keys:\r
154 -                yield self[key]\r
155 -\r
156 -\r
157 -    collections.OrderedDict = _OrderedDict\r
158 -\r
159 -\r
160 -class ConfigError (Exception):\r
161 -    """Errors with config file usage\r
162 -    """\r
163 -    pass\r
164 -\r
165 -\r
166 -def read_config(path=None, encoding=None):\r
167 -    "Read config from json file"\r
168 -    if not encoding:\r
169 -        encoding = _ENCODING\r
170 -    if path:\r
171 -        try:\r
172 -            with open(path, 'rb') as f:\r
173 -                config_bytes = f.read()\r
174 -        except IOError as e:\r
175 -            raise ConfigError('Could not read config from {}'.format(path))\r
176 -    else:\r
177 -        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))\r
178 -        branch = 'config'\r
179 -        filename = 'status-config.json'\r
180 -\r
181 -        # read only the first line from the pipe\r
182 -        sha1_bytes = subprocess.Popen(\r
183 -            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],\r
184 -            stdout=subprocess.PIPE).stdout.readline()\r
185 -        sha1 = sha1_bytes.decode(encoding).rstrip()\r
186 -        if not sha1:\r
187 -            raise ConfigError(\r
188 -                ("No local branch '{branch}' in {nmbgit}.  "\r
189 -                 'Checkout a local {branch} branch or explicitly set --config.'\r
190 -                ).format(branch=branch, nmbgit=nmbhome))\r
191 -\r
192 -        p = subprocess.Popen(\r
193 -            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',\r
194 -             '{}:{}'.format(sha1, filename)],\r
195 -            stdout=subprocess.PIPE)\r
196 -        config_bytes, err = p.communicate()\r
197 -        status = p.wait()\r
198 -        if status != 0:\r
199 -            raise ConfigError(\r
200 -                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "\r
201 -                 'Add the file or explicitly set --config.'\r
202 -                ).format(filename=filename, branch=branch, nmbgit=nmbhome))\r
203 -\r
204 -    config_json = config_bytes.decode(encoding)\r
205 -    try:\r
206 -        return json.loads(config_json)\r
207 -    except ValueError as e:\r
208 -        if not path:\r
209 -            path = "{} in branch '{}' of {}".format(\r
210 -                filename, branch, nmbhome)\r
211 -        raise ConfigError(\r
212 -            'Could not parse JSON from the config file {}:\n{}'.format(\r
213 -                path, e))\r
214 -\r
215 -\r
216 -class Thread (list):\r
217 -    def __init__(self):\r
218 -        self.running_data = {}\r
219 -\r
220 -\r
221 -class Page (object):\r
222 -    def __init__(self, header=None, footer=None):\r
223 -        self.header = header\r
224 -        self.footer = footer\r
225 -\r
226 -    def write(self, database, views, stream=None):\r
227 -        if not stream:\r
228 -            try:  # Python 3\r
229 -                byte_stream = sys.stdout.buffer\r
230 -            except AttributeError:  # Python 2\r
231 -                byte_stream = sys.stdout\r
232 -            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)\r
233 -        self._write_header(views=views, stream=stream)\r
234 -        for view in views:\r
235 -            self._write_view(database=database, view=view, stream=stream)\r
236 -        self._write_footer(views=views, stream=stream)\r
237 -\r
238 -    def _write_header(self, views, stream):\r
239 -        if self.header:\r
240 -            stream.write(self.header)\r
241 -\r
242 -    def _write_footer(self, views, stream):\r
243 -        if self.footer:\r
244 -            stream.write(self.footer)\r
245 -\r
246 -    def _write_view(self, database, view, stream):\r
247 -        # sort order, default to oldest-first\r
248 -        sort_key = view.get('sort', 'oldest-first')\r
249 -        # dynamically accept all values in Query.SORT\r
250 -        sort_attribute = sort_key.upper().replace('-', '_')\r
251 -        try:\r
252 -            sort = getattr(notmuch.Query.SORT, sort_attribute)\r
253 -        except AttributeError:\r
254 -            raise ConfigError('Invalid sort setting for {}: {!r}'.format(\r
255 -                view['title'], sort_key))\r
256 -        if 'query-string' not in view:\r
257 -            query = view['query']\r
258 -            view['query-string'] = ' and '.join(query)\r
259 -        q = notmuch.Query(database, view['query-string'])\r
260 -        q.set_sort(sort)\r
261 -        threads = self._get_threads(messages=q.search_messages())\r
262 -        self._write_view_header(view=view, stream=stream)\r
263 -        self._write_threads(threads=threads, stream=stream)\r
264 -\r
265 -    def _get_threads(self, messages):\r
266 -        threads = collections.OrderedDict()\r
267 -        for message in messages:\r
268 -            thread_id = message.get_thread_id()\r
269 -            if thread_id in threads:\r
270 -                thread = threads[thread_id]\r
271 -            else:\r
272 -                thread = Thread()\r
273 -                threads[thread_id] = thread\r
274 -            thread.running_data, display_data = self._message_display_data(\r
275 -                running_data=thread.running_data, message=message)\r
276 -            thread.append(display_data)\r
277 -        return list(threads.values())\r
278 -\r
279 -    def _write_view_header(self, view, stream):\r
280 -        pass\r
281 -\r
282 -    def _write_threads(self, threads, stream):\r
283 -        for thread in threads:\r
284 -            for message_display_data in thread:\r
285 -                stream.write(\r
286 -                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'\r
287 -                     '{message-id-term:>72}\n'\r
288 -                     ).format(**message_display_data))\r
289 -            if thread != threads[-1]:\r
290 -                stream.write('\n')\r
291 -\r
292 -    def _message_display_data(self, running_data, message):\r
293 -        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')\r
294 -        data = {}\r
295 -        for header in headers:\r
296 -            if header == 'thread-id':\r
297 -                value = message.get_thread_id()\r
298 -            elif header == 'message-id':\r
299 -                value = message.get_message_id()\r
300 -                data['message-id-term'] = 'id:"{0}"'.format(value)\r
301 -            elif header == 'date':\r
302 -                value = str(datetime.datetime.utcfromtimestamp(\r
303 -                    message.get_date()).date())\r
304 -            else:\r
305 -                value = message.get_header(header)\r
306 -            if header == 'from':\r
307 -                (value, addr) = email.utils.parseaddr(value)\r
308 -                if not value:\r
309 -                    value = addr.split('@')[0]\r
310 -            data[header] = value\r
311 -        next_running_data = data.copy()\r
312 -        for header, value in data.items():\r
313 -            if header in ['message-id', 'subject']:\r
314 -                continue\r
315 -            if value == running_data.get(header, None):\r
316 -                data[header] = ''\r
317 -        return (next_running_data, data)\r
318 -\r
319 -\r
320 -class HtmlPage (Page):\r
321 -    _slug_regexp = re.compile('\W+')\r
322 -\r
323 -    def _write_header(self, views, stream):\r
324 -        super(HtmlPage, self)._write_header(views=views, stream=stream)\r
325 -        stream.write('<ul>\n')\r
326 -        for view in views:\r
327 -            if 'id' not in view:\r
328 -                view['id'] = self._slug(view['title'])\r
329 -            stream.write(\r
330 -                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))\r
331 -        stream.write('</ul>\n')\r
332 -\r
333 -    def _write_view_header(self, view, stream):\r
334 -        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))\r
335 -        stream.write('<p>\n')\r
336 -        if 'comment' in view:\r
337 -            stream.write(view['comment'])\r
338 -            stream.write('\n')\r
339 -        for line in [\r
340 -                'The view is generated from the following query:',\r
341 -                '</p>',\r
342 -                '<p>',\r
343 -                '  <code>',\r
344 -                view['query-string'],\r
345 -                '  </code>',\r
346 -                '</p>',\r
347 -                ]:\r
348 -            stream.write(line)\r
349 -            stream.write('\n')\r
350 -\r
351 -    def _write_threads(self, threads, stream):\r
352 -        if not threads:\r
353 -            return\r
354 -        stream.write('<table>\n')\r
355 -        for thread in threads:\r
356 -            stream.write('  <tbody>\n')\r
357 -            for message_display_data in thread:\r
358 -                stream.write((\r
359 -                    '    <tr class="message-first">\n'\r
360 -                    '      <td>{date}</td>\n'\r
361 -                    '      <td><code>{message-id-term}</code></td>\n'\r
362 -                    '    </tr>\n'\r
363 -                    '    <tr class="message-last">\n'\r
364 -                    '      <td>{from}</td>\n'\r
365 -                    '      <td>{subject}</td>\n'\r
366 -                    '    </tr>\n'\r
367 -                    ).format(**message_display_data))\r
368 -            stream.write('  </tbody>\n')\r
369 -            if thread != threads[-1]:\r
370 -                stream.write(\r
371 -                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')\r
372 -        stream.write('</table>\n')\r
373 -\r
374 -    def _message_display_data(self, *args, **kwargs):\r
375 -        running_data, display_data = super(\r
376 -            HtmlPage, self)._message_display_data(\r
377 -                *args, **kwargs)\r
378 -        if 'subject' in display_data and 'message-id' in display_data:\r
379 -            d = {\r
380 -                'message-id': quote(display_data['message-id']),\r
381 -                'subject': xml.sax.saxutils.escape(display_data['subject']),\r
382 -                }\r
383 -            display_data['subject'] = (\r
384 -                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'\r
385 -                ).format(**d)\r
386 -        for key in ['message-id', 'from']:\r
387 -            if key in display_data:\r
388 -                display_data[key] = xml.sax.saxutils.escape(display_data[key])\r
389 -        return (running_data, display_data)\r
390 -\r
391 -    def _slug(self, string):\r
392 -        return self._slug_regexp.sub('-', string)\r
393 -\r
394 -parser = argparse.ArgumentParser(description=__doc__)\r
395 -parser.add_argument('--text', help='output plain text format',\r
396 -                    action='store_true')\r
397 -parser.add_argument('--config', help='load config from given file',\r
398 -                    metavar='PATH')\r
399 -parser.add_argument('--list-views', help='list views',\r
400 -                    action='store_true')\r
401 -parser.add_argument('--get-query', help='get query for view',\r
402 -                    metavar='VIEW')\r
403 -\r
404 -args = parser.parse_args()\r
405 -\r
406 -try:\r
407 -    config = read_config(path=args.config)\r
408 -except ConfigError as e:\r
409 -    print(e, file=sys.stderr)\r
410 -    sys.exit(1)\r
411 -\r
412 -header_template = config['meta'].get('header', '''<!DOCTYPE html>\r
413 -<html lang="en">\r
414 -<head>\r
415 -  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />\r
416 -  <title>{title}</title>\r
417 -  <style media="screen" type="text/css">\r
418 -    table {{\r
419 -      border-spacing: 0;\r
420 -    }}\r
421 -    tr.message-first td {{\r
422 -      padding-top: {inter_message_padding};\r
423 -    }}\r
424 -    tr.message-last td {{\r
425 -      padding-bottom: {inter_message_padding};\r
426 -    }}\r
427 -    td {{\r
428 -      padding-left: {border_radius};\r
429 -      padding-right: {border_radius};\r
430 -    }}\r
431 -    tr:first-child td:first-child {{\r
432 -      border-top-left-radius: {border_radius};\r
433 -    }}\r
434 -    tr:first-child td:last-child {{\r
435 -      border-top-right-radius: {border_radius};\r
436 -    }}\r
437 -    tr:last-child td:first-child {{\r
438 -      border-bottom-left-radius: {border_radius};\r
439 -    }}\r
440 -    tr:last-child td:last-child {{\r
441 -      border-bottom-right-radius: {border_radius};\r
442 -    }}\r
443 -    tbody:nth-child(4n+1) tr td {{\r
444 -      background-color: #ffd96e;\r
445 -    }}\r
446 -    tbody:nth-child(4n+3) tr td {{\r
447 -      background-color: #bce;\r
448 -    }}\r
449 -    hr {{\r
450 -      border: 0;\r
451 -      height: 1px;\r
452 -      color: #ccc;\r
453 -      background-color: #ccc;\r
454 -    }}\r
455 -  </style>\r
456 -</head>\r
457 -<body>\r
458 -<h2>{title}</h2>\r
459 -{blurb}\r
460 -</p>\r
461 -<h3>Views</h3>\r
462 -''')\r
463 -\r
464 -footer_template = config['meta'].get('footer', '''\r
465 -<hr>\r
466 -<p>Generated: {datetime}\r
467 -</body>\r
468 -</html>\r
469 -''')\r
470 -\r
471 -now = datetime.datetime.utcnow()\r
472 -context = {\r
473 -    'date': now,\r
474 -    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),\r
475 -    'title': config['meta']['title'],\r
476 -    'blurb': config['meta']['blurb'],\r
477 -    'encoding': _ENCODING,\r
478 -    'inter_message_padding': '0.25em',\r
479 -    'border_radius': '0.5em',\r
480 -    }\r
481 -\r
482 -_PAGES['text'] = Page()\r
483 -_PAGES['html'] = HtmlPage(\r
484 -    header=header_template.format(**context),\r
485 -    footer=footer_template.format(**context),\r
486 -    )\r
487 -\r
488 -if args.list_views:\r
489 -    for view in config['views']:\r
490 -        print(view['title'])\r
491 -    sys.exit(0)\r
492 -elif args.get_query != None:\r
493 -    for view in config['views']:\r
494 -        if args.get_query == view['title']:\r
495 -            print(' and '.join(view['query']))\r
496 -    sys.exit(0)\r
497 -else:\r
498 -    # only import notmuch if needed\r
499 -    import notmuch\r
500 -\r
501 -if args.text:\r
502 -    page = _PAGES['text']\r
503 -else:\r
504 -    page = _PAGES['html']\r
505 -\r
506 -db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
507 -page.write(database=db, views=config['views'])\r
508 diff --git a/devel/nmbug/notmuch-report b/devel/nmbug/notmuch-report\r
509 new file mode 100755\r
510 index 0000000..22e3b5b\r
511 --- /dev/null\r
512 +++ b/devel/nmbug/notmuch-report\r
513 @@ -0,0 +1,419 @@\r
514 +#!/usr/bin/python\r
515 +#\r
516 +# Copyright (c) 2011-2012 David Bremner <david@tethera.net>\r
517 +#\r
518 +# dependencies\r
519 +#       - python 2.6 for json\r
520 +#       - argparse; either python 2.7, or install separately\r
521 +#\r
522 +# This program is free software: you can redistribute it and/or modify\r
523 +# it under the terms of the GNU General Public License as published by\r
524 +# the Free Software Foundation, either version 3 of the License, or\r
525 +# (at your option) any later version.\r
526 +#\r
527 +# This program is distributed in the hope that it will be useful,\r
528 +# but WITHOUT ANY WARRANTY; without even the implied warranty of\r
529 +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\r
530 +# GNU General Public License for more details.\r
531 +#\r
532 +# You should have received a copy of the GNU General Public License\r
533 +# along with this program.  If not, see http://www.gnu.org/licenses/ .\r
534 +\r
535 +"""Generate HTML for one or more notmuch searches.\r
536 +\r
537 +Messages matching each search are grouped by thread.  Each message\r
538 +that contains both a subject and message-id will have the displayed\r
539 +subject link to the Gmane view of the message.\r
540 +"""\r
541 +\r
542 +from __future__ import print_function\r
543 +from __future__ import unicode_literals\r
544 +\r
545 +import codecs\r
546 +import collections\r
547 +import datetime\r
548 +import email.utils\r
549 +try:  # Python 3\r
550 +    from urllib.parse import quote\r
551 +except ImportError:  # Python 2\r
552 +    from urllib import quote\r
553 +import json\r
554 +import argparse\r
555 +import os\r
556 +import re\r
557 +import sys\r
558 +import subprocess\r
559 +import xml.sax.saxutils\r
560 +\r
561 +\r
562 +_ENCODING = 'UTF-8'\r
563 +_PAGES = {}\r
564 +\r
565 +\r
566 +if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier\r
567 +    class _OrderedDict (dict):\r
568 +        "Just enough of a stub to get through Page._get_threads"\r
569 +        def __init__(self, *args, **kwargs):\r
570 +            super(_OrderedDict, self).__init__(*args, **kwargs)\r
571 +            self._keys = []  # record key order\r
572 +\r
573 +        def __setitem__(self, key, value):\r
574 +            super(_OrderedDict, self).__setitem__(key, value)\r
575 +            self._keys.append(key)\r
576 +\r
577 +        def values(self):\r
578 +            for key in self._keys:\r
579 +                yield self[key]\r
580 +\r
581 +\r
582 +    collections.OrderedDict = _OrderedDict\r
583 +\r
584 +\r
585 +class ConfigError (Exception):\r
586 +    """Errors with config file usage\r
587 +    """\r
588 +    pass\r
589 +\r
590 +\r
591 +def read_config(path=None, encoding=None):\r
592 +    "Read config from json file"\r
593 +    if not encoding:\r
594 +        encoding = _ENCODING\r
595 +    if path:\r
596 +        try:\r
597 +            with open(path, 'rb') as f:\r
598 +                config_bytes = f.read()\r
599 +        except IOError as e:\r
600 +            raise ConfigError('Could not read config from {}'.format(path))\r
601 +    else:\r
602 +        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))\r
603 +        branch = 'config'\r
604 +        filename = 'status-config.json'\r
605 +\r
606 +        # read only the first line from the pipe\r
607 +        sha1_bytes = subprocess.Popen(\r
608 +            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],\r
609 +            stdout=subprocess.PIPE).stdout.readline()\r
610 +        sha1 = sha1_bytes.decode(encoding).rstrip()\r
611 +        if not sha1:\r
612 +            raise ConfigError(\r
613 +                ("No local branch '{branch}' in {nmbgit}.  "\r
614 +                 'Checkout a local {branch} branch or explicitly set --config.'\r
615 +                ).format(branch=branch, nmbgit=nmbhome))\r
616 +\r
617 +        p = subprocess.Popen(\r
618 +            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',\r
619 +             '{}:{}'.format(sha1, filename)],\r
620 +            stdout=subprocess.PIPE)\r
621 +        config_bytes, err = p.communicate()\r
622 +        status = p.wait()\r
623 +        if status != 0:\r
624 +            raise ConfigError(\r
625 +                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "\r
626 +                 'Add the file or explicitly set --config.'\r
627 +                ).format(filename=filename, branch=branch, nmbgit=nmbhome))\r
628 +\r
629 +    config_json = config_bytes.decode(encoding)\r
630 +    try:\r
631 +        return json.loads(config_json)\r
632 +    except ValueError as e:\r
633 +        if not path:\r
634 +            path = "{} in branch '{}' of {}".format(\r
635 +                filename, branch, nmbhome)\r
636 +        raise ConfigError(\r
637 +            'Could not parse JSON from the config file {}:\n{}'.format(\r
638 +                path, e))\r
639 +\r
640 +\r
641 +class Thread (list):\r
642 +    def __init__(self):\r
643 +        self.running_data = {}\r
644 +\r
645 +\r
646 +class Page (object):\r
647 +    def __init__(self, header=None, footer=None):\r
648 +        self.header = header\r
649 +        self.footer = footer\r
650 +\r
651 +    def write(self, database, views, stream=None):\r
652 +        if not stream:\r
653 +            try:  # Python 3\r
654 +                byte_stream = sys.stdout.buffer\r
655 +            except AttributeError:  # Python 2\r
656 +                byte_stream = sys.stdout\r
657 +            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)\r
658 +        self._write_header(views=views, stream=stream)\r
659 +        for view in views:\r
660 +            self._write_view(database=database, view=view, stream=stream)\r
661 +        self._write_footer(views=views, stream=stream)\r
662 +\r
663 +    def _write_header(self, views, stream):\r
664 +        if self.header:\r
665 +            stream.write(self.header)\r
666 +\r
667 +    def _write_footer(self, views, stream):\r
668 +        if self.footer:\r
669 +            stream.write(self.footer)\r
670 +\r
671 +    def _write_view(self, database, view, stream):\r
672 +        # sort order, default to oldest-first\r
673 +        sort_key = view.get('sort', 'oldest-first')\r
674 +        # dynamically accept all values in Query.SORT\r
675 +        sort_attribute = sort_key.upper().replace('-', '_')\r
676 +        try:\r
677 +            sort = getattr(notmuch.Query.SORT, sort_attribute)\r
678 +        except AttributeError:\r
679 +            raise ConfigError('Invalid sort setting for {}: {!r}'.format(\r
680 +                view['title'], sort_key))\r
681 +        if 'query-string' not in view:\r
682 +            query = view['query']\r
683 +            view['query-string'] = ' and '.join(query)\r
684 +        q = notmuch.Query(database, view['query-string'])\r
685 +        q.set_sort(sort)\r
686 +        threads = self._get_threads(messages=q.search_messages())\r
687 +        self._write_view_header(view=view, stream=stream)\r
688 +        self._write_threads(threads=threads, stream=stream)\r
689 +\r
690 +    def _get_threads(self, messages):\r
691 +        threads = collections.OrderedDict()\r
692 +        for message in messages:\r
693 +            thread_id = message.get_thread_id()\r
694 +            if thread_id in threads:\r
695 +                thread = threads[thread_id]\r
696 +            else:\r
697 +                thread = Thread()\r
698 +                threads[thread_id] = thread\r
699 +            thread.running_data, display_data = self._message_display_data(\r
700 +                running_data=thread.running_data, message=message)\r
701 +            thread.append(display_data)\r
702 +        return list(threads.values())\r
703 +\r
704 +    def _write_view_header(self, view, stream):\r
705 +        pass\r
706 +\r
707 +    def _write_threads(self, threads, stream):\r
708 +        for thread in threads:\r
709 +            for message_display_data in thread:\r
710 +                stream.write(\r
711 +                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'\r
712 +                     '{message-id-term:>72}\n'\r
713 +                     ).format(**message_display_data))\r
714 +            if thread != threads[-1]:\r
715 +                stream.write('\n')\r
716 +\r
717 +    def _message_display_data(self, running_data, message):\r
718 +        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')\r
719 +        data = {}\r
720 +        for header in headers:\r
721 +            if header == 'thread-id':\r
722 +                value = message.get_thread_id()\r
723 +            elif header == 'message-id':\r
724 +                value = message.get_message_id()\r
725 +                data['message-id-term'] = 'id:"{0}"'.format(value)\r
726 +            elif header == 'date':\r
727 +                value = str(datetime.datetime.utcfromtimestamp(\r
728 +                    message.get_date()).date())\r
729 +            else:\r
730 +                value = message.get_header(header)\r
731 +            if header == 'from':\r
732 +                (value, addr) = email.utils.parseaddr(value)\r
733 +                if not value:\r
734 +                    value = addr.split('@')[0]\r
735 +            data[header] = value\r
736 +        next_running_data = data.copy()\r
737 +        for header, value in data.items():\r
738 +            if header in ['message-id', 'subject']:\r
739 +                continue\r
740 +            if value == running_data.get(header, None):\r
741 +                data[header] = ''\r
742 +        return (next_running_data, data)\r
743 +\r
744 +\r
745 +class HtmlPage (Page):\r
746 +    _slug_regexp = re.compile('\W+')\r
747 +\r
748 +    def _write_header(self, views, stream):\r
749 +        super(HtmlPage, self)._write_header(views=views, stream=stream)\r
750 +        stream.write('<ul>\n')\r
751 +        for view in views:\r
752 +            if 'id' not in view:\r
753 +                view['id'] = self._slug(view['title'])\r
754 +            stream.write(\r
755 +                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))\r
756 +        stream.write('</ul>\n')\r
757 +\r
758 +    def _write_view_header(self, view, stream):\r
759 +        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))\r
760 +        stream.write('<p>\n')\r
761 +        if 'comment' in view:\r
762 +            stream.write(view['comment'])\r
763 +            stream.write('\n')\r
764 +        for line in [\r
765 +                'The view is generated from the following query:',\r
766 +                '</p>',\r
767 +                '<p>',\r
768 +                '  <code>',\r
769 +                view['query-string'],\r
770 +                '  </code>',\r
771 +                '</p>',\r
772 +                ]:\r
773 +            stream.write(line)\r
774 +            stream.write('\n')\r
775 +\r
776 +    def _write_threads(self, threads, stream):\r
777 +        if not threads:\r
778 +            return\r
779 +        stream.write('<table>\n')\r
780 +        for thread in threads:\r
781 +            stream.write('  <tbody>\n')\r
782 +            for message_display_data in thread:\r
783 +                stream.write((\r
784 +                    '    <tr class="message-first">\n'\r
785 +                    '      <td>{date}</td>\n'\r
786 +                    '      <td><code>{message-id-term}</code></td>\n'\r
787 +                    '    </tr>\n'\r
788 +                    '    <tr class="message-last">\n'\r
789 +                    '      <td>{from}</td>\n'\r
790 +                    '      <td>{subject}</td>\n'\r
791 +                    '    </tr>\n'\r
792 +                    ).format(**message_display_data))\r
793 +            stream.write('  </tbody>\n')\r
794 +            if thread != threads[-1]:\r
795 +                stream.write(\r
796 +                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')\r
797 +        stream.write('</table>\n')\r
798 +\r
799 +    def _message_display_data(self, *args, **kwargs):\r
800 +        running_data, display_data = super(\r
801 +            HtmlPage, self)._message_display_data(\r
802 +                *args, **kwargs)\r
803 +        if 'subject' in display_data and 'message-id' in display_data:\r
804 +            d = {\r
805 +                'message-id': quote(display_data['message-id']),\r
806 +                'subject': xml.sax.saxutils.escape(display_data['subject']),\r
807 +                }\r
808 +            display_data['subject'] = (\r
809 +                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'\r
810 +                ).format(**d)\r
811 +        for key in ['message-id', 'from']:\r
812 +            if key in display_data:\r
813 +                display_data[key] = xml.sax.saxutils.escape(display_data[key])\r
814 +        return (running_data, display_data)\r
815 +\r
816 +    def _slug(self, string):\r
817 +        return self._slug_regexp.sub('-', string)\r
818 +\r
819 +parser = argparse.ArgumentParser(description=__doc__)\r
820 +parser.add_argument('--text', help='output plain text format',\r
821 +                    action='store_true')\r
822 +parser.add_argument('--config', help='load config from given file',\r
823 +                    metavar='PATH')\r
824 +parser.add_argument('--list-views', help='list views',\r
825 +                    action='store_true')\r
826 +parser.add_argument('--get-query', help='get query for view',\r
827 +                    metavar='VIEW')\r
828 +\r
829 +args = parser.parse_args()\r
830 +\r
831 +try:\r
832 +    config = read_config(path=args.config)\r
833 +except ConfigError as e:\r
834 +    print(e, file=sys.stderr)\r
835 +    sys.exit(1)\r
836 +\r
837 +header_template = config['meta'].get('header', '''<!DOCTYPE html>\r
838 +<html lang="en">\r
839 +<head>\r
840 +  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />\r
841 +  <title>{title}</title>\r
842 +  <style media="screen" type="text/css">\r
843 +    table {{\r
844 +      border-spacing: 0;\r
845 +    }}\r
846 +    tr.message-first td {{\r
847 +      padding-top: {inter_message_padding};\r
848 +    }}\r
849 +    tr.message-last td {{\r
850 +      padding-bottom: {inter_message_padding};\r
851 +    }}\r
852 +    td {{\r
853 +      padding-left: {border_radius};\r
854 +      padding-right: {border_radius};\r
855 +    }}\r
856 +    tr:first-child td:first-child {{\r
857 +      border-top-left-radius: {border_radius};\r
858 +    }}\r
859 +    tr:first-child td:last-child {{\r
860 +      border-top-right-radius: {border_radius};\r
861 +    }}\r
862 +    tr:last-child td:first-child {{\r
863 +      border-bottom-left-radius: {border_radius};\r
864 +    }}\r
865 +    tr:last-child td:last-child {{\r
866 +      border-bottom-right-radius: {border_radius};\r
867 +    }}\r
868 +    tbody:nth-child(4n+1) tr td {{\r
869 +      background-color: #ffd96e;\r
870 +    }}\r
871 +    tbody:nth-child(4n+3) tr td {{\r
872 +      background-color: #bce;\r
873 +    }}\r
874 +    hr {{\r
875 +      border: 0;\r
876 +      height: 1px;\r
877 +      color: #ccc;\r
878 +      background-color: #ccc;\r
879 +    }}\r
880 +  </style>\r
881 +</head>\r
882 +<body>\r
883 +<h2>{title}</h2>\r
884 +{blurb}\r
885 +</p>\r
886 +<h3>Views</h3>\r
887 +''')\r
888 +\r
889 +footer_template = config['meta'].get('footer', '''\r
890 +<hr>\r
891 +<p>Generated: {datetime}\r
892 +</body>\r
893 +</html>\r
894 +''')\r
895 +\r
896 +now = datetime.datetime.utcnow()\r
897 +context = {\r
898 +    'date': now,\r
899 +    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),\r
900 +    'title': config['meta']['title'],\r
901 +    'blurb': config['meta']['blurb'],\r
902 +    'encoding': _ENCODING,\r
903 +    'inter_message_padding': '0.25em',\r
904 +    'border_radius': '0.5em',\r
905 +    }\r
906 +\r
907 +_PAGES['text'] = Page()\r
908 +_PAGES['html'] = HtmlPage(\r
909 +    header=header_template.format(**context),\r
910 +    footer=footer_template.format(**context),\r
911 +    )\r
912 +\r
913 +if args.list_views:\r
914 +    for view in config['views']:\r
915 +        print(view['title'])\r
916 +    sys.exit(0)\r
917 +elif args.get_query != None:\r
918 +    for view in config['views']:\r
919 +        if args.get_query == view['title']:\r
920 +            print(' and '.join(view['query']))\r
921 +    sys.exit(0)\r
922 +else:\r
923 +    # only import notmuch if needed\r
924 +    import notmuch\r
925 +\r
926 +if args.text:\r
927 +    page = _PAGES['text']\r
928 +else:\r
929 +    page = _PAGES['html']\r
930 +\r
931 +db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)\r
932 +page.write(database=db, views=config['views'])\r
933 -- \r
934 2.1.0.60.g85f0837\r
935 \r