Package trac ::
Package web ::
Module chrome
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (C) 2005-2009 Edgewall Software
4 # Copyright (C) 2005-2006 Christopher Lenz <cmlenz@gmx.de>
5 # All rights reserved.
6 #
7 # This software is licensed as described in the file COPYING, which
8 # you should have received as part of this distribution. The terms
9 # are also available at http://trac.edgewall.org/wiki/TracLicense.
10 #
11 # This software consists of voluntary contributions made by many
12 # individuals. For the exact contribution history, see the revision
13 # history and logs, available at http://trac.edgewall.org/log/.
14 #
15 # Author: Christopher Lenz <cmlenz@gmx.de>
16
17 import datetime
18 import itertools
19 import os.path
20 import pkg_resources
21 import pprint
22 import re
23 try:
24 from cStringIO import StringIO
25 except ImportError:
26 from StringIO import StringIO
27
28 from genshi import Markup
29 from genshi.builder import tag , Element
30 from genshi.core import Attrs, START
31 from genshi.filters import Translator
32 from genshi.output import DocType
33 from genshi.template import TemplateLoader, MarkupTemplate, NewTextTemplate
34
35 from trac import __version__ as VERSION
36 from trac .config import *
37 from trac .core import *
38 from trac .env import IEnvironmentSetupParticipant , ISystemInfoProvider
39 from trac .mimeview import get_mimetype , Context
40 from trac .resource import *
41 from trac .util import compat , get_reporter_id , presentation , get_pkginfo , \
42 pathjoin , translation
43 from trac .util .compat import any, partial
44 from trac .util .html import escape , plaintext
45 from trac .util .text import pretty_size , obfuscate_email_address , \
46 shorten_line , unicode_quote_plus , to_unicode , \
47 javascript_quote , exception_to_unicode
48 from trac .util .datefmt import pretty_timedelta , format_datetime , format_date , \
49 format_time , from_utimestamp , http_date , utc
50 from trac .util .translation import _ , get_available_locales
51 from trac .web .api import IRequestHandler , ITemplateStreamFilter , HTTPNotFound
52 from trac .web .href import Href
53 from trac .wiki import IWikiSyntaxProvider
54 from trac .wiki .formatter import format_to, format_to_html, format_to_oneliner
55
56
62
63 - def add_link (req, rel, href, title=None, mimetype=None, classname=None,
64 **attrs):
65 """Add a link to the chrome info that will be inserted as <link> element in
66 the <head> of the generated HTML
67 """
68 linkid = '%s:%s' % (rel, href )
69 linkset = req.chrome .setdefault('linkset', set ())
70 if linkid in linkset:
71 return # Already added that link
72
73 link = {'href': href , 'title': title , 'type': mimetype, 'class': classname}
74 link.update (attrs)
75 links = req.chrome .setdefault('links', {})
76 links.setdefault(rel, []).append(link)
77 linkset.add(linkid)
78
80 """Add a link to a style sheet to the chrome info so that it gets included
81 in the generated HTML page.
82
83 If the filename is absolute (i.e. starts with a slash), the generated link
84 will be based off the application root path. If it is relative, the link
85 will be based off the `/chrome/` path.
86 """
87 if filename .startswith('common/') and 'htdocs_location' in req.chrome :
88 href = Href (req.chrome ['htdocs_location'])
89 filename = filename [7:]
90 else:
91 href = req.href
92 if not filename .startswith('/'):
93 href = href .chrome
94 add_link (req, 'stylesheet', href (filename ), mimetype=mimetype, media=media)
95
96 - def add_script (req, filename, mimetype='text/javascript'):
97 """Add a reference to an external javascript file to the template.
98
99 If the filename is absolute (i.e. starts with a slash), the generated link
100 will be based off the application root path. If it is relative, the link
101 will be based off the `/chrome/` path.
102 """
103 scriptset = req.chrome .setdefault('scriptset', set ())
104 if filename in scriptset:
105 return False # Already added that script
106
107 if filename .startswith('common/') and 'htdocs_location' in req.chrome :
108 href = Href (req.chrome ['htdocs_location'])
109 path = filename [7:]
110 else:
111 href = req.href
112 if not filename .startswith('/'):
113 href = href .chrome
114 path = filename
115 script = {'href': href (path ), 'type': mimetype}
116
117 req.chrome .setdefault('scripts', []).append(script)
118 scriptset.add(filename )
119
121 """Add data to be made available in javascript scripts as global variables.
122
123 The keys in `data` provide the names of the global variables. The values
124 are converted to JSON and assigned to the corresponding variables.
125 """
126 req.chrome .setdefault('script_data', {}).update (data )
127
129 """Deprecated: use `add_script()` instead."""
130 add_script (req, filename , mimetype='text/javascript')
131
133 """Add a non-fatal warning to the request object.
134 When rendering pages, any warnings will be rendered to the user."""
135 if args :
136 msg %= args
137 req.chrome ['warnings'].append(msg)
138
140 """Add an informational notice to the request object.
141 When rendering pages, any notice will be rendered to the user."""
142 if args :
143 msg %= args
144 req.chrome ['notices'].append(msg)
145
146 - def add_ctxtnav (req, elm_or_label, href=None, title=None):
147 """Add an entry to the current page's ctxtnav bar."""
148 if href :
149 elm = tag .a(elm_or_label, href =href , title =title )
150 else:
151 elm = elm_or_label
152 req.chrome .setdefault('ctxtnav', []).append(elm)
153
154 - def prevnext_nav (req, prev_label, next_label, up_label=None):
155 """Add Previous/Up/Next navigation links.
156
157 @param req a `Request` object
158 @param prev_label the label to use for left (previous) link
159 @param up_label the label to use for the middle (up) link
160 @param next_label the label to use for right (next) link
161 """
162 links = req.chrome ['links']
163 prev_link = next_link = None
164
165 if not any(lnk in links for lnk in ('prev', 'up', 'next')): # Short circuit
166 return
167
168 if 'prev' in links:
169 prev = links['prev'][0]
170 prev_link = tag .a(prev_label, href =prev['href'], title =prev['title'],
171 class_='prev')
172
173 add_ctxtnav (req, tag .span(Markup('← '), prev_link or prev_label,
174 class_=not prev_link and 'missing' or None))
175
176 if up_label and 'up' in links:
177 up = links['up'][0]
178 add_ctxtnav (req, tag .a(up_label, href =up['href'], title =up['title']))
179
180 if 'next' in links:
181 next_ = links['next'][0]
182 next_link = tag .a(next_label, href =next_['href'], title =next_['title'],
183 class_='next')
184
185 add_ctxtnav (req, tag .span(next_link or next_label, Markup(' →'),
186 class_=not next_link and 'missing' or None))
187
188
190 """Save warnings and notices in case of redirect, so that they can
191 be displayed after the redirect."""
192 for type_ in ['warnings', 'notices']:
193 for (i , message ) in enumerate(req.chrome [type_]):
194 req.session ['chrome.%s.%d' % (type_, i )] = escape (message )
195
196
198 """Extension point interface for components that contribute items to the
199 navigation.
200 """
201
203 """This method is only called for the `IRequestHandler` processing the
204 request.
205
206 It should return the name of the navigation item that should be
207 highlighted as active/current.
208 """
209
211 """Should return an iterable object over the list of navigation items to
212 add, each being a tuple in the form (category, name, text).
213 """
214
215
217 """Extension point interface for components that provide their own
218 ClearSilver templates and accompanying static resources.
219 """
220
222 """Return a list of directories with static resources (such as style
223 sheets, images, etc.)
224
225 Each item in the list must be a `(prefix, abspath)` tuple. The
226 `prefix` part defines the path in the URL that requests to these
227 resources are prefixed with.
228
229 The `abspath` is the absolute path to the directory containing the
230 resources on the local file system.
231 """
232
234 """Return a list of directories containing the provided template
235 files.
236 """
237
238
239 # Mappings for removal of control characters
240 _translate_nop = "".join ([chr(i ) for i in range(256)])
241 _invalid_control_chars = "".join ([chr(i ) for i in range(32)
242 if i not in [0x09, 0x0a, 0x0d]])
243
244
246 """Web site chrome assembly manager.
247
248 Chrome is everything that is not actual page content.
249 """
250 required = True
251
252 implements (ISystemInfoProvider, IEnvironmentSetupParticipant ,
253 IRequestHandler , ITemplateProvider , IWikiSyntaxProvider )
254
255 navigation_contributors = ExtensionPoint (INavigationContributor )
256 template_providers = ExtensionPoint (ITemplateProvider )
257 stream_filters = ExtensionPoint (ITemplateStreamFilter )
258
259 shared_templates_dir = PathOption ('inherit', 'templates_dir', '',
260 """Path to the //shared templates directory//.
261
262 Templates in that directory are loaded in addition to those in the
263 environments `templates` directory, but the latter take precedence.
264
265 (''since 0.11'')""")
266
267 auto_reload = BoolOption ('trac', 'auto_reload', False,
268 """Automatically reload template files after modification.""")
269
270 genshi_cache_size = IntOption ('trac', 'genshi_cache_size', 128,
271 """The maximum number of templates that the template loader will cache
272 in memory. The default value is 128. You may want to choose a higher
273 value if your site uses a larger number of templates, and you have
274 enough memory to spare, or you can reduce it if you are short on
275 memory.""")
276
277 htdocs_location = Option ('trac', 'htdocs_location', '',
278 """Base URL for serving the core static resources below
279 `/chrome/common/`.
280
281 It can be left empty, and Trac will simply serve those resources
282 itself.
283
284 Advanced users can use this together with
285 [TracAdmin trac-admin ... deploy <deploydir>] to allow serving the
286 static resources for Trac directly from the web server.
287 Note however that this only applies to the `<deploydir>/htdocs/common`
288 directory, the other deployed resources (i.e. those from plugins)
289 will not be made available this way and additional rewrite
290 rules will be needed in the web server.""")
291
292 metanav_order = ListOption ('trac', 'metanav',
293 'login,logout,prefs,help,about', doc=
294 """Order of the items to display in the `metanav` navigation bar,
295 listed by IDs. See also TracNavigation.""")
296
297 mainnav_order = ListOption ('trac', 'mainnav',
298 'wiki,timeline,roadmap,browser,tickets,'
299 'newticket,search', doc=
300 """Order of the items to display in the `mainnav` navigation bar,
301 listed by IDs. See also TracNavigation.""")
302
303 logo_link = Option ('header_logo', 'link', '',
304 """URL to link to, from the header logo.""")
305
306 logo_src = Option ('header_logo', 'src', 'site/your_project_logo.png',
307 """URL of the image to use as header logo.
308 It can be absolute, server relative or relative.
309
310 If relative, it is relative to one of the `/chrome` locations:
311 `site/your-logo.png` if `your-logo.png` is located in the `htdocs`
312 folder within your TracEnvironment;
313 `common/your-logo.png` if `your-logo.png` is located in the
314 folder mapped to the [#trac-section htdocs_location] URL.
315 Only specifying `your-logo.png` is equivalent to the latter.""")
316
317 logo_alt = Option ('header_logo', 'alt',
318 "(please configure the [header_logo] section in trac.ini)",
319 """Alternative text for the header logo.""")
320
321 logo_width = IntOption ('header_logo', 'width', -1,
322 """Width of the header logo image in pixels.""")
323
324 logo_height = IntOption ('header_logo', 'height', -1,
325 """Height of the header logo image in pixels.""")
326
327 show_email_addresses = BoolOption ('trac', 'show_email_addresses', 'false',
328 """Show email addresses instead of usernames. If false, we obfuscate
329 email addresses. (''since 0.11'')""")
330
331 never_obfuscate_mailto = BoolOption ('trac', 'never_obfuscate_mailto',
332 'false',
333 """Never obfuscate `mailto:` links explicitly written in the wiki,
334 even if `show_email_addresses` is false or the user has not the
335 EMAIL_VIEW permission (''since 0.11.6'').""")
336
337 show_ip_addresses = BoolOption ('trac', 'show_ip_addresses', 'false',
338 """Show IP addresses for resource edits (e.g. wiki).
339 (''since 0.11.3'')""")
340
341 resizable_textareas = BoolOption ('trac', 'resizable_textareas', 'true',
342 """Make `<textarea>` fields resizable. Requires !JavaScript.
343 (''since 0.12'')""")
344
345 auto_preview_timeout = FloatOption ('trac', 'auto_preview_timeout', 2.0,
346 """Inactivity timeout in seconds after which the automatic wiki preview
347 triggers an update. This option can contain floating-point values. The
348 lower the setting, the more requests will be made to the server. Set
349 this to 0 to disable automatic preview. The default is 2.0 seconds.
350 (''since 0.12'')""")
351
352 templates = None
353
354 # A dictionary of default context data for templates
355 _default_context_data = {
356 '_': translation .gettext,
357 'all': compat .all,
358 'any': compat .any,
359 'classes': presentation .classes ,
360 'date': datetime.date,
361 'datetime': datetime.datetime,
362 'dgettext': translation .dgettext,
363 'dngettext': translation .dngettext,
364 'first_last': presentation .first_last ,
365 'get_reporter_id': get_reporter_id ,
366 'gettext': translation .gettext,
367 'group': presentation .group ,
368 'groupby': compat .py_groupby , # http://bugs.python.org/issue2246
369 'http_date': http_date ,
370 'istext': presentation .istext ,
371 'javascript_quote': javascript_quote ,
372 'ngettext': translation .ngettext ,
373 'paginate': presentation .paginate ,
374 'partial': partial,
375 'pathjoin': pathjoin ,
376 'plaintext': plaintext ,
377 'pprint': pprint.pformat,
378 'pretty_size': pretty_size ,
379 'pretty_timedelta': pretty_timedelta ,
380 'quote_plus': unicode_quote_plus ,
381 'reversed': reversed,
382 'separated': presentation .separated,
383 'shorten_line': shorten_line ,
384 'sorted': sorted,
385 'time': datetime.time,
386 'timedelta': datetime.timedelta,
387 'to_json': presentation .to_json,
388 'to_unicode': to_unicode ,
389 'utc': utc ,
390 }
391
392 # ISystemInfoProvider methods
393
395 import genshi
396 info = get_pkginfo (genshi).get ('version')
397 if hasattr(genshi, '_speedups'):
398 info += ' (with speedups)'
399 else:
400 info += ' (without speedups)'
401 yield 'Genshi', info
402 try:
403 import babel
404 except ImportError:
405 babel = None
406 if babel is not None:
407 info = get_pkginfo (babel).get ('version')
408 if not get_available_locales():
409 info += " (translations unavailable)" # No i18n on purpose
410 self.log .warning("Locale data is missing")
411 yield 'Babel', info
412
413 # IEnvironmentSetupParticipant methods
414
416 """Create the environment templates directory."""
417 if self.env .path :
418 templates_dir = os.path .join (self.env .path , 'templates')
419 if not os.path .exists (templates_dir):
420 os.mkdir(templates_dir)
421
422 site_path = os.path .join (templates_dir, 'site.html.sample')
423 fileobj = open (site_path, 'w')
424 try:
425 fileobj.write ("""\
426 <html xmlns="http://www.w3.org/1999/xhtml"
427 xmlns:xi="http://www.w3.org/2001/XInclude"
428 xmlns:py="http://genshi.edgewall.org/"
429 py:strip="">
430 <!--!
431 This file allows customizing the appearance of the Trac installation.
432 Add your customizations here and rename the file to site.html. Note that
433 it will take precedence over a global site.html placed in the directory
434 specified by [inherit] templates_dir.
435
436 More information about site appearance customization can be found here:
437
438 http://trac.edgewall.org/wiki/TracInterfaceCustomization#SiteAppearance
439 -->
440 </html>
441 """)
442 finally:
443 fileobj.close ()
444
446 return False
447
449 pass
450
451 # IRequestHandler methods
452
454 match = re.match(r'/chrome/(?P<prefix>[^/]+)/+(?P<filename>.+)',
455 req.path_info )
456 if match:
457 req.args ['prefix'] = match.group ('prefix')
458 req.args ['filename'] = match.group ('filename')
459 return True
460
480
481 # ITemplateProvider methods
482
484 return [('common', pkg_resources.resource_filename('trac', 'htdocs')),
485 ('site', self.env .get_htdocs_dir ())]
486
493
494 # IWikiSyntaxProvider methods
495
497 return []
498
500 yield ('htdocs', self._format_link)
501
506
507 # Public API methods
508
515
517 """Prepare the basic chrome data for the request.
518
519 @param req: the request object
520 @param handler: the `IRequestHandler` instance that is processing the
521 request
522 """
523 self.log .debug('Prepare chrome data for request')
524
525 chrome = {'metas': [], 'links': {}, 'scripts': [], 'script_data': {},
526 'ctxtnav': [], 'warnings': [], 'notices': []}
527 setattr(req, 'chrome', chrome )
528
529 htdocs_location = self.htdocs_location or req.href .chrome ('common')
530 chrome ['htdocs_location'] = htdocs_location .rstrip('/') + '/'
531
532 # HTML <head> links
533 add_link (req, 'start', req.href .wiki ())
534 add_link (req, 'search', req.href .search ())
535 add_link (req, 'help', req.href .wiki ('TracGuide'))
536 add_stylesheet (req, 'common/css/trac.css')
537 add_script (req, 'common/js/jquery.js')
538 # Only activate noConflict mode if requested to by the handler
539 if handler is not None and \
540 getattr(handler .__class__, 'jquery_noconflict', False):
541 add_script (req, 'common/js/noconflict.js')
542 add_script (req, 'common/js/babel.js')
543 if req.locale is not None:
544 add_script (req, 'common/js/messages/%s.js' % req.locale )
545 add_script (req, 'common/js/trac.js')
546 add_script (req, 'common/js/search.js')
547
548 # Shortcut icon
549 chrome ['icon'] = self.get_icon_data (req)
550 if chrome ['icon']:
551 src = chrome ['icon']['src']
552 mimetype = chrome ['icon']['mimetype']
553 add_link (req, 'icon', src, mimetype=mimetype)
554 add_link (req, 'shortcut icon', src, mimetype=mimetype)
555
556 # Logo image
557 chrome ['logo'] = self.get_logo_data (req.href , req.abs_href )
558
559 # Navigation links
560 allitems = {}
561 active = None
562 for contributor in self.navigation_contributors :
563 try:
564 for category, name , text in \
565 contributor.get_navigation_items (req) or []:
566 category_section = self.config [category]
567 if category_section.getbool (name , True):
568 # the navigation item is enabled (this is the default)
569 item = None
570 if isinstance(text , Element) and \
571 text .tag .localname == 'a':
572 item = text
573 label = category_section.get (name + '.label')
574 href = category_section.get (name + '.href')
575 if href :
576 if href .startswith('/'):
577 href = req.href + href
578 if label:
579 item = tag .a(label) # create new label
580 elif not item:
581 item = tag .a(text ) # wrap old text
582 item = item(href =href ) # use new href
583 elif label and item: # create new label, use old href
584 item = tag .a(label, href =item.attrib.get ('href'))
585 elif not item: # use old text
586 item = text
587 allitems.setdefault(category, {})[name ] = item
588 if contributor is handler :
589 active = contributor.get_active_navigation_item (req)
590 except Exception, e :
591 name = contributor.__class__.__name__
592 if isinstance(e , TracError ):
593 self.log .warning("Error with navigation contributor %s",
594 name )
595 else:
596 self.log .error ("Error with navigation contributor %s: %s",
597 name , exception_to_unicode (e ))
598 add_warning (req, _ ("Error with navigation contributor "
599 '"%(name)s"', name =name ))
600
601 nav = {}
602 for category, items in [(k, v.items()) for k, v in allitems.items()]:
603 category_order = category + '_order'
604 if hasattr(self, category_order):
605 order = getattr(self, category_order)
606 def navcmp(x, y):
607 if x [0] not in order:
608 return int(y[0] in order)
609 if y[0] not in order:
610 return -int(x [0] in order)
611 return cmp(order.index(x [0]), order.index(y[0]))
612 items.sort(navcmp)
613
614 nav[category] = []
615 for name , label in items:
616 nav[category].append({
617 'name': name ,
618 'label': label,
619 'active': name == active
620 })
621
622 chrome ['nav'] = nav
623
624 # Default theme file
625 chrome ['theme'] = 'theme.html'
626
627 # Avoid recursion by registering as late as possible (#8583)
628 req.add_redirect_listener (_save_messages)
629
630 return chrome
631
633 icon = {}
634 icon_src = icon_abs_src = self.env .project_icon
635 if icon_src:
636 if not icon_src.startswith('/') and icon_src.find('://') == -1:
637 if '/' in icon_src:
638 icon_abs_src = req.abs_href .chrome (icon_src)
639 icon_src = req.href .chrome (icon_src)
640 else:
641 icon_abs_src = req.abs_href .chrome ('common', icon_src)
642 icon_src = req.href .chrome ('common', icon_src)
643 mimetype = get_mimetype (icon_src)
644 icon = {'src': icon_src, 'abs_src': icon_abs_src,
645 'mimetype': mimetype}
646 return icon
647
677
679 """Add chrome-related data to the HDF (deprecated)."""
680 req.hdf['HTTP.PathInfo'] = req.path_info
681 req.hdf['htdocs_location'] = req.chrome ['htdocs_location']
682
683 req.hdf['chrome.href'] = req.href .chrome ()
684 req.hdf['chrome.links'] = req.chrome ['links']
685 req.hdf['chrome.scripts'] = req.chrome ['scripts']
686 req.hdf['chrome.logo'] = req.chrome ['logo']
687
688 for category, items in req.chrome ['nav'].items():
689 req.hdf['chrome.nav.%s' % category] = items
690
692 d = self._default_context_data .copy()
693 d ['trac'] = {
694 'version': VERSION,
695 'homepage': 'http://trac.edgewall.org/', # FIXME: use setup data
696 }
697
698 href = req and req.href
699 abs_href = req and req.abs_href or self.env .abs_href
700 admin_href = None
701 if self.env .project_admin_trac_url == '.':
702 admin_href = href
703 elif self.env .project_admin_trac_url :
704 admin_href = Href (self.env .project_admin_trac_url )
705
706 d ['project'] = {
707 'name': self.env .project_name ,
708 'descr': self.env .project_description ,
709 'url': self.env .project_url ,
710 'admin': self.env .project_admin ,
711 'admin_href': admin_href,
712 'admin_trac_url': self.env .project_admin_trac_url ,
713 }
714 footer = self.env .project_footer
715 d ['chrome'] = {
716 'footer': Markup(footer and translation .gettext(footer))
717 }
718 if req:
719 d ['chrome'].update (req.chrome )
720 else:
721 d ['chrome'].update ({
722 'htdocs_location': self.htdocs_location ,
723 'logo': self.get_logo_data (self.env .abs_href ),
724 })
725
726 try:
727 show_email_addresses = (self.show_email_addresses or not req or \
728 'EMAIL_VIEW' in req.perm )
729 except Exception, e :
730 # simply log the exception here, as we might already be rendering
731 # the error page
732 self.log .error ("Error during check of EMAIL_VIEW: %s",
733 exception_to_unicode (e ))
734 show_email_addresses = False
735 tzinfo = None
736 if req:
737 tzinfo = req.tz
738
739 def dateinfo(date):
740 return tag .span(pretty_timedelta (date),
741 title =format_datetime (date))
742
743 def get_rel_url(resource, **kwargs):
744 return get_resource_url (self.env , resource , href , **kwargs)