#!/usr/bin/python
#
# Copyright (c) 2011-2012 David Bremner <david@tethera.net>
-# License: Same as notmuch
+#
# 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 text and/or 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 an archive view of the message (defaulting to Gmane).
+"""
from __future__ import print_function
from __future__ import unicode_literals
import collections
import datetime
import email.utils
-import locale
try: # Python 3
from urllib.parse import quote
except ImportError: # Python 2
import xml.sax.saxutils
-_ENCODING = locale.getpreferredencoding() or sys.getdefaultencoding()
+_ENCODING = 'UTF-8'
_PAGES = {}
super(_OrderedDict, self).__setitem__(key, value)
self._keys.append(key)
- def __values__(self):
+ 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:
- fp = open(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', 'config'],
+ ['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))
- fp_byte_stream = subprocess.Popen(
+ p = subprocess.Popen(
['git', '--git-dir', nmbhome, 'cat-file', 'blob',
- sha1+':status-config.json'],
- stdout=subprocess.PIPE).stdout
- fp = codecs.getreader(encoding=encoding)(stream=fp_byte_stream)
-
- return json.load(fp)
+ '{}:{}'.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):
byte_stream = sys.stdout.buffer
except AttributeError: # Python 2
byte_stream = sys.stdout
- stream = codecs.getwriter(encoding='UTF-8')(stream=byte_stream)
+ 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)
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)
+ view['query-string'] = ' and '.join(
+ '( {} )'.format(q) for q in query)
q = notmuch.Query(database, view['query-string'])
- q.set_sort(notmuch.Query.SORT.OLDEST_FIRST)
+ 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)
class HtmlPage (Page):
_slug_regexp = re.compile('\W+')
+ def __init__(self, message_url_template, **kwargs):
+ self.message_url_template = message_url_template
+ super(HtmlPage, self).__init__(**kwargs)
+
def _write_header(self, views, stream):
super(HtmlPage, self)._write_header(views=views, stream=stream)
stream.write('<ul>\n')
'message-id': quote(display_data['message-id']),
'subject': xml.sax.saxutils.escape(display_data['subject']),
}
+ d['url'] = self.message_url_template.format(**d)
display_data['subject'] = (
- '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'
+ '<a href="{url}">{subject}</a>'
).format(**d)
for key in ['message-id', 'from']:
if key in 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()
-_PAGES['text'] = Page()
-_PAGES['html'] = HtmlPage(
- header='''<!DOCTYPE html>
+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=utf-8" />
- <title>Notmuch Patches</title>
+ <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />
+ <title>{title}</title>
<style media="screen" type="text/css">
+ h1 {{
+ font-size: 1.5em;
+ }}
+ h2 {{
+ font-size: 1.17em;
+ }}
+ h3 {{
+ font-size: 100%;
+ }}
table {{
border-spacing: 0;
}}
tbody:nth-child(4n+3) tr td {{
background-color: #bce;
}}
+ hr {{
+ border: 0;
+ height: 1px;
+ color: #ccc;
+ background-color: #ccc;
+ }}
</style>
</head>
<body>
-<h2>Notmuch Patches</h2>
+<h1>{title}</h1>
<p>
-Generated: {date}<br />
-For more infomation see <a href="http://notmuchmail.org/nmbug">nmbug</a>
+{blurb}
</p>
-<h3>Views</h3>
-'''.format(date=datetime.datetime.utcnow().date(),
- inter_message_padding='0.25em',
- border_radius='0.5em'),
- footer='</body>\n</html>\n',
- )
+<h2>Views</h2>
+''')
+
+footer_template = config['meta'].get('footer', '''
+<hr>
+<p>Generated: {datetime}</p>
+</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',
+ }
-
-parser = argparse.ArgumentParser()
-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()
-
-config = read_config(path=args.config)
+_PAGES['text'] = Page()
+_PAGES['html'] = HtmlPage(
+ header=header_template.format(**context),
+ footer=footer_template.format(**context),
+ message_url_template=config['meta'].get(
+ 'message-url', 'http://mid.gmane.org/{message-id}'),
+ )
if args.list_views:
for view in config['views']:
elif args.get_query != None:
for view in config['views']:
if args.get_query == view['title']:
- print(' and '.join(view['query']))
+ print(' and '.join('( {} )'.format(q) for q in view['query']))
sys.exit(0)
else:
# only import notmuch if needed