unofficial mirror of notmuch@notmuchmail.org
 help / color / mirror / code / Atom feed
* [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages
@ 2016-01-02  6:08 W. Trevor King
  2016-01-02  6:08 ` [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message W. Trevor King
                   ` (4 more replies)
  0 siblings, 5 replies; 9+ messages in thread
From: W. Trevor King @ 2016-01-02  6:08 UTC (permalink / raw)
  To: notmuch; +Cc: David Bremner, Tomi Ollila, Jani Nikula, Carl Worth,
	W. Trevor King

Spun off from discussion here [1].  I've just shifted nmbug-status in
this series, and left nmbug alone for now.  If/when this series lands,
it will be easy to handle a nmbug rename in a subsequent series.

Note that while I think the renames from nmbug-status to
notmuch-report and status-config.json to notmuch-report.json are
useful changes, they will break backwards compatibility for existing
users.  I'm not sure how many existing users there are, and I expect
the NEWS entry will clue them in.  But if folks want a temporary
fallback to status-config.json in the absence of notmuch-report.json,
I can add that in a v2.

Cheers,
Trevor

[1]: id:m2twmxbl1i.fsf@guru.guru-group.fi
     http://thread.gmane.org/gmane.mail.notmuch.general/21535/focus=21539

W. Trevor King (5):
  nmbug-status: Avoid hard-coded filename in error message
  notmuch-report: Rename from nmbug-status
  notmuch-report.json: Rename from status-config.json
  notmuch-report: Add notmuch-report(1) and notmuch-report.json(5) man
    pages
  NEWS: Document the notmuch-report branch

 NEWS                                           |  26 ++
 devel/nmbug/doc/.gitignore                     |   2 +
 devel/nmbug/doc/Makefile                       |  38 +++
 devel/nmbug/doc/conf.py                        |  67 ++++
 devel/nmbug/doc/index.rst                      |  17 +
 devel/nmbug/doc/man1/notmuch-report.1.rst      |  54 ++++
 devel/nmbug/doc/man5/notmuch-report.json.5.rst | 129 ++++++++
 devel/nmbug/nmbug-status                       | 419 ------------------------
 devel/nmbug/notmuch-report                     | 422 +++++++++++++++++++++++++
 devel/nmbug/notmuch-report.json                |  70 ++++
 devel/nmbug/status-config.json                 |  70 ----
 11 files changed, 825 insertions(+), 489 deletions(-)
 create mode 100644 devel/nmbug/doc/.gitignore
 create mode 100644 devel/nmbug/doc/Makefile
 create mode 100644 devel/nmbug/doc/conf.py
 create mode 100644 devel/nmbug/doc/index.rst
 create mode 100644 devel/nmbug/doc/man1/notmuch-report.1.rst
 create mode 100644 devel/nmbug/doc/man5/notmuch-report.json.5.rst
 delete mode 100755 devel/nmbug/nmbug-status
 create mode 100755 devel/nmbug/notmuch-report
 create mode 100644 devel/nmbug/notmuch-report.json
 delete mode 100644 devel/nmbug/status-config.json

-- 
2.1.0.60.g85f0837

^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message
  2016-01-02  6:08 [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages W. Trevor King
@ 2016-01-02  6:08 ` W. Trevor King
  2016-01-07 13:27   ` David Bremner
  2016-01-02  6:08 ` [PATCH 2/5] notmuch-report: Rename from nmbug-status W. Trevor King
                   ` (3 subsequent siblings)
  4 siblings, 1 reply; 9+ messages in thread
From: W. Trevor King @ 2016-01-02  6:08 UTC (permalink / raw)
  To: notmuch; +Cc: David Bremner, Tomi Ollila, Jani Nikula, Carl Worth,
	W. Trevor King

We already have a 'filename' variable with the name, so stay DRY and
use that variable here.

Also fix a missing-whitespace error from bed8b674 (nmbug-status:
Clarify errors for illegible configs, 2014-05-10), wrapping on the
sentence to match similar error-generation earlier in this function.
---
 devel/nmbug/nmbug-status | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status
index b36b6ad..22e3b5b 100755
--- a/devel/nmbug/nmbug-status
+++ b/devel/nmbug/nmbug-status
@@ -109,9 +109,9 @@ def read_config(path=None, encoding=None):
         status = p.wait()
         if status != 0:
             raise ConfigError(
-                ("Missing status-config.json in branch '{branch}' of"
-                 '{nmbgit}.  Add the file or explicitly set --config.'
-                ).format(branch=branch, nmbgit=nmbhome))
+                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "
+                 'Add the file or explicitly set --config.'
+                ).format(filename=filename, branch=branch, nmbgit=nmbhome))
 
     config_json = config_bytes.decode(encoding)
     try:
-- 
2.1.0.60.g85f0837

^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH 2/5] notmuch-report: Rename from nmbug-status
  2016-01-02  6:08 [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages W. Trevor King
  2016-01-02  6:08 ` [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message W. Trevor King
@ 2016-01-02  6:08 ` W. Trevor King
  2016-01-03 16:19   ` Jani Nikula
  2016-01-02  6:08 ` [PATCH 3/5] notmuch-report.json: Rename from status-config.json W. Trevor King
                   ` (2 subsequent siblings)
  4 siblings, 1 reply; 9+ messages in thread
From: W. Trevor King @ 2016-01-02  6:08 UTC (permalink / raw)
  To: notmuch; +Cc: David Bremner, Tomi Ollila, Jani Nikula, Carl Worth,
	W. Trevor King

This script generates reports based on notmuch queries, and doesn't
really have anything to do with nmbug, except for sharing the NMBGIT
environment variable.
---
 devel/nmbug/nmbug-status   | 419 ---------------------------------------------
 devel/nmbug/notmuch-report | 419 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 419 insertions(+), 419 deletions(-)
 delete mode 100755 devel/nmbug/nmbug-status
 create mode 100755 devel/nmbug/notmuch-report

diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status
deleted file mode 100755
index 22e3b5b..0000000
--- a/devel/nmbug/nmbug-status
+++ /dev/null
@@ -1,419 +0,0 @@
-#!/usr/bin/python
-#
-# Copyright (c) 2011-2012 David Bremner <david@tethera.net>
-#
-# dependencies
-#       - python 2.6 for json
-#       - argparse; either python 2.7, or install separately
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see http://www.gnu.org/licenses/ .
-
-"""Generate HTML for one or more notmuch searches.
-
-Messages matching each search are grouped by thread.  Each message
-that contains both a subject and message-id will have the displayed
-subject link to the Gmane view of the message.
-"""
-
-from __future__ import print_function
-from __future__ import unicode_literals
-
-import codecs
-import collections
-import datetime
-import email.utils
-try:  # Python 3
-    from urllib.parse import quote
-except ImportError:  # Python 2
-    from urllib import quote
-import json
-import argparse
-import os
-import re
-import sys
-import subprocess
-import xml.sax.saxutils
-
-
-_ENCODING = 'UTF-8'
-_PAGES = {}
-
-
-if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier
-    class _OrderedDict (dict):
-        "Just enough of a stub to get through Page._get_threads"
-        def __init__(self, *args, **kwargs):
-            super(_OrderedDict, self).__init__(*args, **kwargs)
-            self._keys = []  # record key order
-
-        def __setitem__(self, key, value):
-            super(_OrderedDict, self).__setitem__(key, value)
-            self._keys.append(key)
-
-        def values(self):
-            for key in self._keys:
-                yield self[key]
-
-
-    collections.OrderedDict = _OrderedDict
-
-
-class ConfigError (Exception):
-    """Errors with config file usage
-    """
-    pass
-
-
-def read_config(path=None, encoding=None):
-    "Read config from json file"
-    if not encoding:
-        encoding = _ENCODING
-    if path:
-        try:
-            with open(path, 'rb') as f:
-                config_bytes = f.read()
-        except IOError as e:
-            raise ConfigError('Could not read config from {}'.format(path))
-    else:
-        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))
-        branch = 'config'
-        filename = 'status-config.json'
-
-        # read only the first line from the pipe
-        sha1_bytes = subprocess.Popen(
-            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],
-            stdout=subprocess.PIPE).stdout.readline()
-        sha1 = sha1_bytes.decode(encoding).rstrip()
-        if not sha1:
-            raise ConfigError(
-                ("No local branch '{branch}' in {nmbgit}.  "
-                 'Checkout a local {branch} branch or explicitly set --config.'
-                ).format(branch=branch, nmbgit=nmbhome))
-
-        p = subprocess.Popen(
-            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',
-             '{}:{}'.format(sha1, filename)],
-            stdout=subprocess.PIPE)
-        config_bytes, err = p.communicate()
-        status = p.wait()
-        if status != 0:
-            raise ConfigError(
-                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "
-                 'Add the file or explicitly set --config.'
-                ).format(filename=filename, branch=branch, nmbgit=nmbhome))
-
-    config_json = config_bytes.decode(encoding)
-    try:
-        return json.loads(config_json)
-    except ValueError as e:
-        if not path:
-            path = "{} in branch '{}' of {}".format(
-                filename, branch, nmbhome)
-        raise ConfigError(
-            'Could not parse JSON from the config file {}:\n{}'.format(
-                path, e))
-
-
-class Thread (list):
-    def __init__(self):
-        self.running_data = {}
-
-
-class Page (object):
-    def __init__(self, header=None, footer=None):
-        self.header = header
-        self.footer = footer
-
-    def write(self, database, views, stream=None):
-        if not stream:
-            try:  # Python 3
-                byte_stream = sys.stdout.buffer
-            except AttributeError:  # Python 2
-                byte_stream = sys.stdout
-            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)
-        self._write_header(views=views, stream=stream)
-        for view in views:
-            self._write_view(database=database, view=view, stream=stream)
-        self._write_footer(views=views, stream=stream)
-
-    def _write_header(self, views, stream):
-        if self.header:
-            stream.write(self.header)
-
-    def _write_footer(self, views, stream):
-        if self.footer:
-            stream.write(self.footer)
-
-    def _write_view(self, database, view, stream):
-        # sort order, default to oldest-first
-        sort_key = view.get('sort', 'oldest-first')
-        # dynamically accept all values in Query.SORT
-        sort_attribute = sort_key.upper().replace('-', '_')
-        try:
-            sort = getattr(notmuch.Query.SORT, sort_attribute)
-        except AttributeError:
-            raise ConfigError('Invalid sort setting for {}: {!r}'.format(
-                view['title'], sort_key))
-        if 'query-string' not in view:
-            query = view['query']
-            view['query-string'] = ' and '.join(query)
-        q = notmuch.Query(database, view['query-string'])
-        q.set_sort(sort)
-        threads = self._get_threads(messages=q.search_messages())
-        self._write_view_header(view=view, stream=stream)
-        self._write_threads(threads=threads, stream=stream)
-
-    def _get_threads(self, messages):
-        threads = collections.OrderedDict()
-        for message in messages:
-            thread_id = message.get_thread_id()
-            if thread_id in threads:
-                thread = threads[thread_id]
-            else:
-                thread = Thread()
-                threads[thread_id] = thread
-            thread.running_data, display_data = self._message_display_data(
-                running_data=thread.running_data, message=message)
-            thread.append(display_data)
-        return list(threads.values())
-
-    def _write_view_header(self, view, stream):
-        pass
-
-    def _write_threads(self, threads, stream):
-        for thread in threads:
-            for message_display_data in thread:
-                stream.write(
-                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'
-                     '{message-id-term:>72}\n'
-                     ).format(**message_display_data))
-            if thread != threads[-1]:
-                stream.write('\n')
-
-    def _message_display_data(self, running_data, message):
-        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')
-        data = {}
-        for header in headers:
-            if header == 'thread-id':
-                value = message.get_thread_id()
-            elif header == 'message-id':
-                value = message.get_message_id()
-                data['message-id-term'] = 'id:"{0}"'.format(value)
-            elif header == 'date':
-                value = str(datetime.datetime.utcfromtimestamp(
-                    message.get_date()).date())
-            else:
-                value = message.get_header(header)
-            if header == 'from':
-                (value, addr) = email.utils.parseaddr(value)
-                if not value:
-                    value = addr.split('@')[0]
-            data[header] = value
-        next_running_data = data.copy()
-        for header, value in data.items():
-            if header in ['message-id', 'subject']:
-                continue
-            if value == running_data.get(header, None):
-                data[header] = ''
-        return (next_running_data, data)
-
-
-class HtmlPage (Page):
-    _slug_regexp = re.compile('\W+')
-
-    def _write_header(self, views, stream):
-        super(HtmlPage, self)._write_header(views=views, stream=stream)
-        stream.write('<ul>\n')
-        for view in views:
-            if 'id' not in view:
-                view['id'] = self._slug(view['title'])
-            stream.write(
-                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))
-        stream.write('</ul>\n')
-
-    def _write_view_header(self, view, stream):
-        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))
-        stream.write('<p>\n')
-        if 'comment' in view:
-            stream.write(view['comment'])
-            stream.write('\n')
-        for line in [
-                'The view is generated from the following query:',
-                '</p>',
-                '<p>',
-                '  <code>',
-                view['query-string'],
-                '  </code>',
-                '</p>',
-                ]:
-            stream.write(line)
-            stream.write('\n')
-
-    def _write_threads(self, threads, stream):
-        if not threads:
-            return
-        stream.write('<table>\n')
-        for thread in threads:
-            stream.write('  <tbody>\n')
-            for message_display_data in thread:
-                stream.write((
-                    '    <tr class="message-first">\n'
-                    '      <td>{date}</td>\n'
-                    '      <td><code>{message-id-term}</code></td>\n'
-                    '    </tr>\n'
-                    '    <tr class="message-last">\n'
-                    '      <td>{from}</td>\n'
-                    '      <td>{subject}</td>\n'
-                    '    </tr>\n'
-                    ).format(**message_display_data))
-            stream.write('  </tbody>\n')
-            if thread != threads[-1]:
-                stream.write(
-                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')
-        stream.write('</table>\n')
-
-    def _message_display_data(self, *args, **kwargs):
-        running_data, display_data = super(
-            HtmlPage, self)._message_display_data(
-                *args, **kwargs)
-        if 'subject' in display_data and 'message-id' in display_data:
-            d = {
-                'message-id': quote(display_data['message-id']),
-                'subject': xml.sax.saxutils.escape(display_data['subject']),
-                }
-            display_data['subject'] = (
-                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'
-                ).format(**d)
-        for key in ['message-id', 'from']:
-            if key in display_data:
-                display_data[key] = xml.sax.saxutils.escape(display_data[key])
-        return (running_data, display_data)
-
-    def _slug(self, string):
-        return self._slug_regexp.sub('-', string)
-
-parser = argparse.ArgumentParser(description=__doc__)
-parser.add_argument('--text', help='output plain text format',
-                    action='store_true')
-parser.add_argument('--config', help='load config from given file',
-                    metavar='PATH')
-parser.add_argument('--list-views', help='list views',
-                    action='store_true')
-parser.add_argument('--get-query', help='get query for view',
-                    metavar='VIEW')
-
-args = parser.parse_args()
-
-try:
-    config = read_config(path=args.config)
-except ConfigError as e:
-    print(e, file=sys.stderr)
-    sys.exit(1)
-
-header_template = config['meta'].get('header', '''<!DOCTYPE html>
-<html lang="en">
-<head>
-  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />
-  <title>{title}</title>
-  <style media="screen" type="text/css">
-    table {{
-      border-spacing: 0;
-    }}
-    tr.message-first td {{
-      padding-top: {inter_message_padding};
-    }}
-    tr.message-last td {{
-      padding-bottom: {inter_message_padding};
-    }}
-    td {{
-      padding-left: {border_radius};
-      padding-right: {border_radius};
-    }}
-    tr:first-child td:first-child {{
-      border-top-left-radius: {border_radius};
-    }}
-    tr:first-child td:last-child {{
-      border-top-right-radius: {border_radius};
-    }}
-    tr:last-child td:first-child {{
-      border-bottom-left-radius: {border_radius};
-    }}
-    tr:last-child td:last-child {{
-      border-bottom-right-radius: {border_radius};
-    }}
-    tbody:nth-child(4n+1) tr td {{
-      background-color: #ffd96e;
-    }}
-    tbody:nth-child(4n+3) tr td {{
-      background-color: #bce;
-    }}
-    hr {{
-      border: 0;
-      height: 1px;
-      color: #ccc;
-      background-color: #ccc;
-    }}
-  </style>
-</head>
-<body>
-<h2>{title}</h2>
-{blurb}
-</p>
-<h3>Views</h3>
-''')
-
-footer_template = config['meta'].get('footer', '''
-<hr>
-<p>Generated: {datetime}
-</body>
-</html>
-''')
-
-now = datetime.datetime.utcnow()
-context = {
-    'date': now,
-    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),
-    'title': config['meta']['title'],
-    'blurb': config['meta']['blurb'],
-    'encoding': _ENCODING,
-    'inter_message_padding': '0.25em',
-    'border_radius': '0.5em',
-    }
-
-_PAGES['text'] = Page()
-_PAGES['html'] = HtmlPage(
-    header=header_template.format(**context),
-    footer=footer_template.format(**context),
-    )
-
-if args.list_views:
-    for view in config['views']:
-        print(view['title'])
-    sys.exit(0)
-elif args.get_query != None:
-    for view in config['views']:
-        if args.get_query == view['title']:
-            print(' and '.join(view['query']))
-    sys.exit(0)
-else:
-    # only import notmuch if needed
-    import notmuch
-
-if args.text:
-    page = _PAGES['text']
-else:
-    page = _PAGES['html']
-
-db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)
-page.write(database=db, views=config['views'])
diff --git a/devel/nmbug/notmuch-report b/devel/nmbug/notmuch-report
new file mode 100755
index 0000000..22e3b5b
--- /dev/null
+++ b/devel/nmbug/notmuch-report
@@ -0,0 +1,419 @@
+#!/usr/bin/python
+#
+# Copyright (c) 2011-2012 David Bremner <david@tethera.net>
+#
+# dependencies
+#       - python 2.6 for json
+#       - argparse; either python 2.7, or install separately
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see http://www.gnu.org/licenses/ .
+
+"""Generate HTML for one or more notmuch searches.
+
+Messages matching each search are grouped by thread.  Each message
+that contains both a subject and message-id will have the displayed
+subject link to the Gmane view of the message.
+"""
+
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import codecs
+import collections
+import datetime
+import email.utils
+try:  # Python 3
+    from urllib.parse import quote
+except ImportError:  # Python 2
+    from urllib import quote
+import json
+import argparse
+import os
+import re
+import sys
+import subprocess
+import xml.sax.saxutils
+
+
+_ENCODING = 'UTF-8'
+_PAGES = {}
+
+
+if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier
+    class _OrderedDict (dict):
+        "Just enough of a stub to get through Page._get_threads"
+        def __init__(self, *args, **kwargs):
+            super(_OrderedDict, self).__init__(*args, **kwargs)
+            self._keys = []  # record key order
+
+        def __setitem__(self, key, value):
+            super(_OrderedDict, self).__setitem__(key, value)
+            self._keys.append(key)
+
+        def values(self):
+            for key in self._keys:
+                yield self[key]
+
+
+    collections.OrderedDict = _OrderedDict
+
+
+class ConfigError (Exception):
+    """Errors with config file usage
+    """
+    pass
+
+
+def read_config(path=None, encoding=None):
+    "Read config from json file"
+    if not encoding:
+        encoding = _ENCODING
+    if path:
+        try:
+            with open(path, 'rb') as f:
+                config_bytes = f.read()
+        except IOError as e:
+            raise ConfigError('Could not read config from {}'.format(path))
+    else:
+        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))
+        branch = 'config'
+        filename = 'status-config.json'
+
+        # read only the first line from the pipe
+        sha1_bytes = subprocess.Popen(
+            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],
+            stdout=subprocess.PIPE).stdout.readline()
+        sha1 = sha1_bytes.decode(encoding).rstrip()
+        if not sha1:
+            raise ConfigError(
+                ("No local branch '{branch}' in {nmbgit}.  "
+                 'Checkout a local {branch} branch or explicitly set --config.'
+                ).format(branch=branch, nmbgit=nmbhome))
+
+        p = subprocess.Popen(
+            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',
+             '{}:{}'.format(sha1, filename)],
+            stdout=subprocess.PIPE)
+        config_bytes, err = p.communicate()
+        status = p.wait()
+        if status != 0:
+            raise ConfigError(
+                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "
+                 'Add the file or explicitly set --config.'
+                ).format(filename=filename, branch=branch, nmbgit=nmbhome))
+
+    config_json = config_bytes.decode(encoding)
+    try:
+        return json.loads(config_json)
+    except ValueError as e:
+        if not path:
+            path = "{} in branch '{}' of {}".format(
+                filename, branch, nmbhome)
+        raise ConfigError(
+            'Could not parse JSON from the config file {}:\n{}'.format(
+                path, e))
+
+
+class Thread (list):
+    def __init__(self):
+        self.running_data = {}
+
+
+class Page (object):
+    def __init__(self, header=None, footer=None):
+        self.header = header
+        self.footer = footer
+
+    def write(self, database, views, stream=None):
+        if not stream:
+            try:  # Python 3
+                byte_stream = sys.stdout.buffer
+            except AttributeError:  # Python 2
+                byte_stream = sys.stdout
+            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)
+        self._write_header(views=views, stream=stream)
+        for view in views:
+            self._write_view(database=database, view=view, stream=stream)
+        self._write_footer(views=views, stream=stream)
+
+    def _write_header(self, views, stream):
+        if self.header:
+            stream.write(self.header)
+
+    def _write_footer(self, views, stream):
+        if self.footer:
+            stream.write(self.footer)
+
+    def _write_view(self, database, view, stream):
+        # sort order, default to oldest-first
+        sort_key = view.get('sort', 'oldest-first')
+        # dynamically accept all values in Query.SORT
+        sort_attribute = sort_key.upper().replace('-', '_')
+        try:
+            sort = getattr(notmuch.Query.SORT, sort_attribute)
+        except AttributeError:
+            raise ConfigError('Invalid sort setting for {}: {!r}'.format(
+                view['title'], sort_key))
+        if 'query-string' not in view:
+            query = view['query']
+            view['query-string'] = ' and '.join(query)
+        q = notmuch.Query(database, view['query-string'])
+        q.set_sort(sort)
+        threads = self._get_threads(messages=q.search_messages())
+        self._write_view_header(view=view, stream=stream)
+        self._write_threads(threads=threads, stream=stream)
+
+    def _get_threads(self, messages):
+        threads = collections.OrderedDict()
+        for message in messages:
+            thread_id = message.get_thread_id()
+            if thread_id in threads:
+                thread = threads[thread_id]
+            else:
+                thread = Thread()
+                threads[thread_id] = thread
+            thread.running_data, display_data = self._message_display_data(
+                running_data=thread.running_data, message=message)
+            thread.append(display_data)
+        return list(threads.values())
+
+    def _write_view_header(self, view, stream):
+        pass
+
+    def _write_threads(self, threads, stream):
+        for thread in threads:
+            for message_display_data in thread:
+                stream.write(
+                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'
+                     '{message-id-term:>72}\n'
+                     ).format(**message_display_data))
+            if thread != threads[-1]:
+                stream.write('\n')
+
+    def _message_display_data(self, running_data, message):
+        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')
+        data = {}
+        for header in headers:
+            if header == 'thread-id':
+                value = message.get_thread_id()
+            elif header == 'message-id':
+                value = message.get_message_id()
+                data['message-id-term'] = 'id:"{0}"'.format(value)
+            elif header == 'date':
+                value = str(datetime.datetime.utcfromtimestamp(
+                    message.get_date()).date())
+            else:
+                value = message.get_header(header)
+            if header == 'from':
+                (value, addr) = email.utils.parseaddr(value)
+                if not value:
+                    value = addr.split('@')[0]
+            data[header] = value
+        next_running_data = data.copy()
+        for header, value in data.items():
+            if header in ['message-id', 'subject']:
+                continue
+            if value == running_data.get(header, None):
+                data[header] = ''
+        return (next_running_data, data)
+
+
+class HtmlPage (Page):
+    _slug_regexp = re.compile('\W+')
+
+    def _write_header(self, views, stream):
+        super(HtmlPage, self)._write_header(views=views, stream=stream)
+        stream.write('<ul>\n')
+        for view in views:
+            if 'id' not in view:
+                view['id'] = self._slug(view['title'])
+            stream.write(
+                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))
+        stream.write('</ul>\n')
+
+    def _write_view_header(self, view, stream):
+        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))
+        stream.write('<p>\n')
+        if 'comment' in view:
+            stream.write(view['comment'])
+            stream.write('\n')
+        for line in [
+                'The view is generated from the following query:',
+                '</p>',
+                '<p>',
+                '  <code>',
+                view['query-string'],
+                '  </code>',
+                '</p>',
+                ]:
+            stream.write(line)
+            stream.write('\n')
+
+    def _write_threads(self, threads, stream):
+        if not threads:
+            return
+        stream.write('<table>\n')
+        for thread in threads:
+            stream.write('  <tbody>\n')
+            for message_display_data in thread:
+                stream.write((
+                    '    <tr class="message-first">\n'
+                    '      <td>{date}</td>\n'
+                    '      <td><code>{message-id-term}</code></td>\n'
+                    '    </tr>\n'
+                    '    <tr class="message-last">\n'
+                    '      <td>{from}</td>\n'
+                    '      <td>{subject}</td>\n'
+                    '    </tr>\n'
+                    ).format(**message_display_data))
+            stream.write('  </tbody>\n')
+            if thread != threads[-1]:
+                stream.write(
+                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')
+        stream.write('</table>\n')
+
+    def _message_display_data(self, *args, **kwargs):
+        running_data, display_data = super(
+            HtmlPage, self)._message_display_data(
+                *args, **kwargs)
+        if 'subject' in display_data and 'message-id' in display_data:
+            d = {
+                'message-id': quote(display_data['message-id']),
+                'subject': xml.sax.saxutils.escape(display_data['subject']),
+                }
+            display_data['subject'] = (
+                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'
+                ).format(**d)
+        for key in ['message-id', 'from']:
+            if key in display_data:
+                display_data[key] = xml.sax.saxutils.escape(display_data[key])
+        return (running_data, display_data)
+
+    def _slug(self, string):
+        return self._slug_regexp.sub('-', string)
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--text', help='output plain text format',
+                    action='store_true')
+parser.add_argument('--config', help='load config from given file',
+                    metavar='PATH')
+parser.add_argument('--list-views', help='list views',
+                    action='store_true')
+parser.add_argument('--get-query', help='get query for view',
+                    metavar='VIEW')
+
+args = parser.parse_args()
+
+try:
+    config = read_config(path=args.config)
+except ConfigError as e:
+    print(e, file=sys.stderr)
+    sys.exit(1)
+
+header_template = config['meta'].get('header', '''<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />
+  <title>{title}</title>
+  <style media="screen" type="text/css">
+    table {{
+      border-spacing: 0;
+    }}
+    tr.message-first td {{
+      padding-top: {inter_message_padding};
+    }}
+    tr.message-last td {{
+      padding-bottom: {inter_message_padding};
+    }}
+    td {{
+      padding-left: {border_radius};
+      padding-right: {border_radius};
+    }}
+    tr:first-child td:first-child {{
+      border-top-left-radius: {border_radius};
+    }}
+    tr:first-child td:last-child {{
+      border-top-right-radius: {border_radius};
+    }}
+    tr:last-child td:first-child {{
+      border-bottom-left-radius: {border_radius};
+    }}
+    tr:last-child td:last-child {{
+      border-bottom-right-radius: {border_radius};
+    }}
+    tbody:nth-child(4n+1) tr td {{
+      background-color: #ffd96e;
+    }}
+    tbody:nth-child(4n+3) tr td {{
+      background-color: #bce;
+    }}
+    hr {{
+      border: 0;
+      height: 1px;
+      color: #ccc;
+      background-color: #ccc;
+    }}
+  </style>
+</head>
+<body>
+<h2>{title}</h2>
+{blurb}
+</p>
+<h3>Views</h3>
+''')
+
+footer_template = config['meta'].get('footer', '''
+<hr>
+<p>Generated: {datetime}
+</body>
+</html>
+''')
+
+now = datetime.datetime.utcnow()
+context = {
+    'date': now,
+    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),
+    'title': config['meta']['title'],
+    'blurb': config['meta']['blurb'],
+    'encoding': _ENCODING,
+    'inter_message_padding': '0.25em',
+    'border_radius': '0.5em',
+    }
+
+_PAGES['text'] = Page()
+_PAGES['html'] = HtmlPage(
+    header=header_template.format(**context),
+    footer=footer_template.format(**context),
+    )
+
+if args.list_views:
+    for view in config['views']:
+        print(view['title'])
+    sys.exit(0)
+elif args.get_query != None:
+    for view in config['views']:
+        if args.get_query == view['title']:
+            print(' and '.join(view['query']))
+    sys.exit(0)
+else:
+    # only import notmuch if needed
+    import notmuch
+
+if args.text:
+    page = _PAGES['text']
+else:
+    page = _PAGES['html']
+
+db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)
+page.write(database=db, views=config['views'])
-- 
2.1.0.60.g85f0837

^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH 3/5] notmuch-report.json: Rename from status-config.json
  2016-01-02  6:08 [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages W. Trevor King
  2016-01-02  6:08 ` [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message W. Trevor King
  2016-01-02  6:08 ` [PATCH 2/5] notmuch-report: Rename from nmbug-status W. Trevor King
@ 2016-01-02  6:08 ` W. Trevor King
  2016-01-02  6:08 ` [PATCH 4/5] notmuch-report: Add notmuch-report(1) and notmuch-report.json(5) man pages W. Trevor King
  2016-01-02  6:08 ` [PATCH 5/5] NEWS: Document the notmuch-report branch W. Trevor King
  4 siblings, 0 replies; 9+ messages in thread
From: W. Trevor King @ 2016-01-02  6:08 UTC (permalink / raw)
  To: notmuch; +Cc: David Bremner, Tomi Ollila, Jani Nikula, Carl Worth,
	W. Trevor King

status-config.json wasn't obviously associated with the old
nmubg-status, now notmuch-report.  The new name is
${CONFIGURED_SCRIPT}.json, so the association should be clear.
---
 devel/nmbug/notmuch-report      |  2 +-
 devel/nmbug/notmuch-report.json | 70 +++++++++++++++++++++++++++++++++++++++++
 devel/nmbug/status-config.json  | 70 -----------------------------------------
 3 files changed, 71 insertions(+), 71 deletions(-)
 create mode 100644 devel/nmbug/notmuch-report.json
 delete mode 100644 devel/nmbug/status-config.json

diff --git a/devel/nmbug/notmuch-report b/devel/nmbug/notmuch-report
index 22e3b5b..5425e06 100755
--- a/devel/nmbug/notmuch-report
+++ b/devel/nmbug/notmuch-report
@@ -88,7 +88,7 @@ def read_config(path=None, encoding=None):
     else:
         nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))
         branch = 'config'
-        filename = 'status-config.json'
+        filename = 'notmuch-report.json'
 
         # read only the first line from the pipe
         sha1_bytes = subprocess.Popen(
diff --git a/devel/nmbug/notmuch-report.json b/devel/nmbug/notmuch-report.json
new file mode 100644
index 0000000..b926946
--- /dev/null
+++ b/devel/nmbug/notmuch-report.json
@@ -0,0 +1,70 @@
+{
+    "meta": {
+        "title": "Notmuch Patches",
+        "blurb": "For more information see <a href=\"http://notmuchmail.org/nmbug\">nmbug</a>"
+    },
+
+    "views": [
+	{
+	    "comment": "Unresolved bugs (or just need tag updating).",
+	    "query": [
+		"tag:notmuch::bug",
+		"not tag:notmuch::fixed",
+		"not tag:notmuch::wontfix"
+	    ],
+	    "title": "Bugs"
+	},
+	{
+	    "comment": "These patches are under consideration for pushing.",
+	    "query": [
+		"tag:notmuch::patch and not tag:notmuch::pushed",
+		"not tag:notmuch::obsolete and not tag:notmuch::wip",
+		"not tag:notmuch::stale and not tag:notmuch::contrib",
+		"not tag:notmuch::moreinfo",
+		"not tag:notmuch::python",
+		"not tag:notmuch::vim",
+		"not tag:notmuch::wontfix",
+		"not tag:notmuch::needs-review"
+	    ],
+	    "title": "Maybe Ready (Core and Emacs)"
+	},
+	{
+	    "comment": "These python related patches might be ready to push, or they might just need updated tags.",
+	    "query": [
+		"tag:notmuch::patch and not tag:notmuch::pushed",
+		"not tag:notmuch::obsolete and not tag:notmuch::wip",
+		"not tag:notmuch::stale and not tag:notmuch::contrib",
+		"not tag:notmuch::moreinfo",
+		"not tag:notmuch::wontfix",
+		" tag:notmuch::python",
+		"not tag:notmuch::needs-review"
+	    ],
+	    "title": "Maybe Ready (Python)"
+	},
+	{
+	    "comment": "These vim related patches might be ready to push, or they might just need updated tags.",
+	    "query": [
+		"tag:notmuch::patch and not tag:notmuch::pushed",
+		"not tag:notmuch::obsolete and not tag:notmuch::wip",
+		"not tag:notmuch::stale and not tag:notmuch::contrib",
+		"not tag:notmuch::moreinfo",
+		"not tag:notmuch::wontfix",
+		"tag:notmuch::vim",
+		"not tag:notmuch::needs-review"
+	    ],
+	    "title": "Maybe Ready (vim)"
+	},
+	{
+	    "comment": "These patches are under review, or waiting for feedback.",
+	    "query": [
+		"tag:notmuch::patch",
+		"not tag:notmuch::pushed",
+		"not tag:notmuch::obsolete",
+		"not tag:notmuch::stale",
+		"not tag:notmuch::wontfix",
+		"(tag:notmuch::moreinfo or tag:notmuch::needs-review)"
+	    ],
+	    "title": "Review"
+	}
+    ]
+}
diff --git a/devel/nmbug/status-config.json b/devel/nmbug/status-config.json
deleted file mode 100644
index b926946..0000000
--- a/devel/nmbug/status-config.json
+++ /dev/null
@@ -1,70 +0,0 @@
-{
-    "meta": {
-        "title": "Notmuch Patches",
-        "blurb": "For more information see <a href=\"http://notmuchmail.org/nmbug\">nmbug</a>"
-    },
-
-    "views": [
-	{
-	    "comment": "Unresolved bugs (or just need tag updating).",
-	    "query": [
-		"tag:notmuch::bug",
-		"not tag:notmuch::fixed",
-		"not tag:notmuch::wontfix"
-	    ],
-	    "title": "Bugs"
-	},
-	{
-	    "comment": "These patches are under consideration for pushing.",
-	    "query": [
-		"tag:notmuch::patch and not tag:notmuch::pushed",
-		"not tag:notmuch::obsolete and not tag:notmuch::wip",
-		"not tag:notmuch::stale and not tag:notmuch::contrib",
-		"not tag:notmuch::moreinfo",
-		"not tag:notmuch::python",
-		"not tag:notmuch::vim",
-		"not tag:notmuch::wontfix",
-		"not tag:notmuch::needs-review"
-	    ],
-	    "title": "Maybe Ready (Core and Emacs)"
-	},
-	{
-	    "comment": "These python related patches might be ready to push, or they might just need updated tags.",
-	    "query": [
-		"tag:notmuch::patch and not tag:notmuch::pushed",
-		"not tag:notmuch::obsolete and not tag:notmuch::wip",
-		"not tag:notmuch::stale and not tag:notmuch::contrib",
-		"not tag:notmuch::moreinfo",
-		"not tag:notmuch::wontfix",
-		" tag:notmuch::python",
-		"not tag:notmuch::needs-review"
-	    ],
-	    "title": "Maybe Ready (Python)"
-	},
-	{
-	    "comment": "These vim related patches might be ready to push, or they might just need updated tags.",
-	    "query": [
-		"tag:notmuch::patch and not tag:notmuch::pushed",
-		"not tag:notmuch::obsolete and not tag:notmuch::wip",
-		"not tag:notmuch::stale and not tag:notmuch::contrib",
-		"not tag:notmuch::moreinfo",
-		"not tag:notmuch::wontfix",
-		"tag:notmuch::vim",
-		"not tag:notmuch::needs-review"
-	    ],
-	    "title": "Maybe Ready (vim)"
-	},
-	{
-	    "comment": "These patches are under review, or waiting for feedback.",
-	    "query": [
-		"tag:notmuch::patch",
-		"not tag:notmuch::pushed",
-		"not tag:notmuch::obsolete",
-		"not tag:notmuch::stale",
-		"not tag:notmuch::wontfix",
-		"(tag:notmuch::moreinfo or tag:notmuch::needs-review)"
-	    ],
-	    "title": "Review"
-	}
-    ]
-}
-- 
2.1.0.60.g85f0837

^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH 4/5] notmuch-report: Add notmuch-report(1) and notmuch-report.json(5) man pages
  2016-01-02  6:08 [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages W. Trevor King
                   ` (2 preceding siblings ...)
  2016-01-02  6:08 ` [PATCH 3/5] notmuch-report.json: Rename from status-config.json W. Trevor King
@ 2016-01-02  6:08 ` W. Trevor King
  2016-01-02  6:08 ` [PATCH 5/5] NEWS: Document the notmuch-report branch W. Trevor King
  4 siblings, 0 replies; 9+ messages in thread
From: W. Trevor King @ 2016-01-02  6:08 UTC (permalink / raw)
  To: notmuch; +Cc: David Bremner, Tomi Ollila, Jani Nikula, Carl Worth,
	W. Trevor King

To describe the script and config file format, so folks don't have to
dig through NEWS or the script's source to get that information.

The Makefile and conf.py are excerpted from the main doc/ directory
with minor simplifications and adjustments.  The devel/nmbug/ scripts
are largely independent of notmuch, and separating the docs here
allows packagers to easily build the docs and install the scripts in a
separate package, without complicating notmuch's core build/install
process.
---
 devel/nmbug/doc/.gitignore                     |   2 +
 devel/nmbug/doc/Makefile                       |  38 ++++++++
 devel/nmbug/doc/conf.py                        |  67 +++++++++++++
 devel/nmbug/doc/index.rst                      |  17 ++++
 devel/nmbug/doc/man1/notmuch-report.1.rst      |  54 +++++++++++
 devel/nmbug/doc/man5/notmuch-report.json.5.rst | 129 +++++++++++++++++++++++++
 devel/nmbug/notmuch-report                     |  19 ++--
 7 files changed, 318 insertions(+), 8 deletions(-)
 create mode 100644 devel/nmbug/doc/.gitignore
 create mode 100644 devel/nmbug/doc/Makefile
 create mode 100644 devel/nmbug/doc/conf.py
 create mode 100644 devel/nmbug/doc/index.rst
 create mode 100644 devel/nmbug/doc/man1/notmuch-report.1.rst
 create mode 100644 devel/nmbug/doc/man5/notmuch-report.json.5.rst

diff --git a/devel/nmbug/doc/.gitignore b/devel/nmbug/doc/.gitignore
new file mode 100644
index 0000000..4930881
--- /dev/null
+++ b/devel/nmbug/doc/.gitignore
@@ -0,0 +1,2 @@
+*.pyc
+_build
diff --git a/devel/nmbug/doc/Makefile b/devel/nmbug/doc/Makefile
new file mode 100644
index 0000000..7ea3ae7
--- /dev/null
+++ b/devel/nmbug/doc/Makefile
@@ -0,0 +1,38 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+DOCBUILDDIR   := _build
+
+SRCDIR ?= .
+ALLSPHINXOPTS := -d $(DOCBUILDDIR)/doctrees $(SPHINXOPTS) $(SRCDIR)
+
+MAN_RST_FILES := $(shell find $(SRCDIR)/man* -name '*.rst')
+MAN_ROFF_FILES := $(patsubst $(SRCDIR)/man%.rst,$(DOCBUILDDIR)/man/man%,$(MAN_RST_FILES))
+MAN_GZIP_FILES := $(addsuffix .gz,$(MAN_ROFF_FILES))
+
+.PHONY: build-man
+build-man: $(MAN_GZIP_FILES)
+
+%.gz: %
+	rm -f $@ && gzip --stdout $^ > $@
+
+$(MAN_ROFF_FILES): $(DOCBUILDDIR)/.roff.stamp
+
+# By using $(DOCBUILDDIR)/.roff.stamp instead of $(MAN_ROFF_FILES), we
+# convey to make that a single invocation of this recipe builds all
+# of the roff files.  This prevents parallel make from starting an
+# instance of this recipe for each roff file.
+$(DOCBUILDDIR)/.roff.stamp $(MAN_ROFF_FILES): $(MAN_RST_FILES)
+	mkdir -p $(DOCBUILDDIR)
+	touch $(DOCBUILDDIR)/.roff.stamp
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(DOCBUILDDIR)/man
+	for section in 1 5; do \
+	    mkdir -p $(DOCBUILDDIR)/man/man$${section}; \
+	    mv $(DOCBUILDDIR)/man/*.$${section} $(DOCBUILDDIR)/man/man$${section}; \
+	done
+
+clean:
+	rm -rf $(DOCBUILDDIR) $(SRCDIR)/conf.pyc
diff --git a/devel/nmbug/doc/conf.py b/devel/nmbug/doc/conf.py
new file mode 100644
index 0000000..29379d0
--- /dev/null
+++ b/devel/nmbug/doc/conf.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+
+import os.path
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = 'notmuch'
+authors = 'Carl Worth and many others'
+copyright = '2009-2015, {0}'.format(authors)
+
+location = os.path.dirname(__file__)
+
+dirname = location
+while True:
+    version_file = os.path.join(dirname, 'version')
+    if os.path.exists(version_file):
+        with open(version_file,'r') as f:
+            version = f.read().strip()
+            break
+    if dirname == '/':
+        raise ValueError(
+            'no version file found in this directory or its ancestors')
+    dirname = os.path.dirname(dirname)
+
+# The full version, including alpha/beta/rc tags.
+release = version
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+
+man_pages = [
+    ('man1/notmuch-report.1', 'notmuch-report',
+     'generate reports from notmuch queries', [authors], 1),
+    ('man5/notmuch-report.json.5', 'notmuch-report.json',
+     'configure notmuch-report', [authors], 5),
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+texinfo_no_detailmenu = True
+
+texinfo_documents = [
+    ('man1/notmuch-report.1', 'notmuch-report',
+     'generate reports from notmuch queries', authors, 'notmuch-report',
+     'generate reports from notmuch queries', 'Miscellaneous'),
+    ('man5/notmuch-report.json.5', 'notmuch-report.json',
+     'configure notmuch-report', authors, 'notmuch-report.json',
+     'configure notmuch-report', 'Miscellaneous'),
+]
diff --git a/devel/nmbug/doc/index.rst b/devel/nmbug/doc/index.rst
new file mode 100644
index 0000000..51ac59e
--- /dev/null
+++ b/devel/nmbug/doc/index.rst
@@ -0,0 +1,17 @@
+Welcome to notmuch's dev-tool documentation!
+============================================
+
+Contents:
+
+.. toctree::
+   :titlesonly:
+
+   man1/notmuch-report.1
+   man5/notmuch-report.json.5
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/devel/nmbug/doc/man1/notmuch-report.1.rst b/devel/nmbug/doc/man1/notmuch-report.1.rst
new file mode 100644
index 0000000..dfd82df
--- /dev/null
+++ b/devel/nmbug/doc/man1/notmuch-report.1.rst
@@ -0,0 +1,54 @@
+==============
+notmuch-report
+==============
+
+SYNOPSIS
+========
+
+**notmuch-report** [options ...]
+
+DESCRIPTION
+===========
+
+Generate HTML or plain-text reports showing query results.
+
+OPTIONS
+=======
+
+  ``-h``, ``--help``
+
+    Show a help message, including a list of available options, and
+    exit.
+
+  ``--text``
+    Output plain text instead of HTML.
+
+  ``--config`` <PATH>
+    Load config from given file.  The format is described in
+    **notmuch-report.json(5)**.  If this option is not set,
+    **notmuch-report** loads the config from the Git repository at
+    ``NMBGIT``.  See :ref:`NMBGIT <NMBGIT>` for details.
+
+  ``--list-views``
+    List available views (by title) and exit.
+
+  ``--get-query`` <VIEW>
+    Print the configured query for view matching the given title.
+
+ENVIRONMENT
+===========
+
+.. _NMBGIT:
+
+  ``NMBGIT``
+    If ``--config PATH`` is not set, **notmuch-report** will attempt
+    to load a config file named ``notmuch-report.json`` from the
+    ``config`` branch of the ``NMBGIT`` repository (defaulting to
+    ``~/.nmbug``).
+
+SEE ALSO
+========
+
+**notmuch(1)**, **notmuch-report.json(5)**, **notmuch-search(1)**,
+ **notmuch-tag(1)**
+
diff --git a/devel/nmbug/doc/man5/notmuch-report.json.5.rst b/devel/nmbug/doc/man5/notmuch-report.json.5.rst
new file mode 100644
index 0000000..4b5f84a
--- /dev/null
+++ b/devel/nmbug/doc/man5/notmuch-report.json.5.rst
@@ -0,0 +1,129 @@
+==============
+notmuch-report
+==============
+
+NAME
+====
+
+notmuch-report.json - configure output for **notmuch-report(1)**
+
+DESCRIPTION
+===========
+
+The config file is JSON_ with the following fields:
+
+meta
+  An object with page-wide information
+
+  title
+    Page title used in the default header.
+
+  blurb
+    Introduction paragraph used in the default header.
+
+  header
+    `Python format string`_ for the HTML header.  Optional.  It is
+    formatted with the following context:
+
+    date
+      The current UTC date.
+
+    datetime
+      The current UTC date-time.
+
+    title
+      The **meta.title** value.
+
+    blurb
+      The **meta.blurb** value.
+
+    encoding
+      The encoding used for the output file.
+
+    inter_message_padding
+      0.25em, for consistent CSS generation.
+
+    border_radius
+      0.5em, for consistent CSS generation.
+
+  footer
+    `Python format string`_ for the HTML footer.  It is formatted with
+    the same context used for **meta.header**.  Optional.
+
+  message-url
+    `Python format string`_ for message-linking URLs.  Optional.
+    Defaults to linking Gmane_.  It is formatted with the following
+    context:
+
+    message-id
+      The quoted_ message ID.
+
+    subject
+      The message subject.
+
+views
+  An array of view objects, where each object has the following
+  fields:
+
+  title
+    Header text for the view.
+
+  comment
+    Paragraph describing the view in more detail.  Optional.
+
+  id
+    Anchor string for the view.  Optional, defaulting to a slugged
+    form of the view title
+
+  query
+    An array of strings, which will be joined with 'and' to form the
+    view query.
+
+.. _Gmane: http://gmane.org/
+.. _JSON: http://json.org/
+.. _Python format string: https://docs.python.org/3/library/string.html#formatstrings
+.. _quoted: https://docs.python.org/3/library/urllib.parse.html#urllib.parse.quote
+
+EXAMPLE
+=======
+
+::
+
+  {
+    "meta": {
+      "title": "Notmuch Patches",
+      "blurb": "For more information see <a href=\"http://notmuchmail.org/nmbug\">nmbug</a>",
+      "header": "<html><head></head><body><h1>{title}</h1><p>{blurb}</p><h2>Views</h2>",
+      "footer": "<hr><p>Generated: {datetime}</p></html>",
+      "message-url": "http://mid.gmane.org/{message-id}"
+    },
+    "views": [
+      {
+        "title": "Bugs",
+        "comment": "Unresolved bugs.",
+        "query": [
+          "tag:notmuch::bug",
+          "not tag:notmuch::fixed",
+          "not tag:notmuch::wontfix"
+        ]
+      },
+      {
+        "title": "Review",
+        "comment": "These patches are under review, or waiting for feedback.",
+        "id": "under-review",
+        "query": [
+          "tag:notmuch::patch",
+          "not tag:notmuch::pushed",
+          "not tag:notmuch::obsolete",
+          "not tag:notmuch::stale",
+          "not tag:notmuch::wontfix",
+          "(tag:notmuch::moreinfo or tag:notmuch::needs-review)"
+        ]
+      }
+    ]
+  }
+
+SEE ALSO
+========
+
+**notmuch(1)**, **notmuch-report(1)**, **notmuch-search(1)**, **notmuch-tag(1)**
diff --git a/devel/nmbug/notmuch-report b/devel/nmbug/notmuch-report
index 5425e06..a4a3197 100755
--- a/devel/nmbug/notmuch-report
+++ b/devel/nmbug/notmuch-report
@@ -304,14 +304,17 @@ class HtmlPage (Page):
         return self._slug_regexp.sub('-', string)
 
 parser = argparse.ArgumentParser(description=__doc__)
-parser.add_argument('--text', help='output plain text format',
-                    action='store_true')
-parser.add_argument('--config', help='load config from given file',
-                    metavar='PATH')
-parser.add_argument('--list-views', help='list views',
-                    action='store_true')
-parser.add_argument('--get-query', help='get query for view',
-                    metavar='VIEW')
+parser.add_argument(
+    '--text', action='store_true', help='output plain text format')
+parser.add_argument(
+    '--config', metavar='PATH',
+    help='load config from given file.  '
+        'The format is described in notmuch-report.json(5).')
+parser.add_argument(
+    '--list-views', action='store_true', help='list views')
+parser.add_argument(
+    '--get-query', metavar='VIEW', help='get query for view')
+
 
 args = parser.parse_args()
 
-- 
2.1.0.60.g85f0837

^ permalink raw reply related	[flat|nested] 9+ messages in thread

* [PATCH 5/5] NEWS: Document the notmuch-report branch
  2016-01-02  6:08 [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages W. Trevor King
                   ` (3 preceding siblings ...)
  2016-01-02  6:08 ` [PATCH 4/5] notmuch-report: Add notmuch-report(1) and notmuch-report.json(5) man pages W. Trevor King
@ 2016-01-02  6:08 ` W. Trevor King
  2016-03-24 10:52   ` David Bremner
  4 siblings, 1 reply; 9+ messages in thread
From: W. Trevor King @ 2016-01-02  6:08 UTC (permalink / raw)
  To: notmuch; +Cc: David Bremner, Tomi Ollila, Jani Nikula, Carl Worth,
	W. Trevor King

---
 NEWS | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/NEWS b/NEWS
index 6681699..3535614 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,29 @@
+Notmuch 0.22 (UNRELEASED)
+=========================
+
+Documentation
+-------------
+
+New `notmuch-report(1)` and `notmuch-report.json(5)` man pages
+describe `notmuch-report` and its JSON configuration file.  You can
+build these files by running `make` in the `devel/nmbug/doc`
+directory.
+
+notmuch-report
+--------------
+
+Renamed from `nmbug-status`.  This script generates reports based on
+notmuch queries, and doesn't really have anything to do with nmbug,
+except for sharing the `NMBGIT` environment variable.  The new name
+focuses on the script's action, instead of its historical association
+with the nmbug workflow.  This should make it more discoverable for
+users looking for generic notmuch reporting tools.
+
+The default configuration file name (extracted from the `config`
+branch of `NBMGIT` has changed from `status-config.json` to
+`notmuch-report.json` so it is more obviously associated with the
+report-generating script.
+
 Notmuch 0.21 (2015-10-29)
 =========================
 
-- 
2.1.0.60.g85f0837

^ permalink raw reply related	[flat|nested] 9+ messages in thread

* Re: [PATCH 2/5] notmuch-report: Rename from nmbug-status
  2016-01-02  6:08 ` [PATCH 2/5] notmuch-report: Rename from nmbug-status W. Trevor King
@ 2016-01-03 16:19   ` Jani Nikula
  0 siblings, 0 replies; 9+ messages in thread
From: Jani Nikula @ 2016-01-03 16:19 UTC (permalink / raw)
  To: W. Trevor King, notmuch
  Cc: David Bremner, Tomi Ollila, Carl Worth, W. Trevor King

On Sat, 02 Jan 2016, "W. Trevor King" <wking@tremily.us> wrote:
> This script generates reports based on notmuch queries, and doesn't
> really have anything to do with nmbug, except for sharing the NMBGIT
> environment variable.
> ---
>  devel/nmbug/nmbug-status   | 419 ---------------------------------------------
>  devel/nmbug/notmuch-report | 419 +++++++++++++++++++++++++++++++++++++++++++++
>  2 files changed, 419 insertions(+), 419 deletions(-)
>  delete mode 100755 devel/nmbug/nmbug-status
>  create mode 100755 devel/nmbug/notmuch-report

FYI, at least with git format-patch you can add -M option to detect
renames, and the resulting patch will be trivial to review.

BR,
Jani.


>
> diff --git a/devel/nmbug/nmbug-status b/devel/nmbug/nmbug-status
> deleted file mode 100755
> index 22e3b5b..0000000
> --- a/devel/nmbug/nmbug-status
> +++ /dev/null
> @@ -1,419 +0,0 @@
> -#!/usr/bin/python
> -#
> -# Copyright (c) 2011-2012 David Bremner <david@tethera.net>
> -#
> -# dependencies
> -#       - python 2.6 for json
> -#       - argparse; either python 2.7, or install separately
> -#
> -# This program is free software: you can redistribute it and/or modify
> -# it under the terms of the GNU General Public License as published by
> -# the Free Software Foundation, either version 3 of the License, or
> -# (at your option) any later version.
> -#
> -# This program is distributed in the hope that it will be useful,
> -# but WITHOUT ANY WARRANTY; without even the implied warranty of
> -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
> -# GNU General Public License for more details.
> -#
> -# You should have received a copy of the GNU General Public License
> -# along with this program.  If not, see http://www.gnu.org/licenses/ .
> -
> -"""Generate HTML for one or more notmuch searches.
> -
> -Messages matching each search are grouped by thread.  Each message
> -that contains both a subject and message-id will have the displayed
> -subject link to the Gmane view of the message.
> -"""
> -
> -from __future__ import print_function
> -from __future__ import unicode_literals
> -
> -import codecs
> -import collections
> -import datetime
> -import email.utils
> -try:  # Python 3
> -    from urllib.parse import quote
> -except ImportError:  # Python 2
> -    from urllib import quote
> -import json
> -import argparse
> -import os
> -import re
> -import sys
> -import subprocess
> -import xml.sax.saxutils
> -
> -
> -_ENCODING = 'UTF-8'
> -_PAGES = {}
> -
> -
> -if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier
> -    class _OrderedDict (dict):
> -        "Just enough of a stub to get through Page._get_threads"
> -        def __init__(self, *args, **kwargs):
> -            super(_OrderedDict, self).__init__(*args, **kwargs)
> -            self._keys = []  # record key order
> -
> -        def __setitem__(self, key, value):
> -            super(_OrderedDict, self).__setitem__(key, value)
> -            self._keys.append(key)
> -
> -        def values(self):
> -            for key in self._keys:
> -                yield self[key]
> -
> -
> -    collections.OrderedDict = _OrderedDict
> -
> -
> -class ConfigError (Exception):
> -    """Errors with config file usage
> -    """
> -    pass
> -
> -
> -def read_config(path=None, encoding=None):
> -    "Read config from json file"
> -    if not encoding:
> -        encoding = _ENCODING
> -    if path:
> -        try:
> -            with open(path, 'rb') as f:
> -                config_bytes = f.read()
> -        except IOError as e:
> -            raise ConfigError('Could not read config from {}'.format(path))
> -    else:
> -        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))
> -        branch = 'config'
> -        filename = 'status-config.json'
> -
> -        # read only the first line from the pipe
> -        sha1_bytes = subprocess.Popen(
> -            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],
> -            stdout=subprocess.PIPE).stdout.readline()
> -        sha1 = sha1_bytes.decode(encoding).rstrip()
> -        if not sha1:
> -            raise ConfigError(
> -                ("No local branch '{branch}' in {nmbgit}.  "
> -                 'Checkout a local {branch} branch or explicitly set --config.'
> -                ).format(branch=branch, nmbgit=nmbhome))
> -
> -        p = subprocess.Popen(
> -            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',
> -             '{}:{}'.format(sha1, filename)],
> -            stdout=subprocess.PIPE)
> -        config_bytes, err = p.communicate()
> -        status = p.wait()
> -        if status != 0:
> -            raise ConfigError(
> -                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "
> -                 'Add the file or explicitly set --config.'
> -                ).format(filename=filename, branch=branch, nmbgit=nmbhome))
> -
> -    config_json = config_bytes.decode(encoding)
> -    try:
> -        return json.loads(config_json)
> -    except ValueError as e:
> -        if not path:
> -            path = "{} in branch '{}' of {}".format(
> -                filename, branch, nmbhome)
> -        raise ConfigError(
> -            'Could not parse JSON from the config file {}:\n{}'.format(
> -                path, e))
> -
> -
> -class Thread (list):
> -    def __init__(self):
> -        self.running_data = {}
> -
> -
> -class Page (object):
> -    def __init__(self, header=None, footer=None):
> -        self.header = header
> -        self.footer = footer
> -
> -    def write(self, database, views, stream=None):
> -        if not stream:
> -            try:  # Python 3
> -                byte_stream = sys.stdout.buffer
> -            except AttributeError:  # Python 2
> -                byte_stream = sys.stdout
> -            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)
> -        self._write_header(views=views, stream=stream)
> -        for view in views:
> -            self._write_view(database=database, view=view, stream=stream)
> -        self._write_footer(views=views, stream=stream)
> -
> -    def _write_header(self, views, stream):
> -        if self.header:
> -            stream.write(self.header)
> -
> -    def _write_footer(self, views, stream):
> -        if self.footer:
> -            stream.write(self.footer)
> -
> -    def _write_view(self, database, view, stream):
> -        # sort order, default to oldest-first
> -        sort_key = view.get('sort', 'oldest-first')
> -        # dynamically accept all values in Query.SORT
> -        sort_attribute = sort_key.upper().replace('-', '_')
> -        try:
> -            sort = getattr(notmuch.Query.SORT, sort_attribute)
> -        except AttributeError:
> -            raise ConfigError('Invalid sort setting for {}: {!r}'.format(
> -                view['title'], sort_key))
> -        if 'query-string' not in view:
> -            query = view['query']
> -            view['query-string'] = ' and '.join(query)
> -        q = notmuch.Query(database, view['query-string'])
> -        q.set_sort(sort)
> -        threads = self._get_threads(messages=q.search_messages())
> -        self._write_view_header(view=view, stream=stream)
> -        self._write_threads(threads=threads, stream=stream)
> -
> -    def _get_threads(self, messages):
> -        threads = collections.OrderedDict()
> -        for message in messages:
> -            thread_id = message.get_thread_id()
> -            if thread_id in threads:
> -                thread = threads[thread_id]
> -            else:
> -                thread = Thread()
> -                threads[thread_id] = thread
> -            thread.running_data, display_data = self._message_display_data(
> -                running_data=thread.running_data, message=message)
> -            thread.append(display_data)
> -        return list(threads.values())
> -
> -    def _write_view_header(self, view, stream):
> -        pass
> -
> -    def _write_threads(self, threads, stream):
> -        for thread in threads:
> -            for message_display_data in thread:
> -                stream.write(
> -                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'
> -                     '{message-id-term:>72}\n'
> -                     ).format(**message_display_data))
> -            if thread != threads[-1]:
> -                stream.write('\n')
> -
> -    def _message_display_data(self, running_data, message):
> -        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')
> -        data = {}
> -        for header in headers:
> -            if header == 'thread-id':
> -                value = message.get_thread_id()
> -            elif header == 'message-id':
> -                value = message.get_message_id()
> -                data['message-id-term'] = 'id:"{0}"'.format(value)
> -            elif header == 'date':
> -                value = str(datetime.datetime.utcfromtimestamp(
> -                    message.get_date()).date())
> -            else:
> -                value = message.get_header(header)
> -            if header == 'from':
> -                (value, addr) = email.utils.parseaddr(value)
> -                if not value:
> -                    value = addr.split('@')[0]
> -            data[header] = value
> -        next_running_data = data.copy()
> -        for header, value in data.items():
> -            if header in ['message-id', 'subject']:
> -                continue
> -            if value == running_data.get(header, None):
> -                data[header] = ''
> -        return (next_running_data, data)
> -
> -
> -class HtmlPage (Page):
> -    _slug_regexp = re.compile('\W+')
> -
> -    def _write_header(self, views, stream):
> -        super(HtmlPage, self)._write_header(views=views, stream=stream)
> -        stream.write('<ul>\n')
> -        for view in views:
> -            if 'id' not in view:
> -                view['id'] = self._slug(view['title'])
> -            stream.write(
> -                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))
> -        stream.write('</ul>\n')
> -
> -    def _write_view_header(self, view, stream):
> -        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))
> -        stream.write('<p>\n')
> -        if 'comment' in view:
> -            stream.write(view['comment'])
> -            stream.write('\n')
> -        for line in [
> -                'The view is generated from the following query:',
> -                '</p>',
> -                '<p>',
> -                '  <code>',
> -                view['query-string'],
> -                '  </code>',
> -                '</p>',
> -                ]:
> -            stream.write(line)
> -            stream.write('\n')
> -
> -    def _write_threads(self, threads, stream):
> -        if not threads:
> -            return
> -        stream.write('<table>\n')
> -        for thread in threads:
> -            stream.write('  <tbody>\n')
> -            for message_display_data in thread:
> -                stream.write((
> -                    '    <tr class="message-first">\n'
> -                    '      <td>{date}</td>\n'
> -                    '      <td><code>{message-id-term}</code></td>\n'
> -                    '    </tr>\n'
> -                    '    <tr class="message-last">\n'
> -                    '      <td>{from}</td>\n'
> -                    '      <td>{subject}</td>\n'
> -                    '    </tr>\n'
> -                    ).format(**message_display_data))
> -            stream.write('  </tbody>\n')
> -            if thread != threads[-1]:
> -                stream.write(
> -                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')
> -        stream.write('</table>\n')
> -
> -    def _message_display_data(self, *args, **kwargs):
> -        running_data, display_data = super(
> -            HtmlPage, self)._message_display_data(
> -                *args, **kwargs)
> -        if 'subject' in display_data and 'message-id' in display_data:
> -            d = {
> -                'message-id': quote(display_data['message-id']),
> -                'subject': xml.sax.saxutils.escape(display_data['subject']),
> -                }
> -            display_data['subject'] = (
> -                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'
> -                ).format(**d)
> -        for key in ['message-id', 'from']:
> -            if key in display_data:
> -                display_data[key] = xml.sax.saxutils.escape(display_data[key])
> -        return (running_data, display_data)
> -
> -    def _slug(self, string):
> -        return self._slug_regexp.sub('-', string)
> -
> -parser = argparse.ArgumentParser(description=__doc__)
> -parser.add_argument('--text', help='output plain text format',
> -                    action='store_true')
> -parser.add_argument('--config', help='load config from given file',
> -                    metavar='PATH')
> -parser.add_argument('--list-views', help='list views',
> -                    action='store_true')
> -parser.add_argument('--get-query', help='get query for view',
> -                    metavar='VIEW')
> -
> -args = parser.parse_args()
> -
> -try:
> -    config = read_config(path=args.config)
> -except ConfigError as e:
> -    print(e, file=sys.stderr)
> -    sys.exit(1)
> -
> -header_template = config['meta'].get('header', '''<!DOCTYPE html>
> -<html lang="en">
> -<head>
> -  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />
> -  <title>{title}</title>
> -  <style media="screen" type="text/css">
> -    table {{
> -      border-spacing: 0;
> -    }}
> -    tr.message-first td {{
> -      padding-top: {inter_message_padding};
> -    }}
> -    tr.message-last td {{
> -      padding-bottom: {inter_message_padding};
> -    }}
> -    td {{
> -      padding-left: {border_radius};
> -      padding-right: {border_radius};
> -    }}
> -    tr:first-child td:first-child {{
> -      border-top-left-radius: {border_radius};
> -    }}
> -    tr:first-child td:last-child {{
> -      border-top-right-radius: {border_radius};
> -    }}
> -    tr:last-child td:first-child {{
> -      border-bottom-left-radius: {border_radius};
> -    }}
> -    tr:last-child td:last-child {{
> -      border-bottom-right-radius: {border_radius};
> -    }}
> -    tbody:nth-child(4n+1) tr td {{
> -      background-color: #ffd96e;
> -    }}
> -    tbody:nth-child(4n+3) tr td {{
> -      background-color: #bce;
> -    }}
> -    hr {{
> -      border: 0;
> -      height: 1px;
> -      color: #ccc;
> -      background-color: #ccc;
> -    }}
> -  </style>
> -</head>
> -<body>
> -<h2>{title}</h2>
> -{blurb}
> -</p>
> -<h3>Views</h3>
> -''')
> -
> -footer_template = config['meta'].get('footer', '''
> -<hr>
> -<p>Generated: {datetime}
> -</body>
> -</html>
> -''')
> -
> -now = datetime.datetime.utcnow()
> -context = {
> -    'date': now,
> -    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),
> -    'title': config['meta']['title'],
> -    'blurb': config['meta']['blurb'],
> -    'encoding': _ENCODING,
> -    'inter_message_padding': '0.25em',
> -    'border_radius': '0.5em',
> -    }
> -
> -_PAGES['text'] = Page()
> -_PAGES['html'] = HtmlPage(
> -    header=header_template.format(**context),
> -    footer=footer_template.format(**context),
> -    )
> -
> -if args.list_views:
> -    for view in config['views']:
> -        print(view['title'])
> -    sys.exit(0)
> -elif args.get_query != None:
> -    for view in config['views']:
> -        if args.get_query == view['title']:
> -            print(' and '.join(view['query']))
> -    sys.exit(0)
> -else:
> -    # only import notmuch if needed
> -    import notmuch
> -
> -if args.text:
> -    page = _PAGES['text']
> -else:
> -    page = _PAGES['html']
> -
> -db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)
> -page.write(database=db, views=config['views'])
> diff --git a/devel/nmbug/notmuch-report b/devel/nmbug/notmuch-report
> new file mode 100755
> index 0000000..22e3b5b
> --- /dev/null
> +++ b/devel/nmbug/notmuch-report
> @@ -0,0 +1,419 @@
> +#!/usr/bin/python
> +#
> +# Copyright (c) 2011-2012 David Bremner <david@tethera.net>
> +#
> +# dependencies
> +#       - python 2.6 for json
> +#       - argparse; either python 2.7, or install separately
> +#
> +# This program is free software: you can redistribute it and/or modify
> +# it under the terms of the GNU General Public License as published by
> +# the Free Software Foundation, either version 3 of the License, or
> +# (at your option) any later version.
> +#
> +# This program is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
> +# GNU General Public License for more details.
> +#
> +# You should have received a copy of the GNU General Public License
> +# along with this program.  If not, see http://www.gnu.org/licenses/ .
> +
> +"""Generate HTML for one or more notmuch searches.
> +
> +Messages matching each search are grouped by thread.  Each message
> +that contains both a subject and message-id will have the displayed
> +subject link to the Gmane view of the message.
> +"""
> +
> +from __future__ import print_function
> +from __future__ import unicode_literals
> +
> +import codecs
> +import collections
> +import datetime
> +import email.utils
> +try:  # Python 3
> +    from urllib.parse import quote
> +except ImportError:  # Python 2
> +    from urllib import quote
> +import json
> +import argparse
> +import os
> +import re
> +import sys
> +import subprocess
> +import xml.sax.saxutils
> +
> +
> +_ENCODING = 'UTF-8'
> +_PAGES = {}
> +
> +
> +if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier
> +    class _OrderedDict (dict):
> +        "Just enough of a stub to get through Page._get_threads"
> +        def __init__(self, *args, **kwargs):
> +            super(_OrderedDict, self).__init__(*args, **kwargs)
> +            self._keys = []  # record key order
> +
> +        def __setitem__(self, key, value):
> +            super(_OrderedDict, self).__setitem__(key, value)
> +            self._keys.append(key)
> +
> +        def values(self):
> +            for key in self._keys:
> +                yield self[key]
> +
> +
> +    collections.OrderedDict = _OrderedDict
> +
> +
> +class ConfigError (Exception):
> +    """Errors with config file usage
> +    """
> +    pass
> +
> +
> +def read_config(path=None, encoding=None):
> +    "Read config from json file"
> +    if not encoding:
> +        encoding = _ENCODING
> +    if path:
> +        try:
> +            with open(path, 'rb') as f:
> +                config_bytes = f.read()
> +        except IOError as e:
> +            raise ConfigError('Could not read config from {}'.format(path))
> +    else:
> +        nmbhome = os.getenv('NMBGIT', os.path.expanduser('~/.nmbug'))
> +        branch = 'config'
> +        filename = 'status-config.json'
> +
> +        # read only the first line from the pipe
> +        sha1_bytes = subprocess.Popen(
> +            ['git', '--git-dir', nmbhome, 'show-ref', '-s', '--heads', branch],
> +            stdout=subprocess.PIPE).stdout.readline()
> +        sha1 = sha1_bytes.decode(encoding).rstrip()
> +        if not sha1:
> +            raise ConfigError(
> +                ("No local branch '{branch}' in {nmbgit}.  "
> +                 'Checkout a local {branch} branch or explicitly set --config.'
> +                ).format(branch=branch, nmbgit=nmbhome))
> +
> +        p = subprocess.Popen(
> +            ['git', '--git-dir', nmbhome, 'cat-file', 'blob',
> +             '{}:{}'.format(sha1, filename)],
> +            stdout=subprocess.PIPE)
> +        config_bytes, err = p.communicate()
> +        status = p.wait()
> +        if status != 0:
> +            raise ConfigError(
> +                ("Missing {filename} in branch '{branch}' of {nmbgit}.  "
> +                 'Add the file or explicitly set --config.'
> +                ).format(filename=filename, branch=branch, nmbgit=nmbhome))
> +
> +    config_json = config_bytes.decode(encoding)
> +    try:
> +        return json.loads(config_json)
> +    except ValueError as e:
> +        if not path:
> +            path = "{} in branch '{}' of {}".format(
> +                filename, branch, nmbhome)
> +        raise ConfigError(
> +            'Could not parse JSON from the config file {}:\n{}'.format(
> +                path, e))
> +
> +
> +class Thread (list):
> +    def __init__(self):
> +        self.running_data = {}
> +
> +
> +class Page (object):
> +    def __init__(self, header=None, footer=None):
> +        self.header = header
> +        self.footer = footer
> +
> +    def write(self, database, views, stream=None):
> +        if not stream:
> +            try:  # Python 3
> +                byte_stream = sys.stdout.buffer
> +            except AttributeError:  # Python 2
> +                byte_stream = sys.stdout
> +            stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)
> +        self._write_header(views=views, stream=stream)
> +        for view in views:
> +            self._write_view(database=database, view=view, stream=stream)
> +        self._write_footer(views=views, stream=stream)
> +
> +    def _write_header(self, views, stream):
> +        if self.header:
> +            stream.write(self.header)
> +
> +    def _write_footer(self, views, stream):
> +        if self.footer:
> +            stream.write(self.footer)
> +
> +    def _write_view(self, database, view, stream):
> +        # sort order, default to oldest-first
> +        sort_key = view.get('sort', 'oldest-first')
> +        # dynamically accept all values in Query.SORT
> +        sort_attribute = sort_key.upper().replace('-', '_')
> +        try:
> +            sort = getattr(notmuch.Query.SORT, sort_attribute)
> +        except AttributeError:
> +            raise ConfigError('Invalid sort setting for {}: {!r}'.format(
> +                view['title'], sort_key))
> +        if 'query-string' not in view:
> +            query = view['query']
> +            view['query-string'] = ' and '.join(query)
> +        q = notmuch.Query(database, view['query-string'])
> +        q.set_sort(sort)
> +        threads = self._get_threads(messages=q.search_messages())
> +        self._write_view_header(view=view, stream=stream)
> +        self._write_threads(threads=threads, stream=stream)
> +
> +    def _get_threads(self, messages):
> +        threads = collections.OrderedDict()
> +        for message in messages:
> +            thread_id = message.get_thread_id()
> +            if thread_id in threads:
> +                thread = threads[thread_id]
> +            else:
> +                thread = Thread()
> +                threads[thread_id] = thread
> +            thread.running_data, display_data = self._message_display_data(
> +                running_data=thread.running_data, message=message)
> +            thread.append(display_data)
> +        return list(threads.values())
> +
> +    def _write_view_header(self, view, stream):
> +        pass
> +
> +    def _write_threads(self, threads, stream):
> +        for thread in threads:
> +            for message_display_data in thread:
> +                stream.write(
> +                    ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'
> +                     '{message-id-term:>72}\n'
> +                     ).format(**message_display_data))
> +            if thread != threads[-1]:
> +                stream.write('\n')
> +
> +    def _message_display_data(self, running_data, message):
> +        headers = ('thread-id', 'message-id', 'date', 'from', 'subject')
> +        data = {}
> +        for header in headers:
> +            if header == 'thread-id':
> +                value = message.get_thread_id()
> +            elif header == 'message-id':
> +                value = message.get_message_id()
> +                data['message-id-term'] = 'id:"{0}"'.format(value)
> +            elif header == 'date':
> +                value = str(datetime.datetime.utcfromtimestamp(
> +                    message.get_date()).date())
> +            else:
> +                value = message.get_header(header)
> +            if header == 'from':
> +                (value, addr) = email.utils.parseaddr(value)
> +                if not value:
> +                    value = addr.split('@')[0]
> +            data[header] = value
> +        next_running_data = data.copy()
> +        for header, value in data.items():
> +            if header in ['message-id', 'subject']:
> +                continue
> +            if value == running_data.get(header, None):
> +                data[header] = ''
> +        return (next_running_data, data)
> +
> +
> +class HtmlPage (Page):
> +    _slug_regexp = re.compile('\W+')
> +
> +    def _write_header(self, views, stream):
> +        super(HtmlPage, self)._write_header(views=views, stream=stream)
> +        stream.write('<ul>\n')
> +        for view in views:
> +            if 'id' not in view:
> +                view['id'] = self._slug(view['title'])
> +            stream.write(
> +                '<li><a href="#{id}">{title}</a></li>\n'.format(**view))
> +        stream.write('</ul>\n')
> +
> +    def _write_view_header(self, view, stream):
> +        stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))
> +        stream.write('<p>\n')
> +        if 'comment' in view:
> +            stream.write(view['comment'])
> +            stream.write('\n')
> +        for line in [
> +                'The view is generated from the following query:',
> +                '</p>',
> +                '<p>',
> +                '  <code>',
> +                view['query-string'],
> +                '  </code>',
> +                '</p>',
> +                ]:
> +            stream.write(line)
> +            stream.write('\n')
> +
> +    def _write_threads(self, threads, stream):
> +        if not threads:
> +            return
> +        stream.write('<table>\n')
> +        for thread in threads:
> +            stream.write('  <tbody>\n')
> +            for message_display_data in thread:
> +                stream.write((
> +                    '    <tr class="message-first">\n'
> +                    '      <td>{date}</td>\n'
> +                    '      <td><code>{message-id-term}</code></td>\n'
> +                    '    </tr>\n'
> +                    '    <tr class="message-last">\n'
> +                    '      <td>{from}</td>\n'
> +                    '      <td>{subject}</td>\n'
> +                    '    </tr>\n'
> +                    ).format(**message_display_data))
> +            stream.write('  </tbody>\n')
> +            if thread != threads[-1]:
> +                stream.write(
> +                    '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')
> +        stream.write('</table>\n')
> +
> +    def _message_display_data(self, *args, **kwargs):
> +        running_data, display_data = super(
> +            HtmlPage, self)._message_display_data(
> +                *args, **kwargs)
> +        if 'subject' in display_data and 'message-id' in display_data:
> +            d = {
> +                'message-id': quote(display_data['message-id']),
> +                'subject': xml.sax.saxutils.escape(display_data['subject']),
> +                }
> +            display_data['subject'] = (
> +                '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'
> +                ).format(**d)
> +        for key in ['message-id', 'from']:
> +            if key in display_data:
> +                display_data[key] = xml.sax.saxutils.escape(display_data[key])
> +        return (running_data, display_data)
> +
> +    def _slug(self, string):
> +        return self._slug_regexp.sub('-', string)
> +
> +parser = argparse.ArgumentParser(description=__doc__)
> +parser.add_argument('--text', help='output plain text format',
> +                    action='store_true')
> +parser.add_argument('--config', help='load config from given file',
> +                    metavar='PATH')
> +parser.add_argument('--list-views', help='list views',
> +                    action='store_true')
> +parser.add_argument('--get-query', help='get query for view',
> +                    metavar='VIEW')
> +
> +args = parser.parse_args()
> +
> +try:
> +    config = read_config(path=args.config)
> +except ConfigError as e:
> +    print(e, file=sys.stderr)
> +    sys.exit(1)
> +
> +header_template = config['meta'].get('header', '''<!DOCTYPE html>
> +<html lang="en">
> +<head>
> +  <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />
> +  <title>{title}</title>
> +  <style media="screen" type="text/css">
> +    table {{
> +      border-spacing: 0;
> +    }}
> +    tr.message-first td {{
> +      padding-top: {inter_message_padding};
> +    }}
> +    tr.message-last td {{
> +      padding-bottom: {inter_message_padding};
> +    }}
> +    td {{
> +      padding-left: {border_radius};
> +      padding-right: {border_radius};
> +    }}
> +    tr:first-child td:first-child {{
> +      border-top-left-radius: {border_radius};
> +    }}
> +    tr:first-child td:last-child {{
> +      border-top-right-radius: {border_radius};
> +    }}
> +    tr:last-child td:first-child {{
> +      border-bottom-left-radius: {border_radius};
> +    }}
> +    tr:last-child td:last-child {{
> +      border-bottom-right-radius: {border_radius};
> +    }}
> +    tbody:nth-child(4n+1) tr td {{
> +      background-color: #ffd96e;
> +    }}
> +    tbody:nth-child(4n+3) tr td {{
> +      background-color: #bce;
> +    }}
> +    hr {{
> +      border: 0;
> +      height: 1px;
> +      color: #ccc;
> +      background-color: #ccc;
> +    }}
> +  </style>
> +</head>
> +<body>
> +<h2>{title}</h2>
> +{blurb}
> +</p>
> +<h3>Views</h3>
> +''')
> +
> +footer_template = config['meta'].get('footer', '''
> +<hr>
> +<p>Generated: {datetime}
> +</body>
> +</html>
> +''')
> +
> +now = datetime.datetime.utcnow()
> +context = {
> +    'date': now,
> +    'datetime': now.strftime('%Y-%m-%d %H:%M:%SZ'),
> +    'title': config['meta']['title'],
> +    'blurb': config['meta']['blurb'],
> +    'encoding': _ENCODING,
> +    'inter_message_padding': '0.25em',
> +    'border_radius': '0.5em',
> +    }
> +
> +_PAGES['text'] = Page()
> +_PAGES['html'] = HtmlPage(
> +    header=header_template.format(**context),
> +    footer=footer_template.format(**context),
> +    )
> +
> +if args.list_views:
> +    for view in config['views']:
> +        print(view['title'])
> +    sys.exit(0)
> +elif args.get_query != None:
> +    for view in config['views']:
> +        if args.get_query == view['title']:
> +            print(' and '.join(view['query']))
> +    sys.exit(0)
> +else:
> +    # only import notmuch if needed
> +    import notmuch
> +
> +if args.text:
> +    page = _PAGES['text']
> +else:
> +    page = _PAGES['html']
> +
> +db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)
> +page.write(database=db, views=config['views'])
> -- 
> 2.1.0.60.g85f0837

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message
  2016-01-02  6:08 ` [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message W. Trevor King
@ 2016-01-07 13:27   ` David Bremner
  0 siblings, 0 replies; 9+ messages in thread
From: David Bremner @ 2016-01-07 13:27 UTC (permalink / raw)
  To: W. Trevor King, notmuch
  Cc: Tomi Ollila, Jani Nikula, Carl Worth, W. Trevor King

"W. Trevor King" <wking@tremily.us> writes:

> We already have a 'filename' variable with the name, so stay DRY and
> use that variable here.
>
> Also fix a missing-whitespace error from bed8b674 (nmbug-status:
> Clarify errors for illegible configs, 2014-05-10), wrapping on the
> sentence to match similar error-generation earlier in this function.

pushed this one patch

^ permalink raw reply	[flat|nested] 9+ messages in thread

* Re: [PATCH 5/5] NEWS: Document the notmuch-report branch
  2016-01-02  6:08 ` [PATCH 5/5] NEWS: Document the notmuch-report branch W. Trevor King
@ 2016-03-24 10:52   ` David Bremner
  0 siblings, 0 replies; 9+ messages in thread
From: David Bremner @ 2016-03-24 10:52 UTC (permalink / raw)
  To: W. Trevor King, notmuch

"W. Trevor King" <wking@tremily.us> writes:

> ---
>  NEWS | 26 ++++++++++++++++++++++++++
>  1 file changed, 26 insertions(+)

I finally merged these. You'll probably want to look at how the NEWS
items for the two notmuch-report series combine. They're also a bit long
for my taste, but I don't think it's worth spending a lot of time fine
tuning NEWS entries.

d

^ permalink raw reply	[flat|nested] 9+ messages in thread

end of thread, other threads:[~2016-03-24 10:52 UTC | newest]

Thread overview: 9+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2016-01-02  6:08 [PATCH 0/5] notmuch-report: Rename from nmbug-status and add man pages W. Trevor King
2016-01-02  6:08 ` [PATCH 1/5] nmbug-status: Avoid hard-coded filename in error message W. Trevor King
2016-01-07 13:27   ` David Bremner
2016-01-02  6:08 ` [PATCH 2/5] notmuch-report: Rename from nmbug-status W. Trevor King
2016-01-03 16:19   ` Jani Nikula
2016-01-02  6:08 ` [PATCH 3/5] notmuch-report.json: Rename from status-config.json W. Trevor King
2016-01-02  6:08 ` [PATCH 4/5] notmuch-report: Add notmuch-report(1) and notmuch-report.json(5) man pages W. Trevor King
2016-01-02  6:08 ` [PATCH 5/5] NEWS: Document the notmuch-report branch W. Trevor King
2016-03-24 10:52   ` David Bremner

Code repositories for project(s) associated with this public inbox

	https://yhetil.org/notmuch.git/

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for read-only IMAP folder(s) and NNTP newsgroup(s).