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