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