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