8c2ca92ae06ba92bffa2ac313f04857f0957b4ea
[be.git] / libbe / command / html.py
1 # Copyright (C) 2005-2012 Aaron Bentley <abentley@panoramicfeedback.com>
2 #                         Chris Ball <cjb@laptop.org>
3 #                         Gianluca Montecchi <gian@grys.it>
4 #                         Marien Zwart <marien.zwart@gmail.com>
5 #                         Mathieu Clabaut <mathieu.clabaut@gmail.com>
6 #                         Thomas Gerigk <tgerigk@gmx.de>
7 #                         W. Trevor King <wking@tremily.us>
8 #
9 # This file is part of Bugs Everywhere.
10 #
11 # Bugs Everywhere is free software: you can redistribute it and/or modify it
12 # under the terms of the GNU General Public License as published by the Free
13 # Software Foundation, either version 2 of the License, or (at your option) any
14 # later version.
15 #
16 # Bugs Everywhere is distributed in the hope that it will be useful, but
17 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
18 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
19 # more details.
20 #
21 # You should have received a copy of the GNU General Public License along with
22 # Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
23
24 import codecs
25 import email.utils
26 import htmlentitydefs
27 import itertools
28 import os
29 import os.path
30 import re
31 import string
32 import time
33 import xml.sax.saxutils
34
35 from jinja2 import Environment, FileSystemLoader, DictLoader, ChoiceLoader
36
37 import libbe
38 import libbe.command
39 import libbe.command.depend
40 import libbe.command.target
41 import libbe.command.util
42 import libbe.comment
43 import libbe.util.encoding
44 import libbe.util.id
45 import libbe.util.wsgi
46 import libbe.version
47
48
49 class ServerApp (libbe.util.wsgi.WSGI_AppObject,
50                  libbe.util.wsgi.WSGI_DataObject):
51     """WSGI server for a BE Storage instance over HTML.
52
53     Serve browsable HTML for public consumption.  Currently everything
54     is read-only.
55     """
56     server_version = 'BE-html-server/' + libbe.version.version()
57
58     def __init__(self, bugdirs={}, template_dir=None, title='Site Title',
59                  header='Header', index_file='', min_id_length=-1,
60                  strip_email=False, generation_time=None, **kwargs):
61         super(ServerApp, self).__init__(
62             urls=[
63                 (r'^{}$'.format(index_file), self.index),
64                 (r'^style.css$', self.style),
65                 (r'^([^/]+)/([^/]+)/{}'.format(index_file), self.bug),
66                 ],
67             **kwargs)
68         self.bugdirs = bugdirs
69         self.title = title
70         self.header = header
71         self._index_file = index_file
72         self.min_id_length = min_id_length
73         self.strip_email = strip_email
74         self.generation_time = generation_time
75         self._refresh = 0
76         self.http_user_error = 418
77         self._load_templates(template_dir=template_dir)
78         self._filters = {
79             'active': lambda bug: bug.active and bug.severity != 'target',
80             'inactive': lambda bug: not bug.active and bug.severity !='target',
81             'target': lambda bug: bug.severity == 'target'
82             }
83
84     # handlers
85     def style(self, environ, start_response): 
86         template = self.template.get_template('style.css')
87         content = template.render()
88         return self.ok_response(
89             environ, start_response, content, content_type='text/css')
90
91     def index(self, environ, start_response):
92         data = self.query_data(environ)
93         source = 'query'
94         bug_type = self.data_get_string(
95             data, 'type', default='active', source=source)
96         assert bug_type in ['active', 'inactive', 'target'], bug_type
97         self.refresh()
98         filter_ = self._filters.get(bug_type, self._filters['active'])
99         bugs = list(itertools.chain(*list(
100                     [bug for bug in bugdir if filter_(bug)]
101                     for bugdir in self.bugdirs.values())))
102         bugs.sort()
103         if self.logger:
104             self.logger.log(
105                 self.log_level, 'generate {} index file for {} bugs'.format(
106                     bug_type, len(bugs)))
107         template_info = {
108             'title': self.title,
109             'charset': 'UTF-8',
110             'stylesheet': 'style.css',
111             'header': self.header,
112             'active_class': 'tab nsel',
113             'inactive_class': 'tab nsel',
114             'target_class': 'tab nsel',
115             'bugs': bugs,
116             'bug_entry': self.template.get_template('index_bug_entry.html'),
117             'bug_dir': self.bug_dir,
118             'index_file': self._index_file,
119             'generation_time': self._generation_time(),
120             }
121         template_info['{}_class'.format(bug_type)] = 'tab sel'
122         if bug_type == 'target':
123             template = self.template.get_template('target_index.html')
124             template_info['targets'] = [
125                 (target, sorted(libbe.command.depend.get_blocked_by(
126                             self.bugdirs, target)))
127                 for target in bugs]
128         else:
129             template = self.template.get_template('standard_index.html')           
130         content = template.render(template_info)+'\n'
131         return self.ok_response(
132             environ, start_response, content, content_type='text/html')
133
134     def bug(self, environ, start_response):
135         try:
136             bugdir_id,bug_id = environ['be-server.url_args']
137         except:
138             raise libbe.util.wsgi.HandlerError(404, 'Not Found')
139         user_id = '{}/{}'.format(bugdir_id, bug_id)
140         bugdir,bug,comment = (
141             libbe.command.util.bugdir_bug_comment_from_user_id(
142                 self.bugdirs, user_id))
143         if self.logger:
144             self.logger.log(
145                 self.log_level, 'generate bug file for {}/{}'.format(
146                     bugdir.uuid, bug.uuid))
147         if bug.severity == 'target':
148             index_type = 'target'
149         elif bug.active:
150             index_type = 'active'
151         else:
152             index_type = 'inactive'
153         target = libbe.command.target.bug_target(self.bugdirs, bug)
154         if target == bug:  # e.g. when bug.severity == 'target'
155             target = None
156         up_link = '../../{}?type={}'.format(self._index_file, index_type)
157         bug.load_comments(load_full=True)
158         bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
159         template_info = {
160             'title': self.title,
161             'charset': 'UTF-8',
162             'stylesheet': '../../style.css',
163             'header': self.header,
164             'backlinks': self.template.get_template('bug_backlinks.html'),
165             'up_link': up_link,
166             'index_type': index_type.capitalize(),
167             'index_file': self._index_file,
168             'bug': bug,
169             'target': target,
170             'comment_entry': self.template.get_template(
171                 'bug_comment_entry.html'),
172             'comments': [(depth,comment) for depth,comment
173                          in bug.comment_root.thread(flatten=False)],
174             'bug_dir': self.bug_dir,
175             'comment_dir': self._truncated_comment_id,
176             'format_body': self._format_comment_body,
177             'div_close': _DivCloser(),
178             'strip_email': self._strip_email,
179             'generation_time': self._generation_time(),
180             }
181         template = self.template.get_template('bug.html')
182         content = template.render(template_info)
183         return self.ok_response(
184             environ, start_response, content, content_type='text/html')
185
186     # helper functions
187     def refresh(self):
188         if time.time() > self._refresh:
189             if self.logger:
190                 self.logger.log(self.log_level, 'refresh bugdirs')
191             for bugdir in self.bugdirs.values():
192                 bugdir.load_all_bugs()
193             self._refresh = time.time() + 60
194
195     def _truncated_bugdir_id(self, bugdir):
196         return libbe.util.id._truncate(
197             bugdir.uuid, self.bugdirs.keys(),
198             min_length=self.min_id_length)
199
200     def _truncated_bug_id(self, bug):
201         return libbe.util.id._truncate(
202             bug.uuid, bug.sibling_uuids(),
203             min_length=self.min_id_length)
204
205     def _truncated_comment_id(self, comment):
206         return libbe.util.id._truncate(
207             comment.uuid, comment.sibling_uuids(),
208             min_length=self.min_id_length)
209
210     def bug_dir(self, bug):
211         return '{}/{}'.format(
212             self._truncated_bugdir_id(bug.bugdir),
213             self._truncated_bug_id(bug))
214
215     def _long_to_linked_user(self, text):
216         """
217         >>> import libbe.bugdir
218         >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
219         >>> a = ServerApp(bugdirs={bugdir.uuid: bugdir})
220         >>> a._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.')
221         'A link <a href="./a/">abc/a</a>, and a non-link #x#y#.'
222         >>> bugdir.cleanup()
223         """
224         replacer = libbe.util.id.IDreplacer(
225             self.bugdirs, self._long_to_linked_user_replacer, wrap=False)
226         return re.sub(
227             libbe.util.id.REGEXP, replacer, text)
228
229     def _long_to_linked_user_replacer(self, bugdirs, long_id):
230         """
231         >>> import libbe.bugdir
232         >>> import libbe.util.id
233         >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
234         >>> bugdirs = {bugdir.uuid: bugdir}
235         >>> a = bugdir.bug_from_uuid('a')
236         >>> uuid_gen = libbe.util.id.uuid_gen
237         >>> libbe.util.id.uuid_gen = lambda : '0123'
238         >>> c = a.new_comment('comment for link testing')
239         >>> libbe.util.id.uuid_gen = uuid_gen
240         >>> c.uuid
241         '0123'
242         >>> a = ServerApp(bugdirs=bugdirs)
243         >>> a._long_to_linked_user_replacer(bugdirs, 'abc123')
244         '#abc123#'
245         >>> a._long_to_linked_user_replacer(bugdirs, 'abc123/a')
246         '<a href="./a/">abc/a</a>'
247         >>> a._long_to_linked_user_replacer(bugdirs, 'abc123/a/0123')
248         '<a href="./a/#0123">abc/a/012</a>'
249         >>> a._long_to_linked_user_replacer(bugdirs, 'x')
250         '#x#'
251         >>> a._long_to_linked_user_replacer(bugdirs, '')
252         '##'
253         >>> bugdir.cleanup()
254         """
255         try:
256             p = libbe.util.id.parse_user(bugdirs, long_id)
257         except (libbe.util.id.MultipleIDMatches,
258                 libbe.util.id.NoIDMatches,
259                 libbe.util.id.InvalidIDStructure), e:
260             return '#%s#' % long_id # re-wrap failures
261         if p['type'] == 'bugdir':
262             return '#%s#' % long_id
263         elif p['type'] == 'bug':
264             bugdir,bug,comment = (
265                 libbe.command.util.bugdir_bug_comment_from_user_id(
266                     bugdirs, long_id))
267             return '<a href="./%s/">%s</a>' \
268                 % (self._truncated_bug_id(bug), bug.id.user())
269         elif p['type'] == 'comment':
270             bugdir,bug,comment = (
271                 libbe.command.util.bugdir_bug_comment_from_user_id(
272                     bugdirs, long_id))
273             return '<a href="./%s/#%s">%s</a>' \
274                 % (self._truncated_bug_id(bug),
275                    self._truncated_comment_id(comment),
276                    comment.id.user())
277         raise Exception('Invalid id type %s for "%s"'
278                         % (p['type'], long_id))
279
280     def _format_comment_body(self, bug, comment):
281         link_long_ids = False
282         save_body = False
283         value = comment.body
284         if comment.content_type == 'text/html':
285             link_long_ids = True
286         elif comment.content_type.startswith('text/'):
287             value = '<pre>\n'+self._escape(value)+'\n</pre>'
288             link_long_ids = True
289         elif comment.content_type.startswith('image/'):
290             save_body = True
291             value = '<img src="./%s/%s" />' % (
292                 self._truncated_bug_id(bug),
293                 self._truncated_comment_id(comment))
294         else:
295             save_body = True
296             value = '<a href="./%s/%s">Link to %s file</a>.' % (
297                 self._truncated_bug_id(bug),
298                 self._truncated_comment_id(comment),
299                 comment.content_type)
300         if link_long_ids == True:
301             value = self._long_to_linked_user(value)
302         if save_body == True:
303             per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
304             if not os.path.exists(per_bug_dir):
305                 os.mkdir(per_bug_dir)
306             comment_path = os.path.join(per_bug_dir, comment.uuid)
307             self._write_file(
308                 '<Files %s>\n  ForceType %s\n</Files>' \
309                     % (comment.uuid, comment.content_type),
310                 [per_bug_dir, '.htaccess'], mode='a')
311             self._write_file(comment.body,
312                              [per_bug_dir, comment.uuid], mode='wb')
313         return value
314
315     def _generation_time(self):
316         if self.generation_time:
317             return self.generation_time
318         return time.ctime()
319
320     def _escape(self, string):
321         if string == None:
322             return ''
323         return xml.sax.saxutils.escape(string)
324
325     def _strip_email(self, string):
326         if self.strip_email:
327             name,address = email.utils.parseaddr(string)
328             if name:
329                 return name
330             return address
331         return string
332
333     def _load_templates(self, template_dir=None):
334         if template_dir is not None:
335             template_dir = os.path.abspath(os.path.expanduser(template_dir))
336
337         self.template_dict = {
338 ##
339             'style.css':
340 """body {
341   font-family: "lucida grande", "sans serif";
342   font-size: 14px;
343   color: #333;
344   width: auto;
345   margin: auto;
346 }
347
348 div.main {
349   padding: 20px;
350   margin: auto;
351   padding-top: 0;
352   margin-top: 1em;
353   background-color: #fcfcfc;
354   border-radius: 10px;
355   
356 }
357
358 div.footer {
359   font-size: small;
360   padding-left: 20px;
361   padding-right: 20px;
362   padding-top: 5px;
363   padding-bottom: 5px;
364   margin: auto;
365   background: #305275;
366   color: #fffee7;
367   border-radius: 10px;
368 }
369
370 div.header {
371     font-size: xx-large;
372     padding-left: 20px;
373     padding-right: 20px;
374     padding-top: 10px;
375     font-weight:bold;
376     padding-bottom: 10px;
377     background: #305275;
378     color: #fffee7;
379     border-radius: 10px;
380 }
381
382 th.target_name {
383     text-align:left;
384     border: 1px solid;
385     border-color: #305275;
386     background-color: #305275;
387     color: #fff;
388     width: auto;
389     border-top-left-radius: 8px;
390     border-top-right-radius: 8px;
391     padding-left: 5px;
392     padding-right: 5px;
393 }
394
395 table {
396   border-style: solid;
397   border: 1px #c3d9ff;
398   border-spacing: 0px 0px;
399   width: auto;
400   padding: 0px;
401   
402   }
403
404 tb { border: 1px; }
405
406 tr {
407   vertical-align: top;
408   border: 1px #c3d9ff;
409   border-style: dotted;
410   width: auto;
411   padding: 0px;
412 }
413
414 th {
415     border-width: 1px;
416     border-style: solid;
417     border-color: #c3d9ff;
418     border-collapse: collapse;
419     padding-left: 5px;
420     padding-right: 5px;
421 }
422
423
424 td {
425     border-width: 1px;
426     border-color: #c3d9ff;
427     border-collapse: collapse;
428     padding-left: 5px;
429     padding-right: 5px;
430     width: auto;
431 }
432
433 img { border-style: none; }
434
435 ul {
436   list-style-type: none;
437   padding: 0;
438 }
439
440 p { width: auto; }
441
442 p.backlink {
443   width: auto;
444   font-weight: bold;
445 }
446
447 a {
448   background: inherit;
449   text-decoration: none;
450 }
451
452 a { color: #553d41; }
453 a:hover { color: #003d41; }
454 a:visited { color: #305275; }
455 .footer a { color: #508d91; }
456
457 /* bug index pages */
458
459 td.tab {
460   padding-right: 1em;
461   padding-left: 1em;
462 }
463
464 td.sel.tab {
465     background-color: #c3d9ff ;
466     border: 1px solid #c3d9ff;
467     font-weight:bold;    
468     border-top-left-radius: 15px;
469     border-top-right-radius: 15px;
470 }
471
472 td.nsel.tab { 
473     border: 1px solid #c3d9ff;
474     font-weight:bold;    
475     border-top-left-radius: 5px;
476     border-top-right-radius: 5px;
477 }
478
479 table.bug_list {
480     border-width: 1px;
481     border-style: solid;
482     border-color: #c3d9ff;
483     padding: 0px;
484     width: 100%;            
485     border: 1px solid #c3d9ff;
486 }
487
488 table.target_list {
489     padding: 0px;
490     width: 100%;
491     margin-bottom: 10px;
492 }
493
494 table.target_list.td {
495     border-width: 1px;
496 }
497
498 tr.wishlist { background-color: #DCFAFF;}
499 tr.wishlist:hover { background-color: #C2DCE1; }
500
501 tr.minor { background-color: #FFFFA6; }
502 tr.minor:hover { background-color: #E6E696; }
503
504 tr.serious { background-color: #FF9077;}
505 tr.serious:hover { background-color: #E6826B; }
506
507 tr.critical { background-color: #FF752A; }
508 tr.critical:hover { background-color: #D63905;}
509
510 tr.fatal { background-color: #FF3300;}
511 tr.fatal:hover { background-color: #D60000;}
512
513 td.uuid { width: 5%; border-style: dotted;}
514 td.status { width: 5%; border-style: dotted;}
515 td.severity { width: 5%; border-style: dotted;}
516 td.summary { border-style: dotted;}
517 td.date { width: 25%; border-style: dotted;}
518
519 /* bug detail pages */
520
521 td.bug_detail_label { text-align: right; border: none;}
522 td.bug_detail { border: none;}
523 td.bug_comment_label { text-align: right; vertical-align: top; }
524 td.bug_comment { }
525
526 div.comment {
527   padding: 20px;
528   padding-top: 20px;
529   margin: auto;
530   margin-top: 0;
531 }
532
533 div.root.comment {
534   padding: 0px;
535   /* padding-top: 0px; */
536   padding-bottom: 20px;
537 }
538 """,
539 ##
540             'base.html':
541 """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
542   "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
543 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
544   <head>
545     <title>{{ title }}</title>
546     <meta http-equiv="Content-Type" content="text/html; charset={{ charset }}" />
547     <link rel="stylesheet" href="{{ stylesheet }}" type="text/css" />
548   </head>
549   <body>
550     <div class="header">{{ header }}</div>
551     <div class="main">
552       {% block content %}{% endblock %}
553     </div>
554     <div class="footer">
555       <p>Generated by <a href="http://www.bugseverywhere.org/">
556       Bugs Everywhere</a> on {{ generation_time }}</p>
557       <p>
558         <a href="http://validator.w3.org/check?uri=referer">
559           Validate XHTML</a>&nbsp;|&nbsp;
560         <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">
561           Validate CSS</a>
562       </p>
563     </div>
564   </body>
565 </html>
566 """,
567 ##
568             'bugdirs.html':
569 """{% extends "base.html" %}
570
571 {% block content %}
572 {% if bugdirss %}
573 {% block bugdir_table %}{% endblock %}
574 {% else %}
575 <p>No bugdirs.</p>
576 {% endif %}
577 {% endblock %}
578 """,
579 ##
580             'index.html':
581 """{% extends "base.html" %}
582
583 {% block content %}
584 <table>
585   <tbody>
586     <tr>
587       <td class="{{ active_class }}"><a href="{% if index_file %}{{ index_file }}{% else %}.{% endif %}">Active Bugs</a></td>
588       <td class="{{ inactive_class }}"><a href="{{ index_file }}?type=inactive">Inactive Bugs</a></td>
589       <td class="{{ target_class }}"><a href="{{ index_file }}?type=target">Divided by target</a></td>
590     </tr>
591   </tbody>
592 </table>
593 {% if bugs %}
594 {% block bug_table %}{% endblock %}
595 {% else %}
596 <p>No bugs.</p>
597 {% endif %}
598 {% endblock %}
599 """,
600 ##
601             'standard_index.html':
602 """{% extends "index.html" %}
603
604 {% block bug_table %}
605 <table class="bug_list">
606   <thead>
607     <tr>
608       <th>UUID</th>
609       <th>Status</th>
610       <th>Severity</th>
611       <th>Summary</th>
612       <th>Date</th>
613     </tr>
614   </thead>
615   <tbody>
616     {% for bug in bugs %}
617     {{ bug_entry.render({'bug':bug, 'dir':bug_dir(bug), 'index_file':index_file}) }}
618     {% endfor %}
619   </tbody>
620 </table>
621 {% endblock %}
622 """,
623 ##
624         'target_index.html':
625 """{% extends "index.html" %}
626
627 {% block bug_table %}
628 {% for target,bugs in targets %}
629 <table class="target_list">
630   <thead>
631     <tr>
632       <th class="target_name" colspan="5">
633         Target: {{ target.summary|e }} ({{ target.status|e }})
634       </th>
635     </tr>
636     <tr>
637       <th>UUID</th>
638       <th>Status</th>
639       <th>Severity</th>
640       <th>Summary</th>
641       <th>Date</th>
642     </tr>
643   </thead>
644   <tbody>
645     {% for bug in bugs %}
646     {{ bug_entry.render({'bug':bug, 'dir':bug_dir(bug), 'index_file':index_file}) }}
647     {% endfor %}
648   </tbody>
649 </table>
650 {% endfor %}
651 {% endblock %}
652 """,
653 ##
654             'index_bug_entry.html':
655 """<tr class="{{ bug.severity }}">
656   <td class="uuid"><a href="{{ dir }}/{{ index_file }}">{{ bug.id.user()|e }}</a></td>
657   <td class="status"><a href="{{ dir }}/{{ index_file }}">{{ bug.status|e }}</a></td>
658   <td class="severity"><a href="{{ dir }}/{{ index_file }}">{{ bug.severity|e }}</a></td>
659   <td class="summary"><a href="{{ dir }}/{{ index_file }}">{{ bug.summary|e }}</a></td>
660   <td class="date"><a href="{{ dir }}/{{ index_file }}">{{ (bug.time_string or '')|e }}</a></td>
661 </tr>
662 """,
663 ##
664         'bug.html':
665 """{% extends "base.html" %}
666
667 {% block content %}
668 {{ backlinks.render({'up_link': up_link, 'index_type':index_type, 'index_file':index_file}) }}
669 <h1>Bug: {{ bug.id.user()|e }}</h1>
670
671 <table>
672   <tbody>
673     <tr><td class="bug_detail_label">ID :</td>
674         <td class="bug_detail">{{ bug.uuid|e }}</td></tr>
675     <tr><td class="bug_detail_label">Short name :</td>
676         <td class="bug_detail">{{ bug.id.user()|e }}</td></tr>
677     <tr><td class="bug_detail_label">Status :</td>
678         <td class="bug_detail">{{ bug.status|e }}</td></tr>
679     <tr><td class="bug_detail_label">Severity :</td>
680         <td class="bug_detail">{{ bug.severity|e }}</td></tr>
681     <tr><td class="bug_detail_label">Assigned :</td>
682         <td class="bug_detail">{{ strip_email(bug.assigned or '')|e }}</td></tr>
683     <tr><td class="bug_detail_label">Reporter :</td>
684         <td class="bug_detail">{{ strip_email(bug.reporter or '')|e }}</td></tr>
685     <tr><td class="bug_detail_label">Creator :</td>
686         <td class="bug_detail">{{ strip_email(bug.creator or '')|e }}</td></tr>
687     <tr><td class="bug_detail_label">Created :</td>
688         <td class="bug_detail">{{ (bug.time_string or '')|e }}</td></tr>
689 {% if target %}
690     <tr><td class="bug_detail_label">Target :</td>
691         <td class="bug_detail"><a href="../../{{ bug_dir(target) }}/{{ index_file }}">{{ target.summary }}</a></td></tr>
692 {% endif %}
693     <tr><td class="bug_detail_label">Summary :</td>
694         <td class="bug_detail">{{ bug.summary|e }}</td></tr>
695   </tbody>
696 </table>
697
698 <hr/>
699
700 {% if comments %}
701 {% for depth,comment in comments %}
702 {% if depth == 0 %}
703 <div class="comment root" id="C{{ comment_dir(comment) }}">
704 {% else %}
705 <div class="comment" id="C{{ comment_dir(comment) }}">
706 {% endif %}
707 {{ comment_entry.render({
708        'depth':depth, 'bug': bug, 'comment':comment, 'comment_dir':comment_dir,
709        'format_body': format_body, 'div_close': div_close,
710        'strip_email': strip_email}) }}
711 {{ div_close(depth) }}
712 {% endfor %}
713 {% if comments[-1][0] > 0 %}
714 {{ div_close(0) }}
715 {% endif %}
716 {% else %}
717 <p>No comments.</p>
718 {% endif %}
719 {{ backlinks.render({'up_link': up_link, 'index_type': index_type}) }}
720 {% endblock %}
721 """,
722 ##
723             'bug_backlinks.html':
724 """<p class="backlink"><a href="{{ up_link }}">Back to {{ index_type }} Index</a></p>
725 <p class="backlink"><a href="../../{{ index_file }}?type=target">Back to Target Index</a></p>
726 """,
727 ##
728             'bug_comment_entry.html':
729 """<table>
730   <tbody>
731     <tr>
732       <td class="bug_comment_label">Comment:</td>
733       <td class="bug_comment">
734         --------- Comment ---------<br/>
735         ID: {{ comment.uuid }}<br/>
736         Short name: {{ comment.id.user() }}<br/>
737         From: {{ strip_email(comment.author or '')|e }}<br/>
738         Date: {{ (comment.date or '')|e }}<br/>
739         <br/>
740         {{ format_body(bug, comment) }}
741       </td>
742     </tr>
743   </tbody>
744 </table>
745 """,
746             }
747
748         loader = DictLoader(self.template_dict)
749
750         if template_dir:
751             file_system_loader = FileSystemLoader(template_dir)
752             loader = ChoiceLoader([file_system_loader, loader])
753         self.template = Environment(loader=loader)
754
755
756 class HTML (libbe.util.wsgi.ServerCommand):
757     """Serve or dump browsable HTML for the current repository
758
759     >>> import sys
760     >>> import libbe.bugdir
761     >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
762     >>> io = libbe.command.StringInputOutput()
763     >>> io.stdout = sys.stdout
764     >>> ui = libbe.command.UserInterface(io=io)
765     >>> ui.storage_callbacks.set_storage(bugdir.storage)
766     >>> cmd = HTML(ui=ui)
767
768     >>> export_path = os.path.join(bugdir.storage.repo, 'html_export')
769     >>> ret = ui.run(cmd, {'output': export_path, 'export-html': True})
770     >>> os.path.exists(export_path)
771     True
772     >>> os.path.exists(os.path.join(export_path, 'index.html'))
773     True
774     >>> os.path.exists(os.path.join(export_path, 'index_inactive.html'))
775     True
776     >>> os.path.exists(os.path.join(export_path, bugdir.uuid))
777     True
778     >>> for bug in sorted(bugdir):
779     ...     if os.path.exists(os.path.join(
780     ...             export_path, bugdir.uuid, bug.uuid, 'index.html')):
781     ...         print('got {}'.format(bug.uuid))
782     ...     else:
783     ...         print('missing {}'.format(bug.uuid))
784     got a
785     got b
786
787     >>> ui.cleanup()
788     >>> bugdir.cleanup()
789     """
790     name = 'html'
791
792     def __init__(self, *args, **kwargs):
793         super(HTML, self).__init__(*args, **kwargs)
794         # ServerApp cannot write, so drop some security options
795         self.options = [
796             option for option in self.options
797             if option.name not in [
798                 'read-only',
799                 'notify',
800                 'auth',
801                 ]]
802
803         self.options.extend([
804                 libbe.command.Option(name='template-dir', short_name='t',
805                     help=('Use different templates.  Defaults to internal '
806                           'templates'),
807                     arg=libbe.command.Argument(
808                         name='template-dir', metavar='DIR',
809                         completion_callback=libbe.command.util.complete_path)),
810                 libbe.command.Option(name='title',
811                     help='Set the bug repository title',
812                     arg=libbe.command.Argument(
813                         name='title', metavar='STRING',
814                         default='Bugs Everywhere Issue Tracker')),
815                 libbe.command.Option(name='index-header',
816                     help='Set the index page headers',
817                     arg=libbe.command.Argument(
818                         name='index-header', metavar='STRING',
819                         default='Bugs Everywhere Bug List')),
820                 libbe.command.Option(name='min-id-length', short_name='l',
821                     help=('Attempt to truncate bug and comment IDs to this '
822                           'length.  Set to -1 for non-truncated IDs'),
823                     arg=libbe.command.Argument(
824                         name='min-id-length', metavar='INT',
825                         default=-1, type='int')),
826                 libbe.command.Option(name='strip-email',
827                     help='Strip email addresses from person fields.'),
828                 libbe.command.Option(name='export-html', short_name='e',
829                     help='Export all HTML pages and exit.'),
830                 libbe.command.Option(name='output', short_name='o',
831                     help='Set the output path for HTML export',
832                     arg=libbe.command.Argument(
833                         name='output', metavar='DIR', default='./html_export',
834                         completion_callback=libbe.command.util.complete_path)),
835                 libbe.command.Option(name='export-template', short_name='E',
836                     help='Export the default template and exit.'),
837                 libbe.command.Option(name='export-template-dir', short_name='d',
838                     help='Set the directory for the template export',
839                     arg=libbe.command.Argument(
840                         name='export-template-dir', metavar='DIR',
841                         default='./default-templates/',
842                         completion_callback=libbe.command.util.complete_path)),
843                 ])
844
845     def _run(self, **params):
846         if True in [params['export-template'], params['export-html']]:
847             app = self._get_app(
848                 logger=None, storage=None, index_file='index.html',
849                 generation_time=time.ctime(), **params)
850             if params['export-template']:
851                 self._write_default_template(
852                     template_dict=app.template_dict,
853                     out_dir=params['export-template-dir'])
854             elif params['export-html']:
855                 self._write_static_pages(app=app, out_dir=params['output'])
856             return 0
857         # provide defaults for the dropped options
858         params['read-only'] = True
859         params['notify'] = None
860         params['auth'] = None
861         return super(HTML, self)._run(**params)
862
863     def _get_app(self, logger, storage, index_file='', generation_time=None,
864                  **kwargs):
865         return ServerApp(
866             logger=logger, bugdirs=self._get_bugdirs(),
867             template_dir=kwargs['template-dir'],
868             title=kwargs['title'],
869             header=kwargs['index-header'],
870             index_file=index_file,
871             min_id_length=kwargs['min-id-length'],
872             strip_email=kwargs['strip-email'],
873             generation_time=generation_time)
874
875     def _long_help(self):
876         return """
877 Example usage::
878
879     $ be html
880
881 Then point your browser at ``http://localhost:8000/``.
882
883 If either ``--export-html`` or ``export-template`` is set, the command
884 will exit after the dump without serving anything over the wire.
885 """
886
887     def _write_default_template(self, template_dict, out_dir):
888         out_dir = self._make_dir(out_dir)
889         for filename,text in template_dict.iteritems():
890             self._write_file(text, [out_dir, filename])
891
892     def _write_static_pages(self, app, out_dir):
893         url_mappings = [
894             ('index.html?type=active', 'index.html'),
895             ('index.html?type=inactive', 'index_inactive.html'),
896             ('index.html?type=target', 'index_by_target.html'),
897             ]
898         out_dir = self._make_dir(out_dir)
899         caller = libbe.util.wsgi.WSGICaller()
900         self._write_file(
901             content=self._get_content(caller, app, 'style.css'),
902             path_array=[out_dir, 'style.css'])
903         for url,data_dict,path in [
904             ('index.html', {'type': 'active'}, 'index.html'),
905             ('index.html', {'type': 'inactive'}, 'index_inactive.html'),
906             ('index.html', {'type': 'target'}, 'index_by_target.html'),
907             ]:
908             content = self._get_content(caller, app, url, data_dict)
909             for url_,path_ in url_mappings:
910                 content = content.replace(url_, path_)
911             self._write_file(content=content, path_array=[out_dir, path])
912         for bugdir in app.bugdirs.values():
913             for bug in bugdir:
914                 bug_dir_url = app.bug_dir(bug=bug)
915                 segments = bug_dir_url.split('/')
916                 path_array = [out_dir]
917                 path_array.extend(segments)
918                 bug_dir_path = os.path.join(*path_array)
919                 path_array.append(app._index_file)
920                 url = '{}/{}'.format(bug_dir_url, app._index_file)
921                 content = self._get_content(caller, app, url)
922                 for url_,path_ in url_mappings:
923                     content = content.replace(url_, path_)
924                 if not os.path.isdir(bug_dir_path):
925                     self._make_dir(bug_dir_path)                    
926                 self._write_file(content=content, path_array=path_array)
927
928     def _get_content(self, caller, app, path, data_dict=None):
929         try:
930             return caller.getURL(app=app, path=path, data_dict=data_dict)
931         except libbe.util.wsgi.HandlerError:
932             self.stdout.write(
933                 'error retrieving {} with {}\n'.format(path, data_dict))
934             raise
935
936     def _make_dir(self, dir_path):
937         dir_path = os.path.abspath(os.path.expanduser(dir_path))
938         if not os.path.exists(dir_path):
939             try:
940                 os.makedirs(dir_path)
941             except:
942                 raise libbe.command.UserError(
943                     'Cannot create output directory "{}".'.format(dir_path))
944         return dir_path
945
946     def _write_file(self, content, path_array, mode='w'):
947         if not hasattr(self, 'encoding'):
948             self.encoding = libbe.util.encoding.get_text_file_encoding()
949         return libbe.util.encoding.set_file_contents(
950             os.path.join(*path_array), content, mode, self.encoding)
951
952
953 Html = HTML # alias for libbe.command.base.get_command_class()
954
955
956 class _DivCloser (object):
957     def __init__(self, depth=0):
958         self.depth = depth
959
960     def __call__(self, depth):
961         ret = []
962         while self.depth >= depth:
963             self.depth -= 1
964             ret.append('</div>')
965         self.depth = depth
966         return '\n'.join(ret)