1 # Copyright (C) 2009-2011 Gianluca Montecchi <gian@grys.it>
2 # Mathieu Clabaut <mathieu.clabaut@gmail.com>
3 # W. Trevor King <wking@drexel.edu>
5 # This file is part of Bugs Everywhere.
7 # Bugs Everywhere is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation, either version 2 of the License, or (at your
10 # option) any later version.
12 # Bugs Everywhere is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 # General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Bugs Everywhere. If not, see <http://www.gnu.org/licenses/>.
27 import xml.sax.saxutils
31 import libbe.command.util
33 import libbe.util.encoding
35 import libbe.command.depend
38 class HTML (libbe.command.Command):
39 """Generate a static HTML dump of the current repository status
42 >>> import libbe.bugdir
43 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
44 >>> io = libbe.command.StringInputOutput()
45 >>> io.stdout = sys.stdout
46 >>> ui = libbe.command.UserInterface(io=io)
47 >>> ui.storage_callbacks.set_storage(bd.storage)
50 >>> ret = ui.run(cmd, {'output':os.path.join(bd.storage.repo, 'html_export')})
51 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export'))
53 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index.html'))
55 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index_inactive.html'))
57 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs'))
59 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'a', 'index.html'))
61 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b', 'index.html'))
68 def __init__(self, *args, **kwargs):
69 libbe.command.Command.__init__(self, *args, **kwargs)
71 libbe.command.Option(name='output', short_name='o',
72 help='Set the output path (%default)',
73 arg=libbe.command.Argument(
74 name='output', metavar='DIR', default='./html_export',
75 completion_callback=libbe.command.util.complete_path)),
76 libbe.command.Option(name='template-dir', short_name='t',
77 help='Use a different template. Defaults to internal templates',
78 arg=libbe.command.Argument(
79 name='template-dir', metavar='DIR',
80 completion_callback=libbe.command.util.complete_path)),
81 libbe.command.Option(name='title',
82 help='Set the bug repository title (%default)',
83 arg=libbe.command.Argument(
84 name='title', metavar='STRING',
85 default='BugsEverywhere Issue Tracker')),
86 libbe.command.Option(name='index-header',
87 help='Set the index page headers (%default)',
88 arg=libbe.command.Argument(
89 name='index-header', metavar='STRING',
90 default='BugsEverywhere Bug List')),
91 libbe.command.Option(name='export-template', short_name='e',
92 help='Export the default template and exit.'),
93 libbe.command.Option(name='export-template-dir', short_name='d',
94 help='Set the directory for the template export (%default)',
95 arg=libbe.command.Argument(
96 name='export-template-dir', metavar='DIR',
97 default='./default-templates/',
98 completion_callback=libbe.command.util.complete_path)),
99 libbe.command.Option(name='min-id-length', short_name='l',
100 help='Attempt to truncate bug and comment IDs to this length. Set to -1 for non-truncated IDs (%default)',
101 arg=libbe.command.Argument(
102 name='min-id-length', metavar='INT',
103 default=-1, type='int')),
104 libbe.command.Option(name='verbose', short_name='v',
105 help='Verbose output, default is %default'),
108 def _run(self, **params):
109 if params['export-template'] == True:
112 bugdir = self._get_bugdir()
113 bugdir.load_all_bugs()
114 html_gen = HTMLGen(bugdir,
115 template=params['template-dir'],
116 title=params['title'],
117 index_header=params['index-header'],
118 min_id_length=params['min-id-length'],
119 verbose=params['verbose'],
121 if params['export-template'] == True:
122 html_gen.write_default_template(params['export-template-dir'])
124 html_gen.run(params['output'])
126 def _long_help(self):
128 Generate a set of html pages representing the current state of the bug
132 Html = HTML # alias for libbe.command.base.get_command_class()
134 class HTMLGen (object):
135 def __init__(self, bd, template=None,
136 title="Site Title", index_header="Index Header",
138 verbose=False, encoding=None, stdout=None,
140 self.generation_time = time.ctime()
143 self.template = "default"
145 self.template = os.path.abspath(os.path.expanduser(template))
147 self.index_header = index_header
148 self.verbose = verbose
151 self.encoding = encoding
153 self.encoding = libbe.util.encoding.get_filesystem_encoding()
154 self._load_default_templates()
156 self._load_user_templates()
157 self.min_id_length = min_id_length
159 def run(self, out_dir):
160 if self.verbose == True:
161 print >> self.stdout, \
162 'Creating the html output in %s using templates in %s' \
163 % (out_dir, self.template)
168 bugs = [b for b in self.bd]
172 if b.active == True and b.severity != 'target':
173 bugs_active.append(b)
174 if b.active != True and b.severity != 'target':
175 bugs_inactive.append(b)
176 if b.severity == 'target':
177 bugs_target.append(b)
178 # bugs_active = [b for b in bugs if b.active == True and b.severity != 'target']
179 # bugs_inactive = [b for b in bugs if b.active != True and b.severity != 'target']
180 # bugs_target = [b for b in bugs if b.active == True and b.severity == 'target']
182 self._create_output_directories(out_dir)
183 self._write_css_file()
186 up_link = '../../index.html'
188 up_link = '../../index_inactive.html'
190 self._write_bug_file(b, up_link)
191 self._write_index_file(
192 bugs_active, title=self.title,
193 index_header=self.index_header, bug_type='active')
194 self._write_index_file(
195 bugs_inactive, title=self.title,
196 index_header=self.index_header, bug_type='inactive')
197 self._write_index_file(
198 bugs_target, title=self.title,
199 index_header=self.index_header, bug_type='target')
201 def _truncated_bug_id(self, bug):
202 return libbe.util.id._truncate(
203 bug.uuid, bug.sibling_uuids(),
204 min_length=self.min_id_length)
206 def _truncated_comment_id(self, comment):
207 return libbe.util.id._truncate(
208 comment.uuid, comment.sibling_uuids(),
209 min_length=self.min_id_length)
211 def _create_output_directories(self, out_dir):
213 print >> self.stdout, 'Creating output directories'
214 self.out_dir = self._make_dir(out_dir)
215 self.out_dir_bugs = self._make_dir(
216 os.path.join(self.out_dir, 'bugs'))
218 def _write_css_file(self):
220 print >> self.stdout, 'Writing css file'
221 assert hasattr(self, 'out_dir'), \
222 'Must run after ._create_output_directories()'
223 self._write_file(self.css_file,
224 [self.out_dir,'style.css'])
226 def _write_bug_file(self, bug, up_link):
228 print >> self.stdout, '\tCreating bug file for %s' % bug.id.user()
229 assert hasattr(self, 'out_dir_bugs'), \
230 'Must run after ._create_output_directories()'
233 if bug.active == True:
234 index_type = 'Active'
236 index_type = 'Inactive'
237 if bug.severity == 'target':
238 index_type = 'Target'
240 bug.load_comments(load_full=True)
241 comment_entries = self._generate_bug_comment_entries(bug)
242 dirname = self._truncated_bug_id(bug)
243 fullpath = os.path.join(self.out_dir_bugs, dirname, 'index.html')
244 template_info = {'title':self.title,
245 'charset':self.encoding,
247 'shortname':bug.id.user(),
248 'comment_entries':comment_entries,
249 'generation_time':self.generation_time,
250 'index_type':index_type}
251 for attr in ['uuid', 'severity', 'status', 'assigned',
252 'reporter', 'creator', 'time_string', 'summary']:
253 template_info[attr] = self._escape(getattr(bug, attr))
254 fulldir = os.path.join(self.out_dir_bugs, dirname)
255 if not os.path.exists(fulldir):
257 self._write_file(self.bug_file % template_info, [fullpath])
259 def _generate_bug_comment_entries(self, bug):
260 assert hasattr(self, 'out_dir_bugs'), \
261 'Must run after ._create_output_directories()'
265 bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
266 for depth,comment in bug.comment_root.thread(flatten=False):
267 while len(stack) > depth:
268 # pop non-parents off the stack
270 # close non-parent <div class="comment...
271 comment_entries.append('</div>\n')
272 assert len(stack) == depth
273 stack.append(comment)
275 'shortname': comment.id.user(),
276 'truncated_id': self._truncated_comment_id(comment)}
278 comment_entries.append('<div class="comment root">')
280 comment_entries.append(
281 '<div class="comment" id="%s">'
282 % template_info['truncated_id'])
283 for attr in ['uuid', 'author', 'date', 'body']:
284 value = getattr(comment, attr)
286 link_long_ids = False
288 if comment.content_type == 'text/html':
290 elif comment.content_type.startswith('text/'):
291 value = '<pre>\n'+self._escape(value)+'\n</pre>'
293 elif comment.content_type.startswith('image/'):
295 value = '<img src="./%s/%s" />' \
296 % (self._truncated_bug_id(bug),
297 self._truncated_comment_id(comment))
300 value = '<a href="./%s/%s">Link to %s file</a>.' \
301 % (self._truncated_bug_id(bug),
302 self._truncated_comment_id(comment),
303 comment.content_type)
304 if link_long_ids == True:
305 value = self._long_to_linked_user(value)
306 if save_body == True:
307 per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
308 if not os.path.exists(per_bug_dir):
309 os.mkdir(per_bug_dir)
310 comment_path = os.path.join(per_bug_dir, comment.uuid)
312 '<Files %s>\n ForceType %s\n</Files>' \
313 % (comment.uuid, comment.content_type),
314 [per_bug_dir, '.htaccess'], mode='a')
315 self._write_file(comment.body,
316 [per_bug_dir, comment.uuid], mode='wb')
318 value = self._escape(value)
319 template_info[attr] = value
320 comment_entries.append(self.bug_comment_entry % template_info)
321 while len(stack) > 0:
323 comment_entries.append('</div>\n') # close every remaining <div class='comment...
324 return '\n'.join(comment_entries)
326 def _long_to_linked_user(self, text):
328 >>> import libbe.bugdir
329 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
331 >>> h._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.')
332 'A link <a href="./a/">abc/a</a>, and a non-link #x#y#.'
335 replacer = libbe.util.id.IDreplacer(
336 [self.bd], self._long_to_linked_user_replacer, wrap=False)
338 libbe.util.id.REGEXP, replacer, text)
340 def _long_to_linked_user_replacer(self, bugdirs, long_id):
342 >>> import libbe.bugdir
343 >>> import libbe.util.id
344 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
345 >>> a = bd.bug_from_uuid('a')
346 >>> uuid_gen = libbe.util.id.uuid_gen
347 >>> libbe.util.id.uuid_gen = lambda : '0123'
348 >>> c = a.new_comment('comment for link testing')
349 >>> libbe.util.id.uuid_gen = uuid_gen
353 >>> h._long_to_linked_user_replacer([bd], 'abc123')
355 >>> h._long_to_linked_user_replacer([bd], 'abc123/a')
356 '<a href="./a/">abc/a</a>'
357 >>> h._long_to_linked_user_replacer([bd], 'abc123/a/0123')
358 '<a href="./a/#0123">abc/a/012</a>'
359 >>> h._long_to_linked_user_replacer([bd], 'x')
361 >>> h._long_to_linked_user_replacer([bd], '')
366 p = libbe.util.id.parse_user(bugdirs[0], long_id)
367 except (libbe.util.id.MultipleIDMatches,
368 libbe.util.id.NoIDMatches,
369 libbe.util.id.InvalidIDStructure), e:
370 return '#%s#' % long_id # re-wrap failures
371 if p['type'] == 'bugdir':
372 return '#%s#' % long_id
373 elif p['type'] == 'bug':
374 bug,comment = libbe.command.util.bug_comment_from_user_id(
376 return '<a href="./%s/">%s</a>' \
377 % (self._truncated_bug_id(bug), bug.id.user())
378 elif p['type'] == 'comment':
379 bug,comment = libbe.command.util.bug_comment_from_user_id(
381 return '<a href="./%s/#%s">%s</a>' \
382 % (self._truncated_bug_id(bug),
383 self._truncated_comment_id(comment),
385 raise Exception('Invalid id type %s for "%s"'
386 % (p['type'], long_id))
388 def _write_index_file(self, bugs, title, index_header, bug_type='active'):
390 print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs))
391 assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()'
393 if (bug_type == 'target'):
394 bug_entries = self._generate_index_bug_entries_target(bugs)
396 bug_entries = self._generate_index_bug_entries(bugs)
398 if bug_type == 'active':
399 filename = 'index.html'
400 elif bug_type == 'inactive':
401 filename = 'index_inactive.html'
402 elif bug_type == 'target':
403 filename = 'index_by_target.html'
405 raise Exception, 'Unrecognized bug_type: "%s"' % bug_type
406 template_info = {'title':title,
407 'index_header':index_header,
408 'charset':self.encoding,
409 'active_class':'tab sel',
410 'inactive_class':'tab nsel',
411 'target_class':'tab nsel',
412 'bug_entries':bug_entries,
413 'generation_time':self.generation_time}
414 if bug_type == 'inactive':
415 template_info['active_class'] = 'tab nsel'
416 template_info['inactive_class'] = 'tab sel'
417 if bug_type == 'target':
418 template_info['active_class'] = 'tab nsel'
419 template_info['target_class'] = 'tab sel'
420 self._write_file(self.index_file % template_info,
421 [self.out_dir, filename])
423 def _generate_index_bug_entries_target(self, targets):
426 for target in targets:
428 template_info_list = {'target':target.summary, 'bug_entries': '', 'status': target.status}
429 blocker = libbe.command.depend.get_blocked_by(self.bd, target)
432 print >> self.stdout, '\tCreating bug entry for %s' % bug.id.user()
433 template_info = {'shortname':bug.id.user()}
434 for attr in ['uuid', 'severity', 'status', 'assigned',
435 'reporter', 'creator', 'time_string', 'summary']:
436 template_info[attr] = self._escape(getattr(bug, attr))
437 template_info['dir'] = self._truncated_bug_id(bug)
438 bug_entries.append(self.index_bug_entry % template_info)
439 template_info_list['bug_entries'] = '\n'.join(bug_entries)
440 target_entries.append(self.target_bug_list % template_info_list)
441 return '\n'.join(target_entries)
443 def _generate_index_bug_entries(self, bugs):
445 template_info_list = {'bug_entries': ''}
448 print >> self.stdout, '\tCreating bug entry for %s' % bug.id.user()
449 template_info = {'shortname':bug.id.user()}
450 for attr in ['uuid', 'severity', 'status', 'assigned',
451 'reporter', 'creator', 'time_string', 'summary']:
452 template_info[attr] = self._escape(getattr(bug, attr))
453 template_info['dir'] = self._truncated_bug_id(bug)
454 bug_entries.append(self.index_bug_entry % template_info)
455 template_info_list['bug_entries'] = '\n'.join(bug_entries)
456 return self.bug_list % template_info_list
458 def _escape(self, string):
461 return xml.sax.saxutils.escape(string)
463 def _load_user_templates(self):
464 for filename,attr in [('style.css','css_file'),
465 ('index_file.tpl','index_file'),
466 ('index_bug_entry.tpl','index_bug_entry'),
467 ('bug_file.tpl','bug_file'),
468 ('bug_comment_entry.tpl','bug_comment_entry')]:
469 fullpath = os.path.join(self.template, filename)
470 if os.path.exists(fullpath):
471 setattr(self, attr, self._read_file([fullpath]))
473 def _make_dir(self, dir_path):
474 dir_path = os.path.abspath(os.path.expanduser(dir_path))
475 if not os.path.exists(dir_path):
477 os.makedirs(dir_path)
479 raise libbe.command.UserError(
480 'Cannot create output directory "%s".' % dir_path)
483 def _write_file(self, content, path_array, mode='w'):
484 return libbe.util.encoding.set_file_contents(
485 os.path.join(*path_array), content, mode, self.encoding)
487 def _read_file(self, path_array, mode='r'):
488 return libbe.util.encoding.get_file_contents(
489 os.path.join(*path_array), mode, self.encoding, decode=True)
491 def write_default_template(self, out_dir):
493 print >> self.stdout, 'Creating output directories'
494 self.out_dir = self._make_dir(out_dir)
496 print >> self.stdout, 'Creating css file'
497 self._write_css_file()
499 print >> self.stdout, 'Creating index_file.tpl file'
500 self._write_file(self.index_file,
501 [self.out_dir, 'index_file.tpl'])
503 print >> self.stdout, 'Creating index_bug_entry.tpl file'
504 self._write_file(self.index_bug_entry,
505 [self.out_dir, 'index_bug_entry.tpl'])
507 print >> self.stdout, 'Creating bug_file.tpl file'
508 self._write_file(self.bug_file,
509 [self.out_dir, 'bug_file.tpl'])
511 print >> self.stdout, 'Creating bug_comment_entry.tpl file'
512 self._write_file(self.bug_comment_entry,
513 [self.out_dir, 'bug_comment_entry.tpl'])
515 def _load_default_templates(self):
518 font-family: "lucida grande", "sans serif";
530 background-color: #fcfcfc;
531 -moz-border-radius: 10px;
544 -moz-border-radius: 10px;
553 padding-bottom: 10px;
556 -moz-border-radius: 10px;
561 border-color: #305275;
562 background-color: #305275;
565 -moz-border-radius-topleft: 8px;
566 -moz-border-radius-topright: 8px;
574 border-spacing: 0px 0px;
585 border-style: dotted;
593 border-color: #c3d9ff;
594 border-collapse: collapse;
602 border-color: #c3d9ff;
603 border-collapse: collapse;
609 img { border-style: none; }
613 background-color: #305275;
622 list-style-type: none;
630 text-decoration: none;
633 a { color: #003d41; }
634 a:visited { color: #553d41; }
635 .footer a { color: #508d91; }
637 /* bug index pages */
645 background-color: #c3d9ff ;
646 border: 1px solid #c3d9ff;
648 -moz-border-radius-topleft: 15px;
649 -moz-border-radius-topright: 15px;
653 border: 1px solid #c3d9ff;
655 -moz-border-radius-topleft: 5px;
656 -moz-border-radius-topright: 5px;
662 border-color: #c3d9ff;
665 border: 1px solid #c3d9ff;
671 border-collapse: collapse;
672 border-color: #c3d9ff;
678 table.target_list.td {
682 tr.wishlist { background-color: #DCFAFF;}
683 tr.wishlist:hover { background-color: #C2DCE1; }
685 tr.minor { background-color: #FFFFA6; }
686 tr.minor:hover { background-color: #E6E696; }
688 tr.serious { background-color: #FF9077;}
689 tr.serious:hover { background-color: #E6826B; }
691 tr.critical { background-color: #FF752A; }
692 tr.critical:hover { background-color: #D63905;}
694 tr.fatal { background-color: #FF3300;}
695 tr.fatal:hover { background-color: #D60000;}
697 td.uuid { text-align: center; width: 5%; border-style: dotted;}
698 td.status { text-align: center; width: 5%; border-style: dotted;}
699 td.severity { text-align: center; width: 5%; border-style: dotted;}
700 td.summary { border-style: dotted;}
701 td.date { text-align: center; width: 25%; border-style: dotted;}
703 /* bug detail pages */
705 td.bug_detail_label { text-align: right; border: none;}
706 td.bug_detail { border: none;}
707 td.bug_comment_label { text-align: right; vertical-align: top; }
719 /* padding-top: 0px; */
720 padding-bottom: 20px;
724 self.index_file = """
725 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
726 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
727 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
729 <title>%(title)s</title>
730 <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
731 <link rel="stylesheet" href="style.css" type="text/css" />
735 <div class="header">%(index_header)s</div>
741 <td class="%(active_class)s"><a href="index.html">Active Bugs</a></td>
742 <td class="%(inactive_class)s"><a href="index_inactive.html">Inactive Bugs</a></td>
743 <td class="%(target_class)s"><a href="index_by_target.html">Divided by target</a></td>
753 <p>Generated by <a href="http://www.bugseverywhere.org/">
754 BugsEverywhere</a> on %(generation_time)s</p>
756 <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> |
757 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
765 self.index_bug_entry ="""
766 <tr class="%(severity)s">
767 <td class="uuid"><a href="bugs/%(dir)s/index.html">%(shortname)s</a></td>
768 <td class="status"><a href="bugs/%(dir)s/index.html">%(status)s</a></td>
769 <td class="severity"><a href="bugs/%(dir)s/index.html">%(severity)s</a></td>
770 <td class="summary"><a href="bugs/%(dir)s/index.html">%(summary)s</a></td>
771 <td class="date"><a href="bugs/%(dir)s/index.html">%(time_string)s</a></td>
774 self.target_bug_list = """
777 <div class="target_name">
778 Target: %(target)s (%(status)s)
781 <table class="target_list">
790 <table class="bug_list">
798 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
799 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
800 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
802 <title>%(title)s</title>
803 <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
804 <link rel="stylesheet" href="../../style.css" type="text/css" />
808 <div class="header">BugsEverywhere Bug List</div>
810 <h5><a href="%(up_link)s">Back to %(index_type)s Index</a></h5>
811 <h5><a href="../../index_by_target.html">Back to Target Index</a></h5>
812 <h2>Bug: %(shortname)s</h2>
816 <tr><td class="bug_detail_label">ID :</td>
817 <td class="bug_detail">%(uuid)s</td></tr>
818 <tr><td class="bug_detail_label">Short name :</td>
819 <td class="bug_detail">%(shortname)s</td></tr>
820 <tr><td class="bug_detail_label">Status :</td>
821 <td class="bug_detail">%(status)s</td></tr>
822 <tr><td class="bug_detail_label">Severity :</td>
823 <td class="bug_detail">%(severity)s</td></tr>
824 <tr><td class="bug_detail_label">Assigned :</td>
825 <td class="bug_detail">%(assigned)s</td></tr>
826 <tr><td class="bug_detail_label">Reporter :</td>
827 <td class="bug_detail">%(reporter)s</td></tr>
828 <tr><td class="bug_detail_label">Creator :</td>
829 <td class="bug_detail">%(creator)s</td></tr>
830 <tr><td class="bug_detail_label">Created :</td>
831 <td class="bug_detail">%(time_string)s</td></tr>
832 <tr><td class="bug_detail_label">Summary :</td>
833 <td class="bug_detail">%(summary)s</td></tr>
842 <h5><a href="%(up_link)s">Back to %(index_type)s Index</a></h5>
843 <h5><a href="../../index_by_target.html">Back to Target Index</a></h5>
845 <p>Generated by <a href="http://www.bugseverywhere.org/">
846 BugsEverywhere</a> on %(generation_time)s</p>
848 <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> |
849 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
857 self.bug_comment_entry ="""
860 <td class="bug_comment_label">Comment:</td>
861 <td class="bug_comment">
862 --------- Comment ---------<br/>
864 Short name: %(shortname)s<br/>
865 From: %(author)s<br/>
874 # strip leading whitespace
875 for attr in ['css_file', 'index_file', 'index_bug_entry', 'bug_file',
876 'bug_comment_entry']:
877 value = getattr(self, attr)
878 value = value.replace('\n'+' '*12, '\n')
879 setattr(self, attr, value.strip()+'\n')