1 # Copyright (C) 2009-2010 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
37 class HTML (libbe.command.Command):
38 """Generate a static HTML dump of the current repository status
41 >>> import libbe.bugdir
42 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
43 >>> io = libbe.command.StringInputOutput()
44 >>> io.stdout = sys.stdout
45 >>> ui = libbe.command.UserInterface(io=io)
46 >>> ui.storage_callbacks.set_storage(bd.storage)
49 >>> ret = ui.run(cmd, {'output':os.path.join(bd.storage.repo, 'html_export')})
50 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export'))
52 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index.html'))
54 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index_inactive.html'))
56 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs'))
58 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'a', 'index.html'))
60 >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b', 'index.html'))
67 def __init__(self, *args, **kwargs):
68 libbe.command.Command.__init__(self, *args, **kwargs)
70 libbe.command.Option(name='output', short_name='o',
71 help='Set the output path (%default)',
72 arg=libbe.command.Argument(
73 name='output', metavar='DIR', default='./html_export',
74 completion_callback=libbe.command.util.complete_path)),
75 libbe.command.Option(name='template-dir', short_name='t',
76 help='Use a different template. Defaults to internal templates',
77 arg=libbe.command.Argument(
78 name='template-dir', metavar='DIR',
79 completion_callback=libbe.command.util.complete_path)),
80 libbe.command.Option(name='title',
81 help='Set the bug repository title (%default)',
82 arg=libbe.command.Argument(
83 name='title', metavar='STRING',
84 default='BugsEverywhere Issue Tracker')),
85 libbe.command.Option(name='index-header',
86 help='Set the index page headers (%default)',
87 arg=libbe.command.Argument(
88 name='index-header', metavar='STRING',
89 default='BugsEverywhere Bug List')),
90 libbe.command.Option(name='export-template', short_name='e',
91 help='Export the default template and exit.'),
92 libbe.command.Option(name='export-template-dir', short_name='d',
93 help='Set the directory for the template export (%default)',
94 arg=libbe.command.Argument(
95 name='export-template-dir', metavar='DIR',
96 default='./default-templates/',
97 completion_callback=libbe.command.util.complete_path)),
98 libbe.command.Option(name='min-id-length', short_name='l',
99 help='Attempt to truncate bug and comment IDs to this length. Set to -1 for non-truncated IDs (%default)',
100 arg=libbe.command.Argument(
101 name='min-id-length', metavar='INT',
102 default=-1, type='int')),
103 libbe.command.Option(name='verbose', short_name='v',
104 help='Verbose output, default is %default'),
107 def _run(self, **params):
108 if params['export-template'] == True:
111 bugdir = self._get_bugdir()
112 bugdir.load_all_bugs()
113 html_gen = HTMLGen(bugdir,
114 template=params['template-dir'],
115 title=params['title'],
116 index_header=params['index-header'],
117 min_id_length=params['min-id-length'],
118 verbose=params['verbose'],
120 if params['export-template'] == True:
121 html_gen.write_default_template(params['export-template-dir'])
123 html_gen.run(params['output'])
125 def _long_help(self):
127 Generate a set of html pages representing the current state of the bug
131 Html = HTML # alias for libbe.command.base.get_command_class()
133 class HTMLGen (object):
134 def __init__(self, bd, template=None,
135 title="Site Title", index_header="Index Header",
137 verbose=False, encoding=None, stdout=None,
139 self.generation_time = time.ctime()
142 self.template = "default"
144 self.template = os.path.abspath(os.path.expanduser(template))
146 self.index_header = index_header
147 self.verbose = verbose
150 self.encoding = encoding
152 self.encoding = libbe.util.encoding.get_filesystem_encoding()
153 self._load_default_templates()
155 self._load_user_templates()
156 self.min_id_length = min_id_length
158 def run(self, out_dir):
159 if self.verbose == True:
160 print >> self.stdout, \
161 'Creating the html output in %s using templates in %s' \
162 % (out_dir, self.template)
166 bugs = [b for b in self.bd]
168 bugs_active = [b for b in bugs if b.active == True]
169 bugs_inactive = [b for b in bugs if b.active != True]
171 self._create_output_directories(out_dir)
172 self._write_css_file()
175 up_link = '../../index.html'
177 up_link = '../../index_inactive.html'
178 self._write_bug_file(b, up_link)
179 self._write_index_file(
180 bugs_active, title=self.title,
181 index_header=self.index_header, bug_type='active')
182 self._write_index_file(
183 bugs_inactive, title=self.title,
184 index_header=self.index_header, bug_type='inactive')
186 def _truncated_bug_id(self, bug):
187 return libbe.util.id._truncate(
188 bug.uuid, bug.sibling_uuids(),
189 min_length=self.min_id_length)
191 def _truncated_comment_id(self, comment):
192 return libbe.util.id._truncate(
193 comment.uuid, comment.sibling_uuids(),
194 min_length=self.min_id_length)
196 def _create_output_directories(self, out_dir):
198 print >> self.stdout, 'Creating output directories'
199 self.out_dir = self._make_dir(out_dir)
200 self.out_dir_bugs = self._make_dir(
201 os.path.join(self.out_dir, 'bugs'))
203 def _write_css_file(self):
205 print >> self.stdout, 'Writing css file'
206 assert hasattr(self, 'out_dir'), \
207 'Must run after ._create_output_directories()'
208 self._write_file(self.css_file,
209 [self.out_dir,'style.css'])
211 def _write_bug_file(self, bug, up_link):
213 print >> self.stdout, '\tCreating bug file for %s' % bug.id.user()
214 assert hasattr(self, 'out_dir_bugs'), \
215 'Must run after ._create_output_directories()'
217 bug.load_comments(load_full=True)
218 comment_entries = self._generate_bug_comment_entries(bug)
219 dirname = self._truncated_bug_id(bug)
220 fullpath = os.path.join(self.out_dir_bugs, dirname, 'index.html')
221 template_info = {'title':self.title,
222 'charset':self.encoding,
224 'shortname':bug.id.user(),
225 'comment_entries':comment_entries,
226 'generation_time':self.generation_time}
227 for attr in ['uuid', 'severity', 'status', 'assigned',
228 'reporter', 'creator', 'time_string', 'summary']:
229 template_info[attr] = self._escape(getattr(bug, attr))
230 fulldir = os.path.join(self.out_dir_bugs, dirname)
231 if not os.path.exists(fulldir):
233 self._write_file(self.bug_file % template_info, [fullpath])
235 def _generate_bug_comment_entries(self, bug):
236 assert hasattr(self, 'out_dir_bugs'), \
237 'Must run after ._create_output_directories()'
241 bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
242 for depth,comment in bug.comment_root.thread(flatten=False):
243 while len(stack) > depth:
244 # pop non-parents off the stack
246 # close non-parent <div class="comment...
247 comment_entries.append('</div>\n')
248 assert len(stack) == depth
249 stack.append(comment)
251 'shortname': comment.id.user(),
252 'truncated_id': self._truncated_comment_id(comment)}
254 comment_entries.append('<div class="comment root">')
256 comment_entries.append(
257 '<div class="comment" id="%s">'
258 % template_info['truncated_id'])
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 % (self._truncated_bug_id(bug),
273 self._truncated_comment_id(comment))
276 value = '<a href="./%s/%s">Link to %s file</a>.' \
277 % (self._truncated_bug_id(bug),
278 self._truncated_comment_id(comment),
279 comment.content_type)
280 if link_long_ids == True:
281 value = self._long_to_linked_user(value)
282 if save_body == True:
283 per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
284 if not os.path.exists(per_bug_dir):
285 os.mkdir(per_bug_dir)
286 comment_path = os.path.join(per_bug_dir, comment.uuid)
288 '<Files %s>\n ForceType %s\n</Files>' \
289 % (comment.uuid, comment.content_type),
290 [per_bug_dir, '.htaccess'], mode='a')
291 self._write_file(comment.body,
292 [per_bug_dir, comment.uuid], mode='wb')
294 value = self._escape(value)
295 template_info[attr] = value
296 comment_entries.append(self.bug_comment_entry % template_info)
297 while len(stack) > 0:
299 comment_entries.append('</div>\n') # close every remaining <div class='comment...
300 return '\n'.join(comment_entries)
302 def _long_to_linked_user(self, text):
304 >>> import libbe.bugdir
305 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
307 >>> h._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.')
308 'A link <a href="./a/">abc/a</a>, and a non-link #x#y#.'
311 replacer = libbe.util.id.IDreplacer(
312 [self.bd], self._long_to_linked_user_replacer, wrap=False)
314 libbe.util.id.REGEXP, replacer, text)
316 def _long_to_linked_user_replacer(self, bugdirs, long_id):
318 >>> import libbe.bugdir
319 >>> import libbe.util.id
320 >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
321 >>> a = bd.bug_from_uuid('a')
322 >>> uuid_gen = libbe.util.id.uuid_gen
323 >>> libbe.util.id.uuid_gen = lambda : '0123'
324 >>> c = a.new_comment('comment for link testing')
325 >>> libbe.util.id.uuid_gen = uuid_gen
329 >>> h._long_to_linked_user_replacer([bd], 'abc123')
331 >>> h._long_to_linked_user_replacer([bd], 'abc123/a')
332 '<a href="./a/">abc/a</a>'
333 >>> h._long_to_linked_user_replacer([bd], 'abc123/a/0123')
334 '<a href="./a/#0123">abc/a/012</a>'
335 >>> h._long_to_linked_user_replacer([bd], 'x')
337 >>> h._long_to_linked_user_replacer([bd], '')
342 p = libbe.util.id.parse_user(bugdirs[0], long_id)
343 except (libbe.util.id.MultipleIDMatches,
344 libbe.util.id.NoIDMatches,
345 libbe.util.id.InvalidIDStructure), e:
346 return '#%s#' % long_id # re-wrap failures
347 if p['type'] == 'bugdir':
348 return '#%s#' % long_id
349 elif p['type'] == 'bug':
350 bug,comment = libbe.command.util.bug_comment_from_user_id(
352 return '<a href="./%s/">%s</a>' \
353 % (self._truncated_bug_id(bug), bug.id.user())
354 elif p['type'] == 'comment':
355 bug,comment = libbe.command.util.bug_comment_from_user_id(
357 return '<a href="./%s/#%s">%s</a>' \
358 % (self._truncated_bug_id(bug),
359 self._truncated_comment_id(comment),
361 raise Exception('Invalid id type %s for "%s"'
362 % (p['type'], long_id))
364 def _write_index_file(self, bugs, title, index_header, bug_type='active'):
366 print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs))
367 assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()'
370 bug_entries = self._generate_index_bug_entries(bugs)
372 if bug_type == 'active':
373 filename = 'index.html'
374 elif bug_type == 'inactive':
375 filename = 'index_inactive.html'
377 raise Exception, 'Unrecognized bug_type: "%s"' % bug_type
378 template_info = {'title':title,
379 'index_header':index_header,
380 'charset':self.encoding,
381 'active_class':'tab sel',
382 'inactive_class':'tab nsel',
383 'bug_entries':bug_entries,
384 'generation_time':self.generation_time}
385 if bug_type == 'inactive':
386 template_info['active_class'] = 'tab nsel'
387 template_info['inactive_class'] = 'tab sel'
389 self._write_file(self.index_file % template_info,
390 [self.out_dir, filename])
392 def _generate_index_bug_entries(self, bugs):
396 print >> self.stdout, '\tCreating bug entry for %s' % bug.id.user()
397 template_info = {'shortname':bug.id.user()}
398 for attr in ['uuid', 'severity', 'status', 'assigned',
399 'reporter', 'creator', 'time_string', 'summary']:
400 template_info[attr] = self._escape(getattr(bug, attr))
401 template_info['dir'] = self._truncated_bug_id(bug)
402 bug_entries.append(self.index_bug_entry % template_info)
403 return '\n'.join(bug_entries)
405 def _escape(self, string):
408 return xml.sax.saxutils.escape(string)
410 def _load_user_templates(self):
411 for filename,attr in [('style.css','css_file'),
412 ('index_file.tpl','index_file'),
413 ('index_bug_entry.tpl','index_bug_entry'),
414 ('bug_file.tpl','bug_file'),
415 ('bug_comment_entry.tpl','bug_comment_entry')]:
416 fullpath = os.path.join(self.template, filename)
417 if os.path.exists(fullpath):
418 setattr(self, attr, self._read_file([fullpath]))
420 def _make_dir(self, dir_path):
421 dir_path = os.path.abspath(os.path.expanduser(dir_path))
422 if not os.path.exists(dir_path):
424 os.makedirs(dir_path)
426 raise libbe.command.UserError(
427 'Cannot create output directory "%s".' % dir_path)
430 def _write_file(self, content, path_array, mode='w'):
431 return libbe.util.encoding.set_file_contents(
432 os.path.join(*path_array), content, mode, self.encoding)
434 def _read_file(self, path_array, mode='r'):
435 return libbe.util.encoding.get_file_contents(
436 os.path.join(*path_array), mode, self.encoding, decode=True)
438 def write_default_template(self, out_dir):
440 print >> self.stdout, 'Creating output directories'
441 self.out_dir = self._make_dir(out_dir)
443 print >> self.stdout, 'Creating css file'
444 self._write_css_file()
446 print >> self.stdout, 'Creating index_file.tpl file'
447 self._write_file(self.index_file,
448 [self.out_dir, 'index_file.tpl'])
450 print >> self.stdout, 'Creating index_bug_entry.tpl file'
451 self._write_file(self.index_bug_entry,
452 [self.out_dir, 'index_bug_entry.tpl'])
454 print >> self.stdout, 'Creating bug_file.tpl file'
455 self._write_file(self.bug_file,
456 [self.out_dir, 'bug_file.tpl'])
458 print >> self.stdout, 'Creating bug_comment_entry.tpl file'
459 self._write_file(self.bug_comment_entry,
460 [self.out_dir, 'bug_comment_entry.tpl'])
462 def _load_default_templates(self):
465 font-family: "lucida grande", "sans serif";
476 background-color: #fcfcfc;
492 border: 10px #313131;
507 padding-right: 0.5em;
512 img { border-style: none; }
516 background-color: #305275;
525 list-style-type: none;
533 text-decoration: none;
536 a { color: #003d41; }
537 a:visited { color: #553d41; }
538 .footer a { color: #508d91; }
540 /* bug index pages */
548 background-color: #afafaf;
549 border: 1px solid #afafaf;
553 td.nsel.tab { border: 0px; }
556 background-color: #afafaf;
557 border: 2px solid #afafaf;
560 .bug_list tr { width: auto; }
561 tr.wishlist { background-color: #B4FF9B; }
562 tr.minor { background-color: #FCFF98; }
563 tr.serious { background-color: #FFB648; }
564 tr.critical { background-color: #FF752A; }
565 tr.fatal { background-color: #FF3300; }
567 /* bug detail pages */
569 td.bug_detail_label { text-align: right; }
571 td.bug_comment_label { text-align: right; vertical-align: top; }
583 /* padding-top: 0px; */
584 padding-bottom: 20px;
588 self.index_file = """
589 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
590 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
591 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
593 <title>%(title)s</title>
594 <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
595 <link rel="stylesheet" href="style.css" type="text/css" />
600 <h1>%(index_header)s</h1>
605 <td class="%(active_class)s"><a href="index.html">Active Bugs</a></td>
606 <td class="%(inactive_class)s"><a href="index_inactive.html">Inactive Bugs</a></td>
610 <table class="bug_list">
620 <p>Generated by <a href="http://www.bugseverywhere.org/">
621 BugsEverywhere</a> on %(generation_time)s</p>
623 <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> |
624 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
632 self.index_bug_entry ="""
633 <tr class="%(severity)s">
634 <td><a href="bugs/%(dir)s/">%(shortname)s</a></td>
635 <td><a href="bugs/%(dir)s/">%(status)s</a></td>
636 <td><a href="bugs/%(dir)s/">%(severity)s</a></td>
637 <td><a href="bugs/%(dir)s/">%(summary)s</a></td>
638 <td><a href="bugs/%(dir)s/">%(time_string)s</a></td>
643 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
644 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
645 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
647 <title>%(title)s</title>
648 <meta http-equiv="Content-Type" content="text/html; charset=%(charset)s" />
649 <link rel="stylesheet" href="../../style.css" type="text/css" />
654 <h1>BugsEverywhere Bug List</h1>
655 <h5><a href="%(up_link)s">Back to Index</a></h5>
656 <h2>Bug: %(shortname)s</h2>
660 <tr><td class="bug_detail_label">ID :</td>
661 <td class="bug_detail">%(uuid)s</td></tr>
662 <tr><td class="bug_detail_label">Short name :</td>
663 <td class="bug_detail">%(shortname)s</td></tr>
664 <tr><td class="bug_detail_label">Status :</td>
665 <td class="bug_detail">%(status)s</td></tr>
666 <tr><td class="bug_detail_label">Severity :</td>
667 <td class="bug_detail">%(severity)s</td></tr>
668 <tr><td class="bug_detail_label">Assigned :</td>
669 <td class="bug_detail">%(assigned)s</td></tr>
670 <tr><td class="bug_detail_label">Reporter :</td>
671 <td class="bug_detail">%(reporter)s</td></tr>
672 <tr><td class="bug_detail_label">Creator :</td>
673 <td class="bug_detail">%(creator)s</td></tr>
674 <tr><td class="bug_detail_label">Created :</td>
675 <td class="bug_detail">%(time_string)s</td></tr>
676 <tr><td class="bug_detail_label">Summary :</td>
677 <td class="bug_detail">%(summary)s</td></tr>
686 <h5><a href="%(up_link)s">Back to Index</a></h5>
689 <p>Generated by <a href="http://www.bugseverywhere.org/">
690 BugsEverywhere</a> on %(generation_time)s</p>
692 <a href="http://validator.w3.org/check?uri=referer">Validate XHTML</a> |
693 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">Validate CSS</a>
701 self.bug_comment_entry ="""
704 <td class="bug_comment_label">Comment:</td>
705 <td class="bug_comment">
706 --------- Comment ---------<br/>
708 Short name: %(shortname)s<br/>
709 From: %(author)s<br/>
718 # strip leading whitespace
719 for attr in ['css_file', 'index_file', 'index_bug_entry', 'bug_file',
720 'bug_comment_entry']:
721 value = getattr(self, attr)
722 value = value.replace('\n'+' '*12, '\n')
723 setattr(self, attr, value.strip()+'\n')