command:html: Rework comment <div> closing
[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 {{ div_close(depth) }}
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 {% endfor %}
712 {{ div_close(0) }}
713 {% else %}
714 <p>No comments.</p>
715 {% endif %}
716 {{ backlinks.render({'up_link': up_link, 'index_type': index_type}) }}
717 {% endblock %}
718 """,
719 ##
720             'bug_backlinks.html':
721 """<p class="backlink"><a href="{{ up_link }}">Back to {{ index_type }} Index</a></p>
722 <p class="backlink"><a href="../../{{ index_file }}?type=target">Back to Target Index</a></p>
723 """,
724 ##
725             'bug_comment_entry.html':
726 """<table>
727   <tbody>
728     <tr>
729       <td class="bug_comment_label">Comment:</td>
730       <td class="bug_comment">
731         --------- Comment ---------<br/>
732         ID: {{ comment.uuid }}<br/>
733         Short name: {{ comment.id.user() }}<br/>
734         From: {{ strip_email(comment.author or '')|e }}<br/>
735         Date: {{ (comment.date or '')|e }}<br/>
736         <br/>
737         {{ format_body(bug, comment) }}
738       </td>
739     </tr>
740   </tbody>
741 </table>
742 """,
743             }
744
745         loader = DictLoader(self.template_dict)
746
747         if template_dir:
748             file_system_loader = FileSystemLoader(template_dir)
749             loader = ChoiceLoader([file_system_loader, loader])
750         self.template = Environment(loader=loader)
751
752
753 class HTML (libbe.util.wsgi.ServerCommand):
754     """Serve or dump browsable HTML for the current repository
755
756     >>> import sys
757     >>> import libbe.bugdir
758     >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
759     >>> io = libbe.command.StringInputOutput()
760     >>> io.stdout = sys.stdout
761     >>> ui = libbe.command.UserInterface(io=io)
762     >>> ui.storage_callbacks.set_storage(bugdir.storage)
763     >>> cmd = HTML(ui=ui)
764
765     >>> export_path = os.path.join(bugdir.storage.repo, 'html_export')
766     >>> ret = ui.run(cmd, {'output': export_path, 'export-html': True})
767     >>> os.path.exists(export_path)
768     True
769     >>> os.path.exists(os.path.join(export_path, 'index.html'))
770     True
771     >>> os.path.exists(os.path.join(export_path, 'index_inactive.html'))
772     True
773     >>> os.path.exists(os.path.join(export_path, bugdir.uuid))
774     True
775     >>> for bug in sorted(bugdir):
776     ...     if os.path.exists(os.path.join(
777     ...             export_path, bugdir.uuid, bug.uuid, 'index.html')):
778     ...         print('got {}'.format(bug.uuid))
779     ...     else:
780     ...         print('missing {}'.format(bug.uuid))
781     got a
782     got b
783
784     >>> ui.cleanup()
785     >>> bugdir.cleanup()
786     """
787     name = 'html'
788
789     def __init__(self, *args, **kwargs):
790         super(HTML, self).__init__(*args, **kwargs)
791         # ServerApp cannot write, so drop some security options
792         self.options = [
793             option for option in self.options
794             if option.name not in [
795                 'read-only',
796                 'notify',
797                 'auth',
798                 ]]
799
800         self.options.extend([
801                 libbe.command.Option(name='template-dir', short_name='t',
802                     help=('Use different templates.  Defaults to internal '
803                           'templates'),
804                     arg=libbe.command.Argument(
805                         name='template-dir', metavar='DIR',
806                         completion_callback=libbe.command.util.complete_path)),
807                 libbe.command.Option(name='title',
808                     help='Set the bug repository title',
809                     arg=libbe.command.Argument(
810                         name='title', metavar='STRING',
811                         default='Bugs Everywhere Issue Tracker')),
812                 libbe.command.Option(name='index-header',
813                     help='Set the index page headers',
814                     arg=libbe.command.Argument(
815                         name='index-header', metavar='STRING',
816                         default='Bugs Everywhere Bug List')),
817                 libbe.command.Option(name='min-id-length', short_name='l',
818                     help=('Attempt to truncate bug and comment IDs to this '
819                           'length.  Set to -1 for non-truncated IDs'),
820                     arg=libbe.command.Argument(
821                         name='min-id-length', metavar='INT',
822                         default=-1, type='int')),
823                 libbe.command.Option(name='strip-email',
824                     help='Strip email addresses from person fields.'),
825                 libbe.command.Option(name='export-html', short_name='e',
826                     help='Export all HTML pages and exit.'),
827                 libbe.command.Option(name='output', short_name='o',
828                     help='Set the output path for HTML export',
829                     arg=libbe.command.Argument(
830                         name='output', metavar='DIR', default='./html_export',
831                         completion_callback=libbe.command.util.complete_path)),
832                 libbe.command.Option(name='export-template', short_name='E',
833                     help='Export the default template and exit.'),
834                 libbe.command.Option(name='export-template-dir', short_name='d',
835                     help='Set the directory for the template export',
836                     arg=libbe.command.Argument(
837                         name='export-template-dir', metavar='DIR',
838                         default='./default-templates/',
839                         completion_callback=libbe.command.util.complete_path)),
840                 ])
841
842     def _run(self, **params):
843         if True in [params['export-template'], params['export-html']]:
844             app = self._get_app(
845                 logger=None, storage=None, index_file='index.html',
846                 generation_time=time.ctime(), **params)
847             if params['export-template']:
848                 self._write_default_template(
849                     template_dict=app.template_dict,
850                     out_dir=params['export-template-dir'])
851             elif params['export-html']:
852                 self._write_static_pages(app=app, out_dir=params['output'])
853             return 0
854         # provide defaults for the dropped options
855         params['read-only'] = True
856         params['notify'] = None
857         params['auth'] = None
858         return super(HTML, self)._run(**params)
859
860     def _get_app(self, logger, storage, index_file='', generation_time=None,
861                  **kwargs):
862         return ServerApp(
863             logger=logger, bugdirs=self._get_bugdirs(),
864             template_dir=kwargs['template-dir'],
865             title=kwargs['title'],
866             header=kwargs['index-header'],
867             index_file=index_file,
868             min_id_length=kwargs['min-id-length'],
869             strip_email=kwargs['strip-email'],
870             generation_time=generation_time)
871
872     def _long_help(self):
873         return """
874 Example usage::
875
876     $ be html
877
878 Then point your browser at ``http://localhost:8000/``.
879
880 If either ``--export-html`` or ``export-template`` is set, the command
881 will exit after the dump without serving anything over the wire.
882 """
883
884     def _write_default_template(self, template_dict, out_dir):
885         out_dir = self._make_dir(out_dir)
886         for filename,text in template_dict.iteritems():
887             self._write_file(text, [out_dir, filename])
888
889     def _write_static_pages(self, app, out_dir):
890         url_mappings = [
891             ('index.html?type=active', 'index.html'),
892             ('index.html?type=inactive', 'index_inactive.html'),
893             ('index.html?type=target', 'index_by_target.html'),
894             ]
895         out_dir = self._make_dir(out_dir)
896         caller = libbe.util.wsgi.WSGICaller()
897         self._write_file(
898             content=self._get_content(caller, app, 'style.css'),
899             path_array=[out_dir, 'style.css'])
900         for url,data_dict,path in [
901             ('index.html', {'type': 'active'}, 'index.html'),
902             ('index.html', {'type': 'inactive'}, 'index_inactive.html'),
903             ('index.html', {'type': 'target'}, 'index_by_target.html'),
904             ]:
905             content = self._get_content(caller, app, url, data_dict)
906             for url_,path_ in url_mappings:
907                 content = content.replace(url_, path_)
908             self._write_file(content=content, path_array=[out_dir, path])
909         for bugdir in app.bugdirs.values():
910             for bug in bugdir:
911                 bug_dir_url = app.bug_dir(bug=bug)
912                 segments = bug_dir_url.split('/')
913                 path_array = [out_dir]
914                 path_array.extend(segments)
915                 bug_dir_path = os.path.join(*path_array)
916                 path_array.append(app._index_file)
917                 url = '{}/{}'.format(bug_dir_url, app._index_file)
918                 content = self._get_content(caller, app, url)
919                 for url_,path_ in url_mappings:
920                     content = content.replace(url_, path_)
921                 if not os.path.isdir(bug_dir_path):
922                     self._make_dir(bug_dir_path)                    
923                 self._write_file(content=content, path_array=path_array)
924
925     def _get_content(self, caller, app, path, data_dict=None):
926         try:
927             return caller.getURL(app=app, path=path, data_dict=data_dict)
928         except libbe.util.wsgi.HandlerError:
929             self.stdout.write(
930                 'error retrieving {} with {}\n'.format(path, data_dict))
931             raise
932
933     def _make_dir(self, dir_path):
934         dir_path = os.path.abspath(os.path.expanduser(dir_path))
935         if not os.path.exists(dir_path):
936             try:
937                 os.makedirs(dir_path)
938             except:
939                 raise libbe.command.UserError(
940                     'Cannot create output directory "{}".'.format(dir_path))
941         return dir_path
942
943     def _write_file(self, content, path_array, mode='w'):
944         if not hasattr(self, 'encoding'):
945             self.encoding = libbe.util.encoding.get_text_file_encoding()
946         return libbe.util.encoding.set_file_contents(
947             os.path.join(*path_array), content, mode, self.encoding)
948
949
950 Html = HTML # alias for libbe.command.base.get_command_class()
951
952
953 class _DivCloser (object):
954     def __init__(self, depth=-1):
955         self.depth = depth
956
957     def __call__(self, depth):
958         ret = []
959         while self.depth >= depth:
960             self.depth -= 1
961             ret.append('</div>')
962         self.depth = depth
963         return '\n'.join(ret)