Merged initial Sphinx documentation structure.
[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         chars = []
372         for char in string:
373             codepoint = ord(char)
374             if codepoint in htmlentitydefs.codepoint2name:
375                 char = '&%s;' % htmlentitydefs.codepoint2name[codepoint]
376             #else: xml.sax.saxutils.escape(char)
377             chars.append(char)
378         return ''.join(chars)
379
380     def _load_user_templates(self):
381         for filename,attr in [('style.css','css_file'),
382                               ('index_file.tpl','index_file'),
383                               ('index_bug_entry.tpl','index_bug_entry'),
384                               ('bug_file.tpl','bug_file'),
385                               ('bug_comment_entry.tpl','bug_comment_entry')]:
386             fullpath = os.path.join(self.template, filename)
387             if os.path.exists(fullpath):
388                 setattr(self, attr, self._read_file([fullpath]))
389
390     def _make_dir(self, dir_path):
391         dir_path = os.path.abspath(os.path.expanduser(dir_path))
392         if not os.path.exists(dir_path):
393             try:
394                 os.makedirs(dir_path)
395             except:
396                 raise libbe.command.UserError(
397                     'Cannot create output directory "%s".' % dir_path)
398         return dir_path
399
400     def _write_file(self, content, path_array, mode='w'):
401         return libbe.util.encoding.set_file_contents(
402             os.path.join(*path_array), content, mode, self.encoding)
403
404     def _read_file(self, path_array, mode='r'):
405         return libbe.util.encoding.get_file_contents(
406             os.path.join(*path_array), mode, self.encoding, decode=True)
407
408     def write_default_template(self, out_dir):
409         if self.verbose:
410             print >> self.stdout, 'Creating output directories'
411         self.out_dir = self._make_dir(out_dir)
412         if self.verbose:
413             print >> self.stdout, 'Creating css file'
414         self._write_css_file()
415         if self.verbose:
416             print >> self.stdout, 'Creating index_file.tpl file'
417         self._write_file(self.index_file,
418                          [self.out_dir, 'index_file.tpl'])
419         if self.verbose:
420             print >> self.stdout, 'Creating index_bug_entry.tpl file'
421         self._write_file(self.index_bug_entry,
422                          [self.out_dir, 'index_bug_entry.tpl'])
423         if self.verbose:
424             print >> self.stdout, 'Creating bug_file.tpl file'
425         self._write_file(self.bug_file,
426                          [self.out_dir, 'bug_file.tpl'])
427         if self.verbose:
428             print >> self.stdout, 'Creating bug_comment_entry.tpl file'
429         self._write_file(self.bug_comment_entry,
430                          [self.out_dir, 'bug_comment_entry.tpl'])
431
432     def _load_default_templates(self):
433         self.css_file = """
434             body {
435               font-family: "lucida grande", "sans serif";
436               color: #333;
437               width: auto;
438               margin: auto;
439             }
440
441             div.main {
442               padding: 20px;
443               margin: auto;
444               padding-top: 0;
445               margin-top: 1em;
446               background-color: #fcfcfc;
447             }
448
449             div.footer {
450               font-size: small;
451               padding-left: 20px;
452               padding-right: 20px;
453               padding-top: 5px;
454               padding-bottom: 5px;
455               margin: auto;
456               background: #305275;
457               color: #fffee7;
458             }
459
460             table {
461               border-style: solid;
462               border: 10px #313131;
463               border-spacing: 0;
464               width: auto;
465             }
466
467             tb { border: 1px; }
468
469             tr {
470               vertical-align: top;
471               width: auto;
472             }
473
474             td {
475               border-width: 0;
476               border-style: none;
477               padding-right: 0.5em;
478               padding-left: 0.5em;
479               width: auto;
480             }
481
482             img { border-style: none; }
483
484             h1 {
485               padding: 0.5em;
486               background-color: #305275;
487               margin-top: 0;
488               margin-bottom: 0;
489               color: #fff;
490               margin-left: -20px;
491               margin-right: -20px;
492             }
493
494             ul {
495               list-style-type: none;
496               padding: 0;
497             }
498
499             p { width: auto; }
500
501             a, a:visited {
502               background: inherit;
503               text-decoration: none;
504             }
505
506             a { color: #003d41; }
507             a:visited { color: #553d41; }
508             .footer a { color: #508d91; }
509
510             /* bug index pages */
511
512             td.tab {
513               padding-right: 1em;
514               padding-left: 1em;
515             }
516
517             td.sel.tab {
518               background-color: #afafaf;
519               border: 1px solid #afafaf;
520               font-weight:bold;
521             }
522
523             td.nsel.tab { border: 0px; }
524
525             table.bug_list {
526               background-color: #afafaf;
527               border: 2px solid #afafaf;
528             }
529
530             .bug_list tr { width: auto; }
531             tr.wishlist { background-color: #B4FF9B; }
532             tr.minor { background-color: #FCFF98; }
533             tr.serious { background-color: #FFB648; }
534             tr.critical { background-color: #FF752A; }
535             tr.fatal { background-color: #FF3300; }
536
537             /* bug detail pages */
538
539             td.bug_detail_label { text-align: right; }
540             td.bug_detail { }
541             td.bug_comment_label { text-align: right; vertical-align: top; }
542             td.bug_comment { }
543
544             div.comment {
545               padding: 20px;
546               padding-top: 20px;
547               margin: auto;
548               margin-top: 0;
549             }
550
551             div.root.comment {
552               padding: 0px;
553               /* padding-top: 0px; */
554               padding-bottom: 20px;
555             }
556        """
557
558         self.index_file = """
559             <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
560               "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
561             <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
562             <head>
563             <title>%(title)s</title>
564             <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
565             <link rel="stylesheet" href="style.css" type="text/css" />
566             </head>
567             <body>
568
569             <div class="main">
570             <h1>%(index_header)s</h1>
571             <p></p>
572             <table>
573
574             <tr>
575             <td class="%(active_class)s"><a href="index.html">Active Bugs</a></td>
576             <td class="%(inactive_class)s"><a href="index_inactive.html">Inactive Bugs</a></td>
577             </tr>
578
579             </table>
580             <table class="bug_list">
581             <tbody>
582
583             %(bug_entries)s
584
585             </tbody>
586             </table>
587             </div>
588
589             <div class="footer">
590             <p>Generated by <a href="http://www.bugseverywhere.org/">
591             BugsEverywhere</a> on %(generation_time)s</p>
592             <p>
593             <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a>&nbsp;|&nbsp;
594             <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
595             </p>
596             </div>
597
598             </body>
599             </html>
600         """
601
602         self.index_bug_entry ="""
603             <tr class="%(severity)s">
604               <td><a href="bugs/%(uuid)s.html">%(shortname)s</a></td>
605               <td><a href="bugs/%(uuid)s.html">%(status)s</a></td>
606               <td><a href="bugs/%(uuid)s.html">%(severity)s</a></td>
607               <td><a href="bugs/%(uuid)s.html">%(summary)s</a></td>
608               <td><a href="bugs/%(uuid)s.html">%(time_string)s</a></td>
609             </tr>
610         """
611
612         self.bug_file = """
613             <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
614               "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
615             <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
616             <head>
617             <title>%(title)s</title>
618             <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
619             <link rel="stylesheet" href="../style.css" type="text/css" />
620             </head>
621             <body>
622
623             <div class="main">
624             <h1>BugsEverywhere Bug List</h1>
625             <h5><a href="%(up_link)s">Back to Index</a></h5>
626             <h2>Bug: %(shortname)s</h2>
627             <table>
628             <tbody>
629
630             <tr><td class="bug_detail_label">ID :</td>
631                 <td class="bug_detail">%(uuid)s</td></tr>
632             <tr><td class="bug_detail_label">Short name :</td>
633                 <td class="bug_detail">%(shortname)s</td></tr>
634             <tr><td class="bug_detail_label">Status :</td>
635                 <td class="bug_detail">%(status)s</td></tr>
636             <tr><td class="bug_detail_label">Severity :</td>
637                 <td class="bug_detail">%(severity)s</td></tr>
638             <tr><td class="bug_detail_label">Assigned :</td>
639                 <td class="bug_detail">%(assigned)s</td></tr>
640             <tr><td class="bug_detail_label">Reporter :</td>
641                 <td class="bug_detail">%(reporter)s</td></tr>
642             <tr><td class="bug_detail_label">Creator :</td>
643                 <td class="bug_detail">%(creator)s</td></tr>
644             <tr><td class="bug_detail_label">Created :</td>
645                 <td class="bug_detail">%(time_string)s</td></tr>
646             <tr><td class="bug_detail_label">Summary :</td>
647                 <td class="bug_detail">%(summary)s</td></tr>
648             </tbody>
649             </table>
650
651             <hr/>
652
653             %(comment_entries)s
654
655             </div>
656             <h5><a href="%(up_link)s">Back to Index</a></h5>
657
658             <div class="footer">
659             <p>Generated by <a href="http://www.bugseverywhere.org/">
660             BugsEverywhere</a> on %(generation_time)s</p>
661             <p>
662             <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a>&nbsp;|&nbsp;
663             <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
664             </p>
665             </div>
666
667             </body>
668             </html>
669         """
670
671         self.bug_comment_entry ="""
672             <table>
673             <tr>
674               <td class="bug_comment_label">Comment:</td>
675               <td class="bug_comment">
676             --------- Comment ---------<br/>
677             ID: %(uuid)s<br/>
678             Short name: %(shortname)s<br/>
679             From: %(author)s<br/>
680             Date: %(date)s<br/>
681             <br/>
682             %(body)s
683               </td>
684             </tr>
685             </table>
686         """
687
688         # strip leading whitespace
689         for attr in ['css_file', 'index_file', 'index_bug_entry', 'bug_file',
690                      'bug_comment_entry']:
691             value = getattr(self, attr)
692             value = value.replace('\n'+' '*12, '\n')
693             setattr(self, attr, value.strip()+'\n')