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