Merged be.restructure, major internal reorganization.
[be.git] / libbe / command / html.py
1 # Copyright (C) 2009 Gianluca Montecchi <gian@grys.it>
2 #                    W. Trevor King <wking@drexel.edu>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along
15 # with this program; if not, write to the Free Software Foundation, Inc.,
16 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18 import codecs
19 import htmlentitydefs
20 import os
21 import os.path
22 import re
23 import string
24 import time
25 import xml.sax.saxutils
26
27 import libbe
28 import libbe.command
29 import libbe.command.util
30 import libbe.util.encoding
31
32
33 class HTML (libbe.command.Command):
34     """Generate a static HTML dump of the current repository status
35
36     >>> import sys
37     >>> import libbe.bugdir
38     >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
39     >>> io = libbe.command.StringInputOutput()
40     >>> io.stdout = sys.stdout
41     >>> ui = libbe.command.UserInterface(io=io)
42     >>> ui.storage_callbacks.set_storage(bd.storage)
43     >>> cmd = HTML(ui=ui)
44
45     >>> ret = ui.run(cmd, {'output':os.path.join(bd.storage.repo, 'html_export')})
46     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export'))
47     True
48     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index.html'))
49     True
50     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index_inactive.html'))
51     True
52     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs'))
53     True
54     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'a.html'))
55     True
56     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b.html'))
57     True
58     >>> ui.cleanup()
59     >>> bd.cleanup()
60     """
61     name = 'html'
62
63     def __init__(self, *args, **kwargs):
64         libbe.command.Command.__init__(self, *args, **kwargs)
65         self.options.extend([
66                 libbe.command.Option(name='output', short_name='o',
67                     help='Set the output path (%default)',
68                     arg=libbe.command.Argument(
69                         name='output', metavar='DIR', default='./html_export',
70                         completion_callback=libbe.command.util.complete_path)),
71                 libbe.command.Option(name='template-dir', short_name='t',
72                     help='Use a different template.  Defaults to internal templates',
73                     arg=libbe.command.Argument(
74                         name='template-dir', metavar='DIR',
75                         completion_callback=libbe.command.util.complete_path)),
76                 libbe.command.Option(name='title',
77                     help='Set the bug repository title (%default)',
78                     arg=libbe.command.Argument(
79                         name='title', metavar='STRING',
80                         default='BugsEverywhere Issue Tracker')),
81                 libbe.command.Option(name='index-header',
82                     help='Set the index page headers (%default)',
83                     arg=libbe.command.Argument(
84                         name='index-header', metavar='STRING',
85                         default='BugsEverywhere Bug List')),
86                 libbe.command.Option(name='export-template', short_name='e',
87                     help='Export the default template and exit.'),
88                 libbe.command.Option(name='export-template-dir', short_name='d',
89                     help='Set the directory for the template export (%default)',
90                     arg=libbe.command.Argument(
91                         name='export-template-dir', metavar='DIR',
92                         default='./default-templates/',
93                         completion_callback=libbe.command.util.complete_path)),
94                 libbe.command.Option(name='verbose', short_name='v',
95                     help='Verbose output, default is %default'),
96                 ])
97
98     def _run(self, **params):
99         if params['export-template'] == True:
100             html_gen.write_default_template(params['export-template-dir'])
101             return 0
102         bugdir = self._get_bugdir()
103         bugdir.load_all_bugs()
104         html_gen = HTMLGen(bugdir,
105                            template=params['template-dir'],
106                            title=params['title'],
107                            index_header=params['index-header'],
108                            verbose=params['verbose'],
109                            stdout=self.stdout)
110         html_gen.run(params['output'])
111         return 0
112
113     def _long_help(self):
114         return """
115 Generate a set of html pages representing the current state of the bug
116 directory.
117 """
118
119 Html = HTML # alias for libbe.command.base.get_command_class()
120
121 class HTMLGen (object):
122     def __init__(self, bd, template=None,
123                  title="Site Title", index_header="Index Header",
124                  verbose=False, encoding=None, stdout=None,
125                  ):
126         self.generation_time = time.ctime()
127         self.bd = bd
128         if template == None:
129             self.template = "default"
130         else:
131             self.template = os.path.abspath(os.path.expanduser(template))
132         self.title = title
133         self.index_header = index_header
134         self.verbose = verbose
135         self.stdout = stdout
136         if encoding != None:
137             self.encoding = encoding
138         else:
139             self.encoding = libbe.util.encoding.get_filesystem_encoding()
140         self._load_default_templates()
141         if template != None:
142             self._load_user_templates()
143
144     def run(self, out_dir):
145         if self.verbose == True:
146             print >> self.stdout, \
147                 'Creating the html output in %s using templates in %s' \
148                 % (out_dir, self.template)
149
150         bugs_active = []
151         bugs_inactive = []
152         bugs = [b for b in self.bd]
153         bugs.sort()
154         bugs_active = [b for b in bugs if b.active == True]
155         bugs_inactive = [b for b in bugs if b.active != True]
156
157         self._create_output_directories(out_dir)
158         self._write_css_file()
159         for b in bugs:
160             if b.active:
161                 up_link = '../index.html'
162             else:
163                 up_link = '../index_inactive.html'
164             self._write_bug_file(b, up_link)
165         self._write_index_file(
166             bugs_active, title=self.title,
167             index_header=self.index_header, bug_type='active')
168         self._write_index_file(
169             bugs_inactive, title=self.title,
170             index_header=self.index_header, bug_type='inactive')
171
172     def _create_output_directories(self, out_dir):
173         if self.verbose:
174             print >> self.stdout, 'Creating output directories'
175         self.out_dir = self._make_dir(out_dir)
176         self.out_dir_bugs = self._make_dir(
177             os.path.join(self.out_dir, 'bugs'))
178
179     def _write_css_file(self):
180         if self.verbose:
181             print >> self.stdout, 'Writing css file'
182         assert hasattr(self, 'out_dir'), \
183             'Must run after ._create_output_directories()'
184         self._write_file(self.css_file,
185                          [self.out_dir,'style.css'])
186
187     def _write_bug_file(self, bug, up_link):
188         if self.verbose:
189             print >> self.stdout, '\tCreating bug file for %s' % bug.id.user()
190         assert hasattr(self, 'out_dir_bugs'), \
191             'Must run after ._create_output_directories()'
192
193         bug.load_comments(load_full=True)
194         comment_entries = self._generate_bug_comment_entries(bug)
195         filename = '%s.html' % bug.uuid
196         fullpath = os.path.join(self.out_dir_bugs, filename)
197         template_info = {'title':self.title,
198                          'charset':self.encoding,
199                          'up_link':up_link,
200                          'shortname':bug.id.user(),
201                          'comment_entries':comment_entries,
202                          'generation_time':self.generation_time}
203         for attr in ['uuid', 'severity', 'status', 'assigned',
204                      'reporter', 'creator', 'time_string', 'summary']:
205             template_info[attr] = self._escape(getattr(bug, attr))
206         self._write_file(self.bug_file % template_info, [fullpath])
207
208     def _generate_bug_comment_entries(self, bug):
209         assert hasattr(self, 'out_dir_bugs'), \
210             'Must run after ._create_output_directories()'
211
212         stack = []
213         comment_entries = []
214         for depth,comment in bug.comment_root.thread(flatten=False):
215             while len(stack) > depth:
216                 # pop non-parents off the stack
217                 stack.pop(-1)
218                 # close non-parent <div class="comment...
219                 comment_entries.append('</div>\n')
220             assert len(stack) == depth
221             stack.append(comment)
222             if depth == 0:
223                 comment_entries.append('<div class="comment root">')
224             else:
225                 comment_entries.append('<div class="comment">')
226             template_info = {}
227             for attr in ['uuid', 'author', 'date', 'body']:
228                 value = getattr(comment, attr)
229                 if attr == 'body':
230                     save_body = False
231                     if comment.content_type == 'text/html':
232                         pass # no need to escape html...
233                     elif comment.content_type.startswith('text/'):
234                         value = '<pre>\n'+self._escape(value)+'\n</pre>'
235                     elif comment.content_type.startswith('image/'):
236                         save_body = True
237                         value = '<img src="./%s/%s" />' \
238                             % (bug.uuid, comment.uuid)
239                     else:
240                         save_body = True
241                         value = '<a href="./%s/%s">Link to %s file</a>.' \
242                             % (bug.uuid, comment.uuid, comment.content_type)
243                     if save_body == True:
244                         per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
245                         if not os.path.exists(per_bug_dir):
246                             os.mkdir(per_bug_dir)
247                         comment_path = os.path.join(per_bug_dir, comment.uuid)
248                         self._write_file(
249                             '<Files %s>\n  ForceType %s\n</Files>' \
250                                 % (comment.uuid, comment.content_type),
251                             [per_bug_dir, '.htaccess'], mode='a')
252                         self._write_file( # TODO: long_to_linked_user()
253                             libbe.util.id.long_to_short_text(
254                                 [self.bd], comment.body),
255                             [per_bug_dir, comment.uuid], mode='wb')
256                 else:
257                     value = self._escape(value)
258                 template_info[attr] = value
259             comment_entries.append(self.bug_comment_entry % template_info)
260         while len(stack) > 0:
261             stack.pop(-1)
262             comment_entries.append('</div>\n') # close every remaining <div class='comment...
263         return '\n'.join(comment_entries)
264
265     def _write_index_file(self, bugs, title, index_header, bug_type='active'):
266         if self.verbose:
267             print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs))
268         assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()'
269         esc = self._escape
270
271         bug_entries = self._generate_index_bug_entries(bugs)
272
273         if bug_type == 'active':
274             filename = 'index.html'
275         elif bug_type == 'inactive':
276             filename = 'index_inactive.html'
277         else:
278             raise Exception, 'Unrecognized bug_type: "%s"' % bug_type
279         template_info = {'title':title,
280                          'index_header':index_header,
281                          'charset':self.encoding,
282                          'active_class':'tab sel',
283                          'inactive_class':'tab nsel',
284                          'bug_entries':bug_entries,
285                          'generation_time':self.generation_time}
286         if bug_type == 'inactive':
287             template_info['active_class'] = 'tab nsel'
288             template_info['inactive_class'] = 'tab sel'
289
290         self._write_file(self.index_file % template_info,
291                          [self.out_dir, filename])
292
293     def _generate_index_bug_entries(self, bugs):
294         bug_entries = []
295         for bug in bugs:
296             if self.verbose:
297                 print >> self.stdout, '\tCreating bug entry for %s' % bug.id.user()
298             template_info = {'shortname':bug.id.user()}
299             for attr in ['uuid', 'severity', 'status', 'assigned',
300                          'reporter', 'creator', 'time_string', 'summary']:
301                 template_info[attr] = self._escape(getattr(bug, attr))
302             bug_entries.append(self.index_bug_entry % template_info)
303         return '\n'.join(bug_entries)
304
305     def _escape(self, string):
306         if string == None:
307             return ''
308         chars = []
309         for char in string:
310             codepoint = ord(char)
311             if codepoint in htmlentitydefs.codepoint2name:
312                 char = '&%s;' % htmlentitydefs.codepoint2name[codepoint]
313             #else: xml.sax.saxutils.escape(char)
314             chars.append(char)
315         return ''.join(chars)
316
317     def _load_user_templates(self):
318         for filename,attr in [('style.css','css_file'),
319                               ('index_file.tpl','index_file'),
320                               ('index_bug_entry.tpl','index_bug_entry'),
321                               ('bug_file.tpl','bug_file'),
322                               ('bug_comment_entry.tpl','bug_comment_entry')]:
323             fullpath = os.path.join(self.template, filename)
324             if os.path.exists(fullpath):
325                 setattr(self, attr, self._read_file([fullpath]))
326
327     def _make_dir(self, dir_path):
328         dir_path = os.path.abspath(os.path.expanduser(dir_path))
329         if not os.path.exists(dir_path):
330             try:
331                 os.makedirs(dir_path)
332             except:
333                 raise libbe.command.UserError(
334                     'Cannot create output directory "%s".' % dir_path)
335         return dir_path
336
337     def _write_file(self, content, path_array, mode='w'):
338         return libbe.util.encoding.set_file_contents(
339             os.path.join(*path_array), content, mode, self.encoding)
340
341     def _read_file(self, path_array, mode='r'):
342         return libbe.util.encoding.get_file_contents(
343             os.path.join(*path_array), mode, self.encoding, decode=True)
344
345     def write_default_template(self, out_dir):
346         if self.verbose:
347             print >> self.stdout, 'Creating output directories'
348         self.out_dir = self._make_dir(out_dir)
349         if self.verbose:
350             print >> self.stdout, 'Creating css file'
351         self._write_css_file()
352         if self.verbose:
353             print >> self.stdout, 'Creating index_file.tpl file'
354         self._write_file(self.index_file,
355                          [self.out_dir, 'index_file.tpl'])
356         if self.verbose:
357             print >> self.stdout, 'Creating index_bug_entry.tpl file'
358         self._write_file(self.index_bug_entry,
359                          [self.out_dir, 'index_bug_entry.tpl'])
360         if self.verbose:
361             print >> self.stdout, 'Creating bug_file.tpl file'
362         self._write_file(self.bug_file,
363                          [self.out_dir, 'bug_file.tpl'])
364         if self.verbose:
365             print >> self.stdout, 'Creating bug_comment_entry.tpl file'
366         self._write_file(self.bug_comment_entry,
367                          [self.out_dir, 'bug_comment_entry.tpl'])
368
369     def _load_default_templates(self):
370         self.css_file = """
371             body {
372               font-family: "lucida grande", "sans serif";
373               color: #333;
374               width: auto;
375               margin: auto;
376             }
377
378             div.main {
379               padding: 20px;
380               margin: auto;
381               padding-top: 0;
382               margin-top: 1em;
383               background-color: #fcfcfc;
384             }
385
386             div.footer {
387               font-size: small;
388               padding-left: 20px;
389               padding-right: 20px;
390               padding-top: 5px;
391               padding-bottom: 5px;
392               margin: auto;
393               background: #305275;
394               color: #fffee7;
395             }
396
397             table {
398               border-style: solid;
399               border: 10px #313131;
400               border-spacing: 0;
401               width: auto;
402             }
403
404             tb { border: 1px; }
405
406             tr {
407               vertical-align: top;
408               width: auto;
409             }
410
411             td {
412               border-width: 0;
413               border-style: none;
414               padding-right: 0.5em;
415               padding-left: 0.5em;
416               width: auto;
417             }
418
419             img { border-style: none; }
420
421             h1 {
422               padding: 0.5em;
423               background-color: #305275;
424               margin-top: 0;
425               margin-bottom: 0;
426               color: #fff;
427               margin-left: -20px;
428               margin-right: -20px;
429             }
430
431             ul {
432               list-style-type: none;
433               padding: 0;
434             }
435
436             p { width: auto; }
437
438             a, a:visited {
439               background: inherit;
440               text-decoration: none;
441             }
442
443             a { color: #003d41; }
444             a:visited { color: #553d41; }
445             .footer a { color: #508d91; }
446
447             /* bug index pages */
448
449             td.tab {
450               padding-right: 1em;
451               padding-left: 1em;
452             }
453
454             td.sel.tab {
455               background-color: #afafaf;
456               border: 1px solid #afafaf;
457               font-weight:bold;
458             }
459
460             td.nsel.tab { border: 0px; }
461
462             table.bug_list {
463               background-color: #afafaf;
464               border: 2px solid #afafaf;
465             }
466
467             .bug_list tr { width: auto; }
468             tr.wishlist { background-color: #B4FF9B; }
469             tr.minor { background-color: #FCFF98; }
470             tr.serious { background-color: #FFB648; }
471             tr.critical { background-color: #FF752A; }
472             tr.fatal { background-color: #FF3300; }
473
474             /* bug detail pages */
475
476             td.bug_detail_label { text-align: right; }
477             td.bug_detail { }
478             td.bug_comment_label { text-align: right; vertical-align: top; }
479             td.bug_comment { }
480
481             div.comment {
482               padding: 20px;
483               padding-top: 20px;
484               margin: auto;
485               margin-top: 0;
486             }
487
488             div.root.comment {
489               padding: 0px;
490               /* padding-top: 0px; */
491               padding-bottom: 20px;
492             }
493        """
494
495         self.index_file = """
496             <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
497               "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
498             <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
499             <head>
500             <title>%(title)s</title>
501             <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
502             <link rel="stylesheet" href="style.css" type="text/css" />
503             </head>
504             <body>
505
506             <div class="main">
507             <h1>%(index_header)s</h1>
508             <p></p>
509             <table>
510
511             <tr>
512             <td class="%(active_class)s"><a href="index.html">Active Bugs</a></td>
513             <td class="%(inactive_class)s"><a href="index_inactive.html">Inactive Bugs</a></td>
514             </tr>
515
516             </table>
517             <table class="bug_list">
518             <tbody>
519
520             %(bug_entries)s
521
522             </tbody>
523             </table>
524             </div>
525
526             <div class="footer">
527             <p>Generated by <a href="http://www.bugseverywhere.org/">
528             BugsEverywhere</a> on %(generation_time)s</p>
529             <p>
530             <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a>&nbsp;|&nbsp;
531             <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
532             </p>
533             </div>
534
535             </body>
536             </html>
537         """
538
539         self.index_bug_entry ="""
540             <tr class="%(severity)s">
541               <td><a href="bugs/%(uuid)s.html">%(shortname)s</a></td>
542               <td><a href="bugs/%(uuid)s.html">%(status)s</a></td>
543               <td><a href="bugs/%(uuid)s.html">%(severity)s</a></td>
544               <td><a href="bugs/%(uuid)s.html">%(summary)s</a></td>
545               <td><a href="bugs/%(uuid)s.html">%(time_string)s</a></td>
546             </tr>
547         """
548
549         self.bug_file = """
550             <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
551               "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
552             <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
553             <head>
554             <title>%(title)s</title>
555             <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
556             <link rel="stylesheet" href="../style.css" type="text/css" />
557             </head>
558             <body>
559
560             <div class="main">
561             <h1>BugsEverywhere Bug List</h1>
562             <h5><a href="%(up_link)s">Back to Index</a></h5>
563             <h2>Bug: %(shortname)s</h2>
564             <table>
565             <tbody>
566
567             <tr><td class="bug_detail_label">ID :</td>
568                 <td class="bug_detail">%(uuid)s</td></tr>
569             <tr><td class="bug_detail_label">Short name :</td>
570                 <td class="bug_detail">%(shortname)s</td></tr>
571             <tr><td class="bug_detail_label">Status :</td>
572                 <td class="bug_detail">%(status)s</td></tr>
573             <tr><td class="bug_detail_label">Severity :</td>
574                 <td class="bug_detail">%(severity)s</td></tr>
575             <tr><td class="bug_detail_label">Assigned :</td>
576                 <td class="bug_detail">%(assigned)s</td></tr>
577             <tr><td class="bug_detail_label">Reporter :</td>
578                 <td class="bug_detail">%(reporter)s</td></tr>
579             <tr><td class="bug_detail_label">Creator :</td>
580                 <td class="bug_detail">%(creator)s</td></tr>
581             <tr><td class="bug_detail_label">Created :</td>
582                 <td class="bug_detail">%(time_string)s</td></tr>
583             <tr><td class="bug_detail_label">Summary :</td>
584                 <td class="bug_detail">%(summary)s</td></tr>
585             </tbody>
586             </table>
587
588             <hr/>
589
590             %(comment_entries)s
591
592             </div>
593             <h5><a href="%(up_link)s">Back to Index</a></h5>
594
595             <div class="footer">
596             <p>Generated by <a href="http://www.bugseverywhere.org/">
597             BugsEverywhere</a> on %(generation_time)s</p>
598             <p>
599             <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a>&nbsp;|&nbsp;
600             <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
601             </p>
602             </div>
603
604             </body>
605             </html>
606         """
607
608         self.bug_comment_entry ="""
609             <table>
610             <tr>
611               <td class="bug_comment_label">Comment:</td>
612               <td class="bug_comment">
613             --------- Comment ---------<br/>
614             Name: %(uuid)s<br/>
615             From: %(author)s<br/>
616             Date: %(date)s<br/>
617             <br/>
618             %(body)s
619               </td>
620             </tr>
621             </table>
622         """
623
624         # strip leading whitespace
625         for attr in ['css_file', 'index_file', 'index_bug_entry', 'bug_file',
626                      'bug_comment_entry']:
627             value = getattr(self, attr)
628             value = value.replace('\n'+' '*12, '\n')
629             setattr(self, attr, value.strip()+'\n')