]> git.cworth.org Git - obsolete/notmuch-to-html/blob - notmuch-to-html
498921565379514b59e18ce83d532e8dd0d895d6
[obsolete/notmuch-to-html] / notmuch-to-html
1 #!/usr/bin/python
2 #
3 # Generate an HTML page with the result of one or more notmuch
4 # searches, (with links to gmane views of each email if available).
5 #
6 # Copyright (c) 2011-2012 David Bremner <david@tethera.net>
7 #
8 # dependencies
9 #       - python 2.6 for json
10 #       - argparse; either python 2.7, or install separately
11 #
12 # This program is free software: you can redistribute it and/or modify
13 # it under the terms of the GNU General Public License as published by
14 # the Free Software Foundation, either version 3 of the License, or
15 # (at your option) any later version.
16 #
17 # This program is distributed in the hope that it will be useful,
18 # but WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 # GNU General Public License for more details.
21 #
22 # You should have received a copy of the GNU General Public License
23 # along with this program.  If not, see http://www.gnu.org/licenses/ .
24
25 from __future__ import print_function
26 from __future__ import unicode_literals
27
28 import codecs
29 import collections
30 import datetime
31 import email.utils
32 try:  # Python 3
33     from urllib.parse import quote
34 except ImportError:  # Python 2
35     from urllib import quote
36 import json
37 import argparse
38 import os
39 import re
40 import sys
41 import subprocess
42 import xml.sax.saxutils
43
44
45 _ENCODING = 'UTF-8'
46 _PAGES = {}
47
48 DEFAULT_CONFIG='''
49 {{
50     "meta": {{
51         "title": "Page title",
52         "blurb": "Page description"
53     }},
54
55     "views": [
56         {{
57             "title": "View title",
58             "comment": "View description",
59             "query": [ "{query}" ]
60         }}
61     ]
62 }}'''
63
64
65 if not hasattr(collections, 'OrderedDict'):  # Python 2.6 or earlier
66     class _OrderedDict (dict):
67         "Just enough of a stub to get through Page._get_threads"
68         def __init__(self, *args, **kwargs):
69             super(_OrderedDict, self).__init__(*args, **kwargs)
70             self._keys = []  # record key order
71
72         def __setitem__(self, key, value):
73             super(_OrderedDict, self).__setitem__(key, value)
74             self._keys.append(key)
75
76         def values(self):
77             for key in self._keys:
78                 yield self[key]
79
80
81     collections.OrderedDict = _OrderedDict
82
83
84 def read_config(path, encoding=None):
85     "Read config from json file"
86     if not encoding:
87         encoding = _ENCODING
88     fp = open(path)
89     return json.load(fp)
90
91
92 class Thread (list):
93     def __init__(self):
94         self.running_data = {}
95
96
97 class Page (object):
98     def __init__(self, header=None, footer=None):
99         self.header = header
100         self.footer = footer
101
102     def write(self, database, views, stream=None):
103         if not stream:
104             try:  # Python 3
105                 byte_stream = sys.stdout.buffer
106             except AttributeError:  # Python 2
107                 byte_stream = sys.stdout
108             stream = codecs.getwriter(encoding=_ENCODING)(stream=byte_stream)
109         self._write_header(views=views, stream=stream)
110         for view in views:
111             self._write_view(database=database, view=view, stream=stream)
112         self._write_footer(views=views, stream=stream)
113
114     def _write_header(self, views, stream):
115         if self.header:
116             stream.write(self.header)
117
118     def _write_footer(self, views, stream):
119         if self.footer:
120             stream.write(self.footer)
121
122     def _write_view(self, database, view, stream):
123         if 'query-string' not in view:
124             query = view['query']
125             view['query-string'] = ' and '.join(query)
126         q = notmuch.Query(database, view['query-string'])
127         q.set_sort(notmuch.Query.SORT.OLDEST_FIRST)
128         threads = self._get_threads(messages=q.search_messages())
129         self._write_view_header(view=view, stream=stream)
130         self._write_threads(threads=threads, stream=stream)
131
132     def _get_threads(self, messages):
133         threads = collections.OrderedDict()
134         for message in messages:
135             thread_id = message.get_thread_id()
136             if thread_id in threads:
137                 thread = threads[thread_id]
138             else:
139                 thread = Thread()
140                 threads[thread_id] = thread
141             thread.running_data, display_data = self._message_display_data(
142                 running_data=thread.running_data, message=message)
143             thread.append(display_data)
144         return list(threads.values())
145
146     def _write_view_header(self, view, stream):
147         pass
148
149     def _write_threads(self, threads, stream):
150         for thread in threads:
151             for message_display_data in thread:
152                 stream.write(
153                     ('{date:10.10s} {from:20.20s} {subject:40.40s}\n'
154                      '{message-id-term:>72}\n'
155                      ).format(**message_display_data))
156             if thread != threads[-1]:
157                 stream.write('\n')
158
159     def _message_display_data(self, running_data, message):
160         headers = ('thread-id', 'message-id', 'date', 'from', 'subject')
161         data = {}
162         for header in headers:
163             if header == 'thread-id':
164                 value = message.get_thread_id()
165             elif header == 'message-id':
166                 value = message.get_message_id()
167                 data['message-id-term'] = 'id:"{0}"'.format(value)
168             elif header == 'date':
169                 value = str(datetime.datetime.utcfromtimestamp(
170                     message.get_date()).date())
171             else:
172                 value = message.get_header(header)
173             if header == 'from':
174                 (value, addr) = email.utils.parseaddr(value)
175                 if not value:
176                     value = addr.split('@')[0]
177             data[header] = value
178         next_running_data = data.copy()
179         for header, value in data.items():
180             if header in ['message-id', 'subject']:
181                 continue
182             if value == running_data.get(header, None):
183                 data[header] = ''
184         return (next_running_data, data)
185
186
187 class HtmlPage (Page):
188     _slug_regexp = re.compile('\W+')
189
190     def _write_header(self, views, stream):
191         super(HtmlPage, self)._write_header(views=views, stream=stream)
192         stream.write('<ul>\n')
193         for view in views:
194             if 'id' not in view:
195                 view['id'] = self._slug(view['title'])
196             stream.write(
197                 '<li><a href="#{id}">{title}</a></li>\n'.format(**view))
198         stream.write('</ul>\n')
199
200     def _write_view_header(self, view, stream):
201         stream.write('<h3 id="{id}">{title}</h3>\n'.format(**view))
202         stream.write('<p>\n')
203         if 'comment' in view:
204             stream.write(view['comment'])
205             stream.write('\n')
206         for line in [
207                 '<p>This view is generated from the following query:',
208                 '</p>',
209                 '<p>',
210                 '  <code>',
211                 'notmuch search ' + view['query-string'],
212                 '  </code>',
213                 '</p>',
214                 ]:
215             stream.write(line)
216             stream.write('\n')
217
218     def _write_threads(self, threads, stream):
219         if not threads:
220             return
221         stream.write('<table>\n')
222         for thread in threads:
223             stream.write('  <tbody>\n')
224             for message_display_data in thread:
225                 stream.write((
226                     '    <tr class="message-first">\n'
227                     '      <td>{from}</td>\n'
228                     '      <td>{date}</td>\n'
229                     '      <td>{subject}</td>\n'
230                     '    </tr>\n'
231                     ).format(**message_display_data))
232             stream.write('  </tbody>\n')
233             if thread != threads[-1]:
234                 stream.write(
235                     '  <tbody><tr><td colspan="2"><br /></td></tr></tbody>\n')
236         stream.write('</table>\n')
237
238     def _message_display_data(self, *args, **kwargs):
239         running_data, display_data = super(
240             HtmlPage, self)._message_display_data(
241                 *args, **kwargs)
242         if 'subject' in display_data and 'message-id' in display_data:
243             d = {
244                 'message-id': quote(display_data['message-id']),
245                 'subject': xml.sax.saxutils.escape(display_data['subject']),
246                 }
247             display_data['subject'] = (
248                 '<a href="http://mid.gmane.org/{message-id}">{subject}</a>'
249                 ).format(**d)
250         for key in ['message-id', 'from']:
251             if key in display_data:
252                 display_data[key] = xml.sax.saxutils.escape(display_data[key])
253         return (running_data, display_data)
254
255     def _slug(self, string):
256         return self._slug_regexp.sub('-', string)
257
258 parser = argparse.ArgumentParser()
259 parser.add_argument('--text', help='output plain text format',
260                     action='store_true')
261 group = parser.add_mutually_exclusive_group()
262 group.add_argument('--config', help='path to configuration file',
263                    metavar='PATH')
264 group.add_argument('--query', help='path to configuration file',
265                    metavar='PATH')
266 parser.add_argument('--list-views', help='list views',
267                     action='store_true')
268
269 args = parser.parse_args()
270
271 if (args.config):
272     config = read_config(path=args.config)
273 elif (args.query):
274     config = json.loads(DEFAULT_CONFIG.format(query=args.query))
275 else:
276     print ('''To use notmuch-to-html, you need to provide a notmuch query. Try:
277
278         notmuch-to-html --query=tag:inbox
279
280 Or 'notmuch-to-html --help' for additional options.''')
281     exit (0)
282
283 _PAGES['text'] = Page()
284 _PAGES['html'] = HtmlPage(
285     header='''<!DOCTYPE html>
286 <html lang="en">
287 <head>
288   <meta http-equiv="Content-Type" content="text/html; charset={encoding}" />
289   <title>{title}</title>
290   <style media="screen" type="text/css">
291     table {{
292       border-spacing: 0;
293     }}
294     tr.message-first td {{
295       padding-top: {inter_message_padding};
296     }}
297     tr.message-last td {{
298       padding-bottom: {inter_message_padding};
299     }}
300     td {{
301       padding-left: {border_radius};
302       padding-right: {border_radius};
303     }}
304     tr:first-child td:first-child {{
305       border-top-left-radius: {border_radius};
306     }}
307     tr:first-child td:last-child {{
308       border-top-right-radius: {border_radius};
309     }}
310     tr:last-child td:first-child {{
311       border-bottom-left-radius: {border_radius};
312     }}
313     tr:last-child td:last-child {{
314       border-bottom-right-radius: {border_radius};
315     }}
316     tbody:nth-child(4n+1) tr td {{
317       background-color: #ffd96e;
318     }}
319     tbody:nth-child(4n+3) tr td {{
320       background-color: #bce;
321     }}
322   </style>
323 </head>
324 <body>
325 <h2>{title}</h2>
326 {blurb}
327 </p>
328 <h3>Views</h3>
329 '''.format(title=config['meta']['title'],
330            blurb=config['meta']['blurb'],
331            encoding=_ENCODING,
332            inter_message_padding='0.25em',
333            border_radius='0.5em'),
334     footer='''<hr>
335 <p>Generated: {date} courtesy of <a href="http://notmuchmail.org">notmuch</a> and <a href="http://git.cworth.org/git/notmuch-to-html">notmuch-to-html</a>.
336 </body>
337 </html>
338 '''.format(date=datetime.datetime.utcnow().date())
339     )
340
341 if args.list_views:
342     for view in config['views']:
343         print(view['title'])
344     sys.exit(0)
345 else:
346     # only import notmuch if needed
347     import notmuch
348
349 if args.text:
350     page = _PAGES['text']
351 else:
352     page = _PAGES['html']
353
354 db = notmuch.Database(mode=notmuch.Database.MODE.READ_ONLY)
355 page.write(database=db, views=config['views'])
356
357 if (args.query):
358     print ('''To customize the output use 'notmuch-to-html --config=CONFIG_FILE' after
359 placing the following content into CONFIG_FILE (note that you can add
360 additional views with their own queries):
361
362
363 ''', file=sys.stderr)
364     print (json.dumps(config, indent=4, separators=(',',':')), file=sys.stderr)