1 # Copyright (C) 2009-2010 Gianluca Montecchi <gian@grys.it>
2 # W. Trevor King <wking@drexel.edu>
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.
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.
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.
25 import xml.sax.saxutils
29 import libbe.command.util
31 import libbe.util.encoding
35 class HTML (libbe.command.Command):
36 """Generate a static HTML dump of the current repository status
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)
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'))
50 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index.html'))
52 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index_inactive.html'))
54 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs'))
56 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'a.html'))
58 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b.html'))
65 def __init__(self, *args, **kwargs):
66 libbe.command.Command.__init__(self, *args, **kwargs)
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'),
100 def _run(self, **params):
101 if params['export-template'] == True:
102 html_gen.write_default_template(params['export-template-dir'])
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'],
112 html_gen.run(params['output'])
115 def _long_help(self):
117 Generate a set of html pages representing the current state of the bug
121 Html = HTML # alias for libbe.command.base.get_command_class()
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,
128 self.generation_time = time.ctime()
131 self.template = "default"
133 self.template = os.path.abspath(os.path.expanduser(template))
135 self.index_header = index_header
136 self.verbose = verbose
139 self.encoding = encoding
141 self.encoding = libbe.util.encoding.get_filesystem_encoding()
142 self._load_default_templates()
144 self._load_user_templates()
145 self.bug_list_dict = {}
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)
155 bugs = [b for b in self.bd]
157 bugs_active = [b for b in bugs if b.active == True]
158 bugs_inactive = [b for b in bugs if b.active != True]
160 self._create_output_directories(out_dir)
161 self._write_css_file()
164 up_link = '../index.html'
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')
176 def _create_file_name(self, bugid):
178 if not self.bug_list_dict.has_key(bugid[0:3]):
179 self.bug_list_dict[bugid[0:3]] = bugid
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
188 self.bug_list_dict[bugid] = bugid
190 fpath = os.path.join(self.out_dir_bugs, fname)
193 def _find_file_name(self, bugid):
195 for k in self.bug_list_dict:
196 if self.bug_list_dict[k] == bugid:
198 self.bug_list_dict.pop(k)
202 def _create_output_directories(self, out_dir):
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'))
209 def _write_css_file(self):
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'])
217 def _write_bug_file(self, bug, up_link, fname):
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()'
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,
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])
238 def _generate_bug_comment_entries(self, bug):
239 assert hasattr(self, 'out_dir_bugs'), \
240 'Must run after ._create_output_directories()'
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
249 # close non-parent <div class="comment...
250 comment_entries.append('</div>\n')
251 assert len(stack) == depth
252 stack.append(comment)
254 comment_entries.append('<div class="comment root">')
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)
262 link_long_ids = False
264 if comment.content_type == 'text/html':
266 elif comment.content_type.startswith('text/'):
267 value = '<pre>\n'+self._escape(value)+'\n</pre>'
269 elif comment.content_type.startswith('image/'):
271 value = '<img src="./%s/%s" />' \
272 % (bug.uuid, comment.uuid)
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)
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')
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:
296 comment_entries.append('</div>\n') # close every remaining <div class='comment...
297 return '\n'.join(comment_entries)
299 def _long_to_linked_user(self, text):
301 >>> import libbe.bugdir
302 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
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#.'
308 replacer = libbe.util.id.IDreplacer(
309 [self.bd], self._long_to_linked_user_replacer, wrap=False)
311 libbe.util.id.REGEXP, replacer, text)
313 def _long_to_linked_user_replacer(self, bugdirs, long_id):
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
326 >>> h._long_to_linked_user_replacer([bd], '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')
334 >>> h._long_to_linked_user_replacer([bd], '')
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))
356 def _write_index_file(self, bugs, title, index_header, bug_type='active'):
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()'
362 bug_entries = self._generate_index_bug_entries(bugs)
364 if bug_type == 'active':
365 filename = 'index.html'
366 elif bug_type == 'inactive':
367 filename = 'index_inactive.html'
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'
381 self._write_file(self.index_file % template_info,
382 [self.out_dir, filename])
384 def _generate_index_bug_entries(self, bugs):
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)
398 def _escape(self, string):
401 return xml.sax.saxutils.escape(string)
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]))
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):
417 os.makedirs(dir_path)
419 raise libbe.command.UserError(
420 'Cannot create output directory "%s".' % dir_path)
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)
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)
431 def write_default_template(self, out_dir):
433 print >> self.stdout, 'Creating output directories'
434 self.out_dir = self._make_dir(out_dir)
436 print >> self.stdout, 'Creating css file'
437 self._write_css_file()
439 print >> self.stdout, 'Creating index_file.tpl file'
440 self._write_file(self.index_file,
441 [self.out_dir, 'index_file.tpl'])
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'])
447 print >> self.stdout, 'Creating bug_file.tpl file'
448 self._write_file(self.bug_file,
449 [self.out_dir, 'bug_file.tpl'])
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'])
455 def _load_default_templates(self):
458 font-family: "lucida grande", "sans serif";
469 background-color: #fcfcfc;
485 border: 10px #313131;
500 padding-right: 0.5em;
505 img { border-style: none; }
509 background-color: #305275;
518 list-style-type: none;
526 text-decoration: none;
529 a { color: #003d41; }
530 a:visited { color: #553d41; }
531 .footer a { color: #508d91; }
533 /* bug index pages */
541 background-color: #afafaf;
542 border: 1px solid #afafaf;
546 td.nsel.tab { border: 0px; }
549 background-color: #afafaf;
550 border: 2px solid #afafaf;
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; }
560 /* bug detail pages */
562 td.bug_detail_label { text-align: right; }
564 td.bug_comment_label { text-align: right; vertical-align: top; }
576 /* padding-top: 0px; */
577 padding-bottom: 20px;
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">
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" />
593 <h1>%(index_header)s</h1>
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>
603 <table class="bug_list">
613 <p>Generated by <a href="http://www.bugseverywhere.org/">
614 BugsEverywhere</a> on %(generation_time)s</p>
616 <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> |
617 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
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>
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">
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" />
647 <h1>BugsEverywhere Bug List</h1>
648 <h5><a href="%(up_link)s">Back to Index</a></h5>
649 <h2>Bug: %(shortname)s</h2>
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>
679 <h5><a href="%(up_link)s">Back to Index</a></h5>
682 <p>Generated by <a href="http://www.bugseverywhere.org/">
683 BugsEverywhere</a> on %(generation_time)s</p>
685 <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> |
686 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
694 self.bug_comment_entry ="""
697 <td class="bug_comment_label">Comment:</td>
698 <td class="bug_comment">
699 --------- Comment ---------<br/>
701 Short name: %(shortname)s<br/>
702 From: %(author)s<br/>
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')