Variable lenght of the file name in the be html command.
[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         self.bug_list_dict = {}
146         
147     def run(self, out_dir):
148         if self.verbose == True:
149             print >> self.stdout, \
150                 'Creating the html output in %s using templates in %s' \
151                 % (out_dir, self.template)
152
153         bugs_active = []
154         bugs_inactive = []
155         bugs = [b for b in self.bd]
156         bugs.sort()
157         bugs_active = [b for b in bugs if b.active == True]
158         bugs_inactive = [b for b in bugs if b.active != True]
159
160         self._create_output_directories(out_dir)
161         self._write_css_file()
162         for b in bugs:
163             if b.active:
164                 up_link = '../index.html'
165             else:
166                 up_link = '../index_inactive.html'
167             fname = self._create_file_name(b.uuid)
168             self._write_bug_file(b, up_link, fname)
169         self._write_index_file(
170             bugs_active, title=self.title,
171             index_header=self.index_header, bug_type='active')
172         self._write_index_file(
173             bugs_inactive, title=self.title,
174             index_header=self.index_header, bug_type='inactive')
175
176     def _create_file_name(self, bugid):
177         s = 4
178         if not self.bug_list_dict.has_key(bugid[0:3]):
179             self.bug_list_dict[bugid[0:3]] = bugid
180             fname = bugid[0:3]
181         else:
182             for i in range(s, s+6):
183                 if not self.bug_list_dict.has_key(bugid[0:s]):
184                     self.bug_list_dict[bugid[0:s]] = bugid
185                     fname = bugid[0:s]
186                     break
187                 if s == 8:
188                     self.bug_list_dict[bugid] = bugid
189                     fname = bugid
190         fpath = os.path.join(self.out_dir_bugs, fname)
191         return fpath
192         
193     def _find_file_name(self, bugid):
194         name = ""
195         for k in self.bug_list_dict:
196             if self.bug_list_dict[k] == bugid:
197                 name = k
198                 self.bug_list_dict.pop(k)
199                 break
200         return name 
201
202     def _create_output_directories(self, out_dir):
203         if self.verbose:
204             print >> self.stdout, 'Creating output directories'
205         self.out_dir = self._make_dir(out_dir)
206         self.out_dir_bugs = self._make_dir(
207             os.path.join(self.out_dir, 'bugs'))
208
209     def _write_css_file(self):
210         if self.verbose:
211             print >> self.stdout, 'Writing css file'
212         assert hasattr(self, 'out_dir'), \
213             'Must run after ._create_output_directories()'
214         self._write_file(self.css_file,
215                          [self.out_dir,'style.css'])
216
217     def _write_bug_file(self, bug, up_link, fname):
218         if self.verbose:
219             print >> self.stdout, '\tCreating bug file for %s' % bug.id.user()
220         assert hasattr(self, 'out_dir_bugs'), \
221             'Must run after ._create_output_directories()'
222
223         bug.load_comments(load_full=True)
224         comment_entries = self._generate_bug_comment_entries(bug)
225         filename = '%s.html' % fname
226         fullpath = os.path.join(self.out_dir_bugs, filename)
227         template_info = {'title':self.title,
228                          'charset':self.encoding,
229                          'up_link':up_link,
230                          'shortname':bug.id.user(),
231                          'comment_entries':comment_entries,
232                          'generation_time':self.generation_time}
233         for attr in ['uuid', 'severity', 'status', 'assigned',
234                      'reporter', 'creator', 'time_string', 'summary']:
235             template_info[attr] = self._escape(getattr(bug, attr))
236         self._write_file(self.bug_file % template_info, [fullpath])
237
238     def _generate_bug_comment_entries(self, bug):
239         assert hasattr(self, 'out_dir_bugs'), \
240             'Must run after ._create_output_directories()'
241
242         stack = []
243         comment_entries = []
244         bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
245         for depth,comment in bug.comment_root.thread(flatten=False):
246             while len(stack) > depth:
247                 # pop non-parents off the stack
248                 stack.pop(-1)
249                 # close non-parent <div class="comment...
250                 comment_entries.append('</div>\n')
251             assert len(stack) == depth
252             stack.append(comment)
253             if depth == 0:
254                 comment_entries.append('<div class="comment root">')
255             else:
256                 comment_entries.append(
257                     '<div class="comment" id="%s">' % comment.uuid)
258             template_info = {'shortname': comment.id.user()}
259             for attr in ['uuid', 'author', 'date', 'body']:
260                 value = getattr(comment, attr)
261                 if attr == 'body':
262                     link_long_ids = False
263                     save_body = False
264                     if comment.content_type == 'text/html':
265                         link_long_ids = True
266                     elif comment.content_type.startswith('text/'):
267                         value = '<pre>\n'+self._escape(value)+'\n</pre>'
268                         link_long_ids = True
269                     elif comment.content_type.startswith('image/'):
270                         save_body = True
271                         value = '<img src="./%s/%s" />' \
272                             % (bug.uuid, comment.uuid)
273                     else:
274                         save_body = True
275                         value = '<a href="./%s/%s">Link to %s file</a>.' \
276                             % (bug.uuid, comment.uuid, comment.content_type)
277                     if link_long_ids == True:
278                         value = self._long_to_linked_user(value)
279                     if save_body == True:
280                         per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
281                         if not os.path.exists(per_bug_dir):
282                             os.mkdir(per_bug_dir)
283                         comment_path = os.path.join(per_bug_dir, comment.uuid)
284                         self._write_file(
285                             '<Files %s>\n  ForceType %s\n</Files>' \
286                                 % (comment.uuid, comment.content_type),
287                             [per_bug_dir, '.htaccess'], mode='a')
288                         self._write_file(comment.body,
289                             [per_bug_dir, comment.uuid], mode='wb')
290                 else:
291                     value = self._escape(value)
292                 template_info[attr] = value
293             comment_entries.append(self.bug_comment_entry % template_info)
294         while len(stack) > 0:
295             stack.pop(-1)
296             comment_entries.append('</div>\n') # close every remaining <div class='comment...
297         return '\n'.join(comment_entries)
298
299     def _long_to_linked_user(self, text):
300         """
301         >>> import libbe.bugdir
302         >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
303         >>> h = HTMLGen(bd)
304         >>> h._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.')
305         'A link <a href="./a.html">abc/a</a>, and a non-link #x#y#.'
306         >>> bd.cleanup()
307         """
308         replacer = libbe.util.id.IDreplacer(
309             [self.bd], self._long_to_linked_user_replacer, wrap=False)
310         return re.sub(
311             libbe.util.id.REGEXP, replacer, text)
312
313     def _long_to_linked_user_replacer(self, bugdirs, long_id):
314         """
315         >>> import libbe.bugdir
316         >>> import libbe.util.id
317         >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
318         >>> a = bd.bug_from_uuid('a')
319         >>> uuid_gen = libbe.util.id.uuid_gen
320         >>> libbe.util.id.uuid_gen = lambda : '0123'
321         >>> c = a.new_comment('comment for link testing')
322         >>> libbe.util.id.uuid_gen = uuid_gen
323         >>> c.uuid
324         '0123'
325         >>> h = HTMLGen(bd)
326         >>> h._long_to_linked_user_replacer([bd], 'abc123')
327         '#abc123#'
328         >>> h._long_to_linked_user_replacer([bd], 'abc123/a')
329         '<a href="./a.html">abc/a</a>'
330         >>> h._long_to_linked_user_replacer([bd], 'abc123/a/0123')
331         '<a href="./a.html#0123">abc/a/012</a>'
332         >>> h._long_to_linked_user_replacer([bd], 'x')
333         '#x#'
334         >>> h._long_to_linked_user_replacer([bd], '')
335         '##'
336         >>> bd.cleanup()
337         """
338         try:
339             p = libbe.util.id.parse_user(bugdirs[0], long_id)
340             short_id = libbe.util.id.long_to_short_user(bugdirs, long_id)
341         except (libbe.util.id.MultipleIDMatches,
342                 libbe.util.id.NoIDMatches,
343                 libbe.util.id.InvalidIDStructure), e:
344             return '#%s#' % long_id # re-wrap failures
345         if p['type'] == 'bugdir':
346             return '#%s#' % long_id
347         elif p['type'] == 'bug':
348             return '<a href="./%s.html">%s</a>' \
349                 % (p['bug'], short_id)
350         elif p['type'] == 'comment':
351             return '<a href="./%s.html#%s">%s</a>' \
352                 % (p['bug'], p['comment'], short_id)
353         raise Exception('Invalid id type %s for "%s"'
354                         % (p['type'], long_id))
355
356     def _write_index_file(self, bugs, title, index_header, bug_type='active'):
357         if self.verbose:
358             print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs))
359         assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()'
360         esc = self._escape
361
362         bug_entries = self._generate_index_bug_entries(bugs)
363
364         if bug_type == 'active':
365             filename = 'index.html'
366         elif bug_type == 'inactive':
367             filename = 'index_inactive.html'
368         else:
369             raise Exception, 'Unrecognized bug_type: "%s"' % bug_type
370         template_info = {'title':title,
371                          'index_header':index_header,
372                          'charset':self.encoding,
373                          'active_class':'tab sel',
374                          'inactive_class':'tab nsel',
375                          'bug_entries':bug_entries,
376                          'generation_time':self.generation_time}
377         if bug_type == 'inactive':
378             template_info['active_class'] = 'tab nsel'
379             template_info['inactive_class'] = 'tab sel'
380
381         self._write_file(self.index_file % template_info,
382                          [self.out_dir, filename])
383
384     def _generate_index_bug_entries(self, bugs):
385         bug_entries = []
386         for bug in bugs:
387             if self.verbose:
388                 print >> self.stdout, '\tCreating bug entry for %s' % bug.id.user()
389             template_info = {'shortname':bug.id.user()}
390             fn = self._find_file_name(bug.uuid)
391             for attr in ['uuid', 'severity', 'status', 'assigned',
392                          'reporter', 'creator', 'time_string', 'summary']:
393                 template_info[attr] = self._escape(getattr(bug, attr))
394             template_info['uuid'] = fn
395             bug_entries.append(self.index_bug_entry % template_info)
396         return '\n'.join(bug_entries)
397
398     def _escape(self, string):
399         if string == None:
400             return ''
401         return xml.sax.saxutils.escape(string)
402
403     def _load_user_templates(self):
404         for filename,attr in [('style.css','css_file'),
405                               ('index_file.tpl','index_file'),
406                               ('index_bug_entry.tpl','index_bug_entry'),
407                               ('bug_file.tpl','bug_file'),
408                               ('bug_comment_entry.tpl','bug_comment_entry')]:
409             fullpath = os.path.join(self.template, filename)
410             if os.path.exists(fullpath):
411                 setattr(self, attr, self._read_file([fullpath]))
412
413     def _make_dir(self, dir_path):
414         dir_path = os.path.abspath(os.path.expanduser(dir_path))
415         if not os.path.exists(dir_path):
416             try:
417                 os.makedirs(dir_path)
418             except:
419                 raise libbe.command.UserError(
420                     'Cannot create output directory "%s".' % dir_path)
421         return dir_path
422
423     def _write_file(self, content, path_array, mode='w'):
424         return libbe.util.encoding.set_file_contents(
425             os.path.join(*path_array), content, mode, self.encoding)
426
427     def _read_file(self, path_array, mode='r'):
428         return libbe.util.encoding.get_file_contents(
429             os.path.join(*path_array), mode, self.encoding, decode=True)
430
431     def write_default_template(self, out_dir):
432         if self.verbose:
433             print >> self.stdout, 'Creating output directories'
434         self.out_dir = self._make_dir(out_dir)
435         if self.verbose:
436             print >> self.stdout, 'Creating css file'
437         self._write_css_file()
438         if self.verbose:
439             print >> self.stdout, 'Creating index_file.tpl file'
440         self._write_file(self.index_file,
441                          [self.out_dir, 'index_file.tpl'])
442         if self.verbose:
443             print >> self.stdout, 'Creating index_bug_entry.tpl file'
444         self._write_file(self.index_bug_entry,
445                          [self.out_dir, 'index_bug_entry.tpl'])
446         if self.verbose:
447             print >> self.stdout, 'Creating bug_file.tpl file'
448         self._write_file(self.bug_file,
449                          [self.out_dir, 'bug_file.tpl'])
450         if self.verbose:
451             print >> self.stdout, 'Creating bug_comment_entry.tpl file'
452         self._write_file(self.bug_comment_entry,
453                          [self.out_dir, 'bug_comment_entry.tpl'])
454
455     def _load_default_templates(self):
456         self.css_file = """
457             body {
458               font-family: "lucida grande", "sans serif";
459               color: #333;
460               width: auto;
461               margin: auto;
462             }
463
464             div.main {
465               padding: 20px;
466               margin: auto;
467               padding-top: 0;
468               margin-top: 1em;
469               background-color: #fcfcfc;
470             }
471
472             div.footer {
473               font-size: small;
474               padding-left: 20px;
475               padding-right: 20px;
476               padding-top: 5px;
477               padding-bottom: 5px;
478               margin: auto;
479               background: #305275;
480               color: #fffee7;
481             }
482
483             table {
484               border-style: solid;
485               border: 10px #313131;
486               border-spacing: 0;
487               width: auto;
488             }
489
490             tb { border: 1px; }
491
492             tr {
493               vertical-align: top;
494               width: auto;
495             }
496
497             td {
498               border-width: 0;
499               border-style: none;
500               padding-right: 0.5em;
501               padding-left: 0.5em;
502               width: auto;
503             }
504
505             img { border-style: none; }
506
507             h1 {
508               padding: 0.5em;
509               background-color: #305275;
510               margin-top: 0;
511               margin-bottom: 0;
512               color: #fff;
513               margin-left: -20px;
514               margin-right: -20px;
515             }
516
517             ul {
518               list-style-type: none;
519               padding: 0;
520             }
521
522             p { width: auto; }
523
524             a, a:visited {
525               background: inherit;
526               text-decoration: none;
527             }
528
529             a { color: #003d41; }
530             a:visited { color: #553d41; }
531             .footer a { color: #508d91; }
532
533             /* bug index pages */
534
535             td.tab {
536               padding-right: 1em;
537               padding-left: 1em;
538             }
539
540             td.sel.tab {
541               background-color: #afafaf;
542               border: 1px solid #afafaf;
543               font-weight:bold;
544             }
545
546             td.nsel.tab { border: 0px; }
547
548             table.bug_list {
549               background-color: #afafaf;
550               border: 2px solid #afafaf;
551             }
552
553             .bug_list tr { width: auto; }
554             tr.wishlist { background-color: #B4FF9B; }
555             tr.minor { background-color: #FCFF98; }
556             tr.serious { background-color: #FFB648; }
557             tr.critical { background-color: #FF752A; }
558             tr.fatal { background-color: #FF3300; }
559
560             /* bug detail pages */
561
562             td.bug_detail_label { text-align: right; }
563             td.bug_detail { }
564             td.bug_comment_label { text-align: right; vertical-align: top; }
565             td.bug_comment { }
566
567             div.comment {
568               padding: 20px;
569               padding-top: 20px;
570               margin: auto;
571               margin-top: 0;
572             }
573
574             div.root.comment {
575               padding: 0px;
576               /* padding-top: 0px; */
577               padding-bottom: 20px;
578             }
579        """
580
581         self.index_file = """
582             <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
583               "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
584             <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
585             <head>
586             <title>%(title)s</title>
587             <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
588             <link rel="stylesheet" href="style.css" type="text/css" />
589             </head>
590             <body>
591
592             <div class="main">
593             <h1>%(index_header)s</h1>
594             <p></p>
595             <table>
596
597             <tr>
598             <td class="%(active_class)s"><a href="index.html">Active Bugs</a></td>
599             <td class="%(inactive_class)s"><a href="index_inactive.html">Inactive Bugs</a></td>
600             </tr>
601
602             </table>
603             <table class="bug_list">
604             <tbody>
605
606             %(bug_entries)s
607
608             </tbody>
609             </table>
610             </div>
611
612             <div class="footer">
613             <p>Generated by <a href="http://www.bugseverywhere.org/">
614             BugsEverywhere</a> on %(generation_time)s</p>
615             <p>
616             <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a>&nbsp;|&nbsp;
617             <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
618             </p>
619             </div>
620
621             </body>
622             </html>
623         """
624
625         self.index_bug_entry ="""
626             <tr class="%(severity)s">
627               <td><a href="bugs/%(uuid)s.html">%(shortname)s</a></td>
628               <td><a href="bugs/%(uuid)s.html">%(status)s</a></td>
629               <td><a href="bugs/%(uuid)s.html">%(severity)s</a></td>
630               <td><a href="bugs/%(uuid)s.html">%(summary)s</a></td>
631               <td><a href="bugs/%(uuid)s.html">%(time_string)s</a></td>
632             </tr>
633         """
634
635         self.bug_file = """
636             <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
637               "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
638             <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
639             <head>
640             <title>%(title)s</title>
641             <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
642             <link rel="stylesheet" href="../style.css" type="text/css" />
643             </head>
644             <body>
645
646             <div class="main">
647             <h1>BugsEverywhere Bug List</h1>
648             <h5><a href="%(up_link)s">Back to Index</a></h5>
649             <h2>Bug: %(shortname)s</h2>
650             <table>
651             <tbody>
652
653             <tr><td class="bug_detail_label">ID :</td>
654                 <td class="bug_detail">%(uuid)s</td></tr>
655             <tr><td class="bug_detail_label">Short name :</td>
656                 <td class="bug_detail">%(shortname)s</td></tr>
657             <tr><td class="bug_detail_label">Status :</td>
658                 <td class="bug_detail">%(status)s</td></tr>
659             <tr><td class="bug_detail_label">Severity :</td>
660                 <td class="bug_detail">%(severity)s</td></tr>
661             <tr><td class="bug_detail_label">Assigned :</td>
662                 <td class="bug_detail">%(assigned)s</td></tr>
663             <tr><td class="bug_detail_label">Reporter :</td>
664                 <td class="bug_detail">%(reporter)s</td></tr>
665             <tr><td class="bug_detail_label">Creator :</td>
666                 <td class="bug_detail">%(creator)s</td></tr>
667             <tr><td class="bug_detail_label">Created :</td>
668                 <td class="bug_detail">%(time_string)s</td></tr>
669             <tr><td class="bug_detail_label">Summary :</td>
670                 <td class="bug_detail">%(summary)s</td></tr>
671             </tbody>
672             </table>
673
674             <hr/>
675
676             %(comment_entries)s
677
678             </div>
679             <h5><a href="%(up_link)s">Back to Index</a></h5>
680
681             <div class="footer">
682             <p>Generated by <a href="http://www.bugseverywhere.org/">
683             BugsEverywhere</a> on %(generation_time)s</p>
684             <p>
685             <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a>&nbsp;|&nbsp;
686             <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
687             </p>
688             </div>
689
690             </body>
691             </html>
692         """
693
694         self.bug_comment_entry ="""
695             <table>
696             <tr>
697               <td class="bug_comment_label">Comment:</td>
698               <td class="bug_comment">
699             --------- Comment ---------<br/>
700             ID: %(uuid)s<br/>
701             Short name: %(shortname)s<br/>
702             From: %(author)s<br/>
703             Date: %(date)s<br/>
704             <br/>
705             %(body)s
706               </td>
707             </tr>
708             </table>
709         """
710
711         # strip leading whitespace
712         for attr in ['css_file', 'index_file', 'index_bug_entry', 'bug_file',
713                      'bug_comment_entry']:
714             value = getattr(self, attr)
715             value = value.replace('\n'+' '*12, '\n')
716             setattr(self, attr, value.strip()+'\n')