1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 import re
19 from datetime import datetime, timedelta
20 from fnmatch import fnmatchcase
21
22 from genshi.builder import tag
23
24 from trac.config import BoolOption, ListOption, Option
25 from trac.core import *
26 from trac.mimeview.api import IHTMLPreviewAnnotator, Mimeview, is_binary
27 from trac.perm import IPermissionRequestor, PermissionError
28 from trac.resource import Resource, ResourceNotFound
29 from trac.util import as_bool, embedded_numbers
30 from trac.util.datefmt import datetime_now, http_date, to_datetime, utc
31 from trac.util.html import Markup, escape
32 from trac.util.text import exception_to_unicode, shorten_line
33 from trac.util.translation import _, cleandoc_
34 from trac.versioncontrol.api import NoSuchChangeset, RepositoryManager
35 from trac.versioncontrol.web_ui.util import *
36 from trac.web.api import IRequestHandler, RequestDone
37 from trac.web.chrome import (INavigationContributor, add_ctxtnav, add_link,
38 add_script, add_stylesheet, prevnext_nav,
39 web_context)
40 from trac.wiki.api import IWikiMacroProvider, IWikiSyntaxProvider, parse_args
41 from trac.wiki.formatter import format_to_html, format_to_oneliner
42
43
44 CHUNK_SIZE = 4096
45
46
48 """Render node properties in TracBrowser and TracChangeset views."""
49
51 """Indicate whether this renderer can treat the given property
52
53 `mode` is the current rendering context, which can be:
54 - 'browser' rendered in the browser view
55 - 'changeset' rendered in the changeset view as a node property
56 - 'revprop' rendered in the changeset view as a revision property
57
58 Other identifiers might be used by plugins, so it's advised to simply
59 ignore unknown modes.
60
61 Returns a quality number, ranging from 0 (unsupported) to 9
62 (''perfect'' match).
63 """
64
66 """Render the given property.
67
68 `name` is the property name as given to `match()`,
69 `mode` is the same as for `match_property`,
70 `context` is the context for the node being render
71 (useful when the rendering depends on the node kind) and
72 `props` is the collection of the corresponding properties
73 (i.e. the `node.get_properties()`).
74
75 The rendered result can be one of the following:
76 - `None`: the property will be skipped
77 - an `unicode` value: the property will be displayed as text
78 - a `RenderedProperty` instance: the property will only be displayed
79 using the instance's `content` attribute, and the other attributes
80 will also be used in some display contexts (like `revprop`)
81 - `Markup` or other Genshi content: the property will be displayed
82 normally, using that content as a block-level markup
83 """
84
85
87 - def __init__(self, name=None, name_attributes=None,
88 content=None, content_attributes=None):
89 self.name = name
90 self.name_attributes = name_attributes
91 self.content = content
92 self.content_attributes = content_attributes
93
94
96 """Default version control property renderer."""
97
98 implements(IPropertyRenderer)
99
102
104
105 value = props[name]
106 if value and '\n' in value:
107 value = Markup(''.join(['<br />%s' % escape(v)
108 for v in value.split('\n')]))
109 return value
110
111
113 """Wiki text property renderer."""
114
115 implements(IPropertyRenderer)
116
117 wiki_properties = ListOption('browser', 'wiki_properties',
118 'trac:description',
119 doc="""Comma-separated list of version control properties to render
120 as wiki content in the repository browser.
121 """)
122
123 oneliner_properties = ListOption('browser', 'oneliner_properties',
124 'trac:summary',
125 doc="""Comma-separated list of version control properties to render
126 as oneliner wiki content in the repository browser.
127 """)
128
132
138
139
141
142 min = datetime(1, 1, 1, 0, 0, 0, 0, utc)
143
145 self.oldest = self.newest = base
146 self._total = None
147
149 delta = dt1 - dt2
150 return delta.days * 24 * 3600 + delta.seconds
151
154
156 return TimeRange.min + timedelta(*divmod(secs, 24* 3600))
157
159 if self._total is None:
160 self._total = float(self.seconds_between(self.newest, self.oldest))
161 age = 1.0
162 if self._total:
163 age = self.seconds_between(datetime, self.oldest) / self._total
164 return age
165
167 self._total = None
168 self.oldest = min(self.oldest, datetime)
169 self.newest = max(self.newest, datetime)
170
171
172
174
175 implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
176 IWikiSyntaxProvider, IHTMLPreviewAnnotator,
177 IWikiMacroProvider)
178
179 property_renderers = ExtensionPoint(IPropertyRenderer)
180
181 realm = RepositoryManager.source_realm
182
183 downloadable_paths = ListOption('browser', 'downloadable_paths',
184 '/trunk, /branches/*, /tags/*',
185 doc="""List of repository paths that can be downloaded.
186
187 Leave this option empty if you want to disable all downloads, otherwise
188 set it to a comma-separated list of authorized paths (those paths are
189 glob patterns, i.e. "*" can be used as a wild card). In a
190 multi-repository environment, the path must be qualified with the
191 repository name if the path does not point to the default repository
192 (e.g. /reponame/trunk). Note that a simple prefix matching is
193 performed on the paths, so aliases won't get automatically resolved.
194 """)
195
196 color_scale = BoolOption('browser', 'color_scale', True,
197 doc="""Enable colorization of the ''age'' column.
198
199 This uses the same color scale as the source code annotation:
200 blue is older, red is newer.
201 """)
202
203 NEWEST_COLOR = (255, 136, 136)
204
205 newest_color = Option('browser', 'newest_color', repr(NEWEST_COLOR),
206 doc="""(r,g,b) color triple to use for the color corresponding
207 to the newest color, for the color scale used in ''blame'' or
208 the browser ''age'' column if `color_scale` is enabled.
209 """)
210
211 OLDEST_COLOR = (136, 136, 255)
212
213 oldest_color = Option('browser', 'oldest_color', repr(OLDEST_COLOR),
214 doc="""(r,g,b) color triple to use for the color corresponding
215 to the oldest color, for the color scale used in ''blame'' or
216 the browser ''age'' column if `color_scale` is enabled.
217 """)
218
219 intermediate_point = Option('browser', 'intermediate_point', '',
220 doc="""If set to a value between 0 and 1 (exclusive), this will be the
221 point chosen to set the `intermediate_color` for interpolating
222 the color value.
223 """)
224
225 intermediate_color = Option('browser', 'intermediate_color', '',
226 doc="""(r,g,b) color triple to use for the color corresponding
227 to the intermediate color, if two linear interpolations are used
228 for the color scale (see `intermediate_point`).
229 If not set, the intermediate color between `oldest_color` and
230 `newest_color` will be used.
231 """)
232
233 render_unsafe_content = BoolOption('browser', 'render_unsafe_content',
234 'false',
235 """Whether raw files should be rendered in the browser, or only made
236 downloadable.
237
238 Pretty much any file may be interpreted as HTML by the browser,
239 which allows a malicious user to create a file containing cross-site
240 scripting attacks.
241
242 For open repositories where anyone can check-in a file, it is
243 recommended to leave this option disabled.""")
244
245 hidden_properties = ListOption('browser', 'hide_properties', 'svk:merge',
246 doc="""Comma-separated list of version control properties to hide from
247 the repository browser.
248 """)
249
250
251
253 """Returns a converter for values from [0.0, 1.0] to a RGB triple."""
254
255 def interpolate(old, new, value):
256
257
258 return tuple([int(b + (a - b) * value) for a, b in zip(new, old)])
259
260 def parse_color(rgb, default):
261
262 try:
263 t = tuple([int(v) for v in re.split(r'(\d+)', rgb)[1::2]])
264 return t if len(t) == 3 else default
265 except ValueError:
266 return default
267
268 newest_color = parse_color(self.newest_color, self.NEWEST_COLOR)
269 oldest_color = parse_color(self.oldest_color, self.OLDEST_COLOR)
270 try:
271 intermediate = float(self.intermediate_point)
272 except ValueError:
273 intermediate = None
274 if intermediate:
275 intermediate_color = parse_color(self.intermediate_color, None)
276 if not intermediate_color:
277 intermediate_color = tuple([(a + b) / 2 for a, b in
278 zip(newest_color, oldest_color)])
279 def colorizer(value):
280 if value <= intermediate:
281 value = value / intermediate
282 return interpolate(oldest_color, intermediate_color, value)
283 else:
284 value = (value - intermediate) / (1.0 - intermediate)
285 return interpolate(intermediate_color, newest_color, value)
286 else:
287 def colorizer(value):
288 return interpolate(oldest_color, newest_color, value)
289 return colorizer
290
291
292
295
302
303
304
306 return ['BROWSER_VIEW', 'FILE_VIEW']
307
308
309
311 match = re.match(r'/(export|browser|file)(/.*)?$', req.path_info)
312 if match:
313 mode, path = match.groups()
314 if mode == 'export':
315 if path and '/' in path:
316 path_elts = path.split('/', 2)
317 if len(path_elts) != 3:
318 return False
319 path = path_elts[2]
320 req.args['rev'] = path_elts[1]
321 req.args['format'] = 'raw'
322 elif mode == 'file':
323 req.redirect(req.href.browser(path, rev=req.args.get('rev'),
324 format=req.args.get('format')),
325 permanent=True)
326 req.args['path'] = path or '/'
327 return True
328
330 presel = req.args.get('preselected')
331 if presel and (presel + '/').startswith(req.href.browser() + '/'):
332 req.redirect(presel)
333
334 path = req.args.get('path', '/')
335 rev = req.args.getfirst('rev', '')
336 if rev.lower() in ('', 'head'):
337 rev = None
338 format = req.args.get('format')
339 order = req.args.get('order', 'name').lower()
340 desc = 'desc' in req.args
341
342 rm = RepositoryManager(self.env)
343 all_repositories = rm.get_all_repositories()
344 reponame, repos, path = rm.get_repository_by_path(path)
345
346
347 show_index = not reponame and path == '/'
348 if show_index:
349 if repos and (as_bool(all_repositories[''].get('hidden'))
350 or not repos.is_viewable(req.perm)):
351 repos = None
352
353 if not repos and reponame:
354 raise ResourceNotFound(_("Repository '%(repo)s' not found",
355 repo=reponame))
356
357 if reponame and reponame != repos.reponame:
358 qs = req.query_string
359 req.redirect(req.href.browser(repos.reponame or None, path)
360 + ('?' + qs if qs else ''))
361 reponame = repos.reponame if repos else None
362
363
364 context = web_context(req)
365 node = None
366 changeset = None
367 display_rev = lambda rev: rev
368 if repos:
369 try:
370 if rev:
371 rev = repos.normalize_rev(rev)
372
373
374 rev_or_latest = rev or repos.youngest_rev
375 node = get_existing_node(req, repos, path, rev_or_latest)
376 except NoSuchChangeset as e:
377 raise ResourceNotFound(e, _('Invalid changeset number'))
378 if node:
379 try:
380
381 changeset = repos.get_changeset(node.rev)
382 except NoSuchChangeset:
383 pass
384
385 context = context.child(repos.resource.child(self.realm, path,
386 version=rev_or_latest))
387 display_rev = repos.display_rev
388
389
390 path_links = get_path_links(req.href, reponame, path, rev,
391 order, desc)
392
393 repo_data = dir_data = file_data = None
394 if show_index:
395 repo_data = self._render_repository_index(
396 context, all_repositories, order, desc)
397 if node:
398 if not node.is_viewable(req.perm):
399 raise PermissionError('BROWSER_VIEW' if node.isdir else
400 'FILE_VIEW', node.resource, self.env)
401 if node.isdir:
402 if format in ('zip',):
403 self._render_zip(req, context, repos, node, rev)
404
405 dir_data = self._render_dir(req, repos, node, rev, order, desc)
406 elif node.isfile:
407 file_data = self._render_file(req, context, repos, node, rev)
408
409 if not repos and not (repo_data and repo_data['repositories']):
410
411
412 req.perm.require('BROWSER_VIEW')
413 if show_index:
414 raise ResourceNotFound(_("No viewable repositories"))
415 else:
416 raise ResourceNotFound(_("No node %(path)s", path=path))
417
418 quickjump_data = properties_data = None
419 if node and not req.is_xhr:
420 properties_data = self.render_properties(
421 'browser', context, node.get_properties())
422 quickjump_data = list(repos.get_quickjump_entries(rev))
423
424 data = {
425 'context': context, 'reponame': reponame, 'repos': repos,
426 'repoinfo': all_repositories.get(reponame or ''),
427 'path': path, 'rev': node and node.rev, 'stickyrev': rev,
428 'display_rev': display_rev, 'changeset': changeset,
429 'created_path': node and node.created_path,
430 'created_rev': node and node.created_rev,
431 'properties': properties_data,
432 'path_links': path_links,
433 'order': order, 'desc': 1 if desc else None,
434 'repo': repo_data, 'dir': dir_data, 'file': file_data,
435 'quickjump_entries': quickjump_data,
436 'wiki_format_messages': \
437 self.config['changeset'].getbool('wiki_format_messages'),
438 'xhr': req.is_xhr,
439 }
440 if req.is_xhr:
441 return 'dir_entries.html', data, None
442
443 if dir_data or repo_data:
444 add_script(req, 'common/js/expand_dir.js')
445 add_script(req, 'common/js/keyboard_nav.js')
446
447
448 if node:
449 if node.isfile:
450 prev_rev = repos.previous_rev(rev=node.created_rev,
451 path=node.created_path)
452 if prev_rev:
453 href = req.href.browser(reponame,
454 node.created_path, rev=prev_rev)
455 add_link(req, 'prev', href,
456 _('Revision %(num)s', num=display_rev(prev_rev)))
457 if rev is not None:
458 add_link(req, 'up', req.href.browser(reponame,
459 node.created_path))
460 next_rev = repos.next_rev(rev=node.created_rev,
461 path=node.created_path)
462 if next_rev:
463 href = req.href.browser(reponame, node.created_path,
464 rev=next_rev)
465 add_link(req, 'next', href,
466 _('Revision %(num)s', num=display_rev(next_rev)))
467 prevnext_nav(req, _('Previous Revision'), _('Next Revision'),
468 _('Latest Revision'))
469 else:
470 if path != '/':
471 add_link(req, 'up', path_links[-2]['href'],
472 _('Parent directory'))
473 add_ctxtnav(req, tag.a(_('Last Change'),
474 href=req.href.changeset(node.created_rev, reponame,
475 node.created_path)))
476 if node.isfile:
477 annotate = data['file']['annotate']
478 if annotate:
479 add_ctxtnav(req, _('Normal'),
480 title=_('View file without annotations'),
481 href=req.href.browser(reponame,
482 node.created_path,
483 rev=rev))
484 if annotate != 'blame':
485 add_ctxtnav(req, _('Blame'),
486 title=_('Annotate each line with the last '
487 'changed revision '
488 '(this can be time consuming...)'),
489 href=req.href.browser(reponame,
490 node.created_path,
491 rev=rev,
492 annotate='blame'))
493 add_ctxtnav(req, _('Revision Log'),
494 href=req.href.log(reponame, path, rev=rev))
495 path_url = repos.get_path_url(path, rev)
496 if path_url:
497 if path_url.startswith('//'):
498 path_url = req.scheme + ':' + path_url
499 add_ctxtnav(req, _('Repository URL'), href=path_url)
500
501 add_stylesheet(req, 'common/css/browser.css')
502 return 'browser.html', data, None
503
504
505
507
508 timerange = custom_colorizer = None
509 if self.color_scale:
510 custom_colorizer = self.get_custom_colorizer()
511
512 rm = RepositoryManager(self.env)
513 repositories = []
514 for reponame, repoinfo in all_repositories.iteritems():
515 if not reponame or as_bool(repoinfo.get('hidden')):
516 continue
517 try:
518 repos = rm.get_repository(reponame)
519 except TracError as err:
520 entry = (reponame, repoinfo, None, None,
521 exception_to_unicode(err), None)
522 else:
523 if repos:
524 if not repos.is_viewable(context.perm):
525 continue
526 try:
527 youngest = repos.get_changeset(repos.youngest_rev)
528 except NoSuchChangeset:
529 youngest = None
530 if self.color_scale and youngest:
531 if not timerange:
532 timerange = TimeRange(youngest.date)
533 else:
534 timerange.insert(youngest.date)
535 raw_href = self._get_download_href(context.href, repos,
536 None, None)
537 entry = (reponame, repoinfo, repos, youngest, None,
538 raw_href)
539 else:
540 entry = (reponame, repoinfo, None, None, u"\u2013", None)
541 if entry[4] is not None:
542 root = Resource('repository', reponame).child(self.realm, '/')
543 if 'BROWSER_VIEW' not in context.perm(root):
544 continue
545 repositories.append(entry)
546
547
548 if order == 'date':
549 def repo_order((reponame, repoinfo, repos, youngest, err, href)):
550 return (youngest.date if youngest else to_datetime(0),
551 embedded_numbers(reponame.lower()))
552 elif order == 'author':
553 def repo_order((reponame, repoinfo, repos, youngest, err, href)):
554 return (youngest.author.lower() if youngest else '',
555 embedded_numbers(reponame.lower()))
556 else:
557 def repo_order((reponame, repoinfo, repos, youngest, err, href)):
558 return embedded_numbers(reponame.lower())
559
560 repositories = sorted(repositories, key=repo_order, reverse=desc)
561
562 return {'repositories' : repositories,
563 'timerange': timerange, 'colorize_age': custom_colorizer}
564
565 - def _render_dir(self, req, repos, node, rev, order, desc):
566 req.perm(node.resource).require('BROWSER_VIEW')
567 download_href = self._get_download_href
568
569
570 class entry(object):
571 _copy = 'name rev created_rev kind isdir path content_length' \
572 .split()
573 __slots__ = _copy + ['raw_href']
574
575 def __init__(self, node):
576 for f in entry._copy:
577 setattr(self, f, getattr(node, f))
578 self.raw_href = download_href(req.href, repos, node, rev)
579
580 entries = [entry(n) for n in node.get_entries()
581 if n.is_viewable(req.perm)]
582 changes = get_changes(repos, [i.created_rev for i in entries],
583 self.log)
584
585 if rev:
586 newest = repos.get_changeset(rev).date
587 else:
588 newest = datetime_now(req.tz)
589
590
591 timerange = custom_colorizer = None
592 if self.color_scale:
593 timerange = TimeRange(newest)
594 max_s = req.args.get('range_max_secs')
595 min_s = req.args.get('range_min_secs')
596 parent_range = [timerange.from_seconds(long(s))
597 for s in [max_s, min_s] if s]
598 this_range = [c.date for c in changes.values() if c]
599 for dt in this_range + parent_range:
600 timerange.insert(dt)
601 custom_colorizer = self.get_custom_colorizer()
602
603
604 if order == 'date':
605 def file_order(a):
606 return (changes[a.created_rev].date,
607 embedded_numbers(a.name.lower()))
608 elif order == 'size':
609 def file_order(a):
610 return (a.content_length,
611 embedded_numbers(a.name.lower()))
612 elif order == 'author':
613 def file_order(a):
614 return (changes[a.created_rev].author.lower(),
615 embedded_numbers(a.name.lower()))
616 else:
617 def file_order(a):
618 return embedded_numbers(a.name.lower())
619
620 dir_order = 1 if desc else -1
621
622 def browse_order(a):
623 return dir_order if a.isdir else 0, file_order(a)
624 entries = sorted(entries, key=browse_order, reverse=desc)
625
626
627 zip_href = self._get_download_href(req.href, repos, node, rev)
628 if zip_href:
629 add_link(req, 'alternate', zip_href, _('Zip Archive'),
630 'application/zip', 'zip')
631
632 return {'entries': entries, 'changes': changes,
633 'timerange': timerange, 'colorize_age': custom_colorizer,
634 'range_max_secs': (timerange and
635 timerange.to_seconds(timerange.newest)),
636 'range_min_secs': (timerange and
637 timerange.to_seconds(timerange.oldest)),
638 }
639
641 stack = [node]
642 while stack:
643 node = stack.pop()
644 yield node
645 if node.isdir:
646 stack.extend(sorted(node.get_entries(),
647 key=lambda x: x.name,
648 reverse=True))
649
650 - def _render_zip(self, req, context, repos, root_node, rev=None):
651 if not self.is_path_downloadable(repos, root_node.path):
652 raise TracError(_("Path not available for download"))
653 req.perm(context.resource).require('FILE_VIEW')
654 root_path = root_node.path.rstrip('/')
655 if root_path:
656 archive_name = root_node.name
657 else:
658 archive_name = repos.reponame or 'repository'
659 filename = '%s-%s.zip' % (archive_name, root_node.rev)
660 render_zip(req, filename, repos, root_node, self._iter_nodes)
661
662 - def _render_file(self, req, context, repos, node, rev=None):
663 req.perm(node.resource).require('FILE_VIEW')
664
665 mimeview = Mimeview(self.env)
666
667
668 with content_closing(node.get_processed_content()) as content:
669 chunk = content.read(CHUNK_SIZE)
670 mime_type = node.content_type
671 if not mime_type or mime_type == 'application/octet-stream':
672 mime_type = mimeview.get_mimetype(node.name, chunk) or \
673 mime_type or 'text/plain'
674
675
676 format = req.args.get('format')
677 if format in ('raw', 'txt'):
678 req.send_response(200)
679 req.send_header('Content-Type',
680 'text/plain' if format == 'txt' else mime_type)
681 req.send_header('Last-Modified', http_date(node.last_modified))
682 if rev is None:
683 req.send_header('Pragma', 'no-cache')
684 req.send_header('Cache-Control', 'no-cache')
685 req.send_header('Expires', 'Fri, 01 Jan 1999 00:00:00 GMT')
686 if not self.render_unsafe_content:
687
688
689
690 req.send_header('Content-Disposition', 'attachment')
691 req.end_headers()
692
693
694 while chunk:
695 req.write(chunk)
696 chunk = content.read(CHUNK_SIZE)
697 raise RequestDone
698
699
700
701 changeset = repos.get_changeset(node.created_rev)
702
703
704 if not is_binary(chunk) and mime_type != 'text/plain':
705 plain_href = req.href.browser(repos.reponame or None,
706 node.path, rev=rev,
707 format='txt')
708 add_link(req, 'alternate', plain_href, _('Plain Text'),
709 'text/plain')
710
711
712 raw_href = req.href.export(rev or repos.youngest_rev,
713 repos.reponame or None, node.path)
714 add_link(req, 'alternate', raw_href, _('Original Format'),
715 mime_type)
716
717 self.log.debug("Rendering preview of node %s@%s with "
718 "mime-type %s", node.name, rev, mime_type)
719
720 add_stylesheet(req, 'common/css/code.css')
721
722 annotations = ['lineno']
723 annotate = req.args.get('annotate')
724 if annotate:
725 annotations.insert(0, annotate)
726 with content_closing(node.get_processed_content()) as content:
727 preview_data = mimeview.preview_data(context, content,
728 node.get_content_length(),
729 mime_type, node.created_path,
730 raw_href,
731 annotations=annotations,
732 force_source=bool(annotate))
733 return {
734 'changeset': changeset,
735 'size': node.content_length,
736 'preview': preview_data,
737 'annotate': annotate,
738 }
739
741 """Return the URL for downloading a file, or a directory as a ZIP."""
742 if node is not None and node.isfile:
743 return href.export(rev or 'HEAD', repos.reponame or None,
744 node.path)
745 path = '' if node is None else node.path.strip('/')
746 if self.is_path_downloadable(repos, path):
747 return href.browser(repos.reponame or None, path,
748 rev=rev or repos.youngest_rev, format='zip')
749
750
751
757
759 """Prepare rendering of a collection of properties."""
760 return filter(None, [self.render_property(name, mode, context, props)
761 for name in sorted(props)])
762
764 """Renders a node property to HTML."""
765 if name in self.hidden_properties:
766 return
767 candidates = []
768 for renderer in self.property_renderers:
769 quality = renderer.match_property(name, mode)
770 if quality > 0:
771 candidates.append((quality, renderer))
772 candidates.sort(reverse=True)
773 for (quality, renderer) in candidates:
774 try:
775 rendered = renderer.render_property(name, mode, context, props)
776 if not rendered:
777 return rendered
778 if isinstance(rendered, RenderedProperty):
779 value = rendered.content
780 else:
781 value = rendered
782 rendered = None
783 prop = {'name': name, 'value': value, 'rendered': rendered}
784 return prop
785 except Exception as e:
786 self.log.warning('Rendering failed for property %s with '
787 'renderer %s: %s', name,
788 renderer.__class__.__name__,
789 exception_to_unicode(e, traceback=True))
790
791
792
795
797 """TracBrowser link resolvers.
798
799 `source:` and `browser:`
800 * simple paths (/dir/file)
801 * paths at a given revision (/dir/file@234)
802 * paths with line number marks (/dir/file@234:10,20-30)
803 * paths with line number anchor (/dir/file@234#L100)
804
805 Marks and anchor can be combined.
806 The revision must be present when specifying line numbers.
807 In the few cases where it would be redundant (e.g. for tags), the
808 revision number itself can be omitted: /tags/v10/file@100-110#L99
809 """
810 return [('repos', self._format_browser_link),
811 ('export', self._format_export_link),
812 ('source', self._format_browser_link),
813 ('browser', self._format_browser_link)]
814
829
849
850 PATH_LINK_RE = re.compile(r"([^@#:]*)"
851 r"[@:]([^#:]+)?"
852 r"(?::(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?"
853 )
854
856 rm = RepositoryManager(self.env)
857 node = raw_href = title = None
858 try:
859 reponame, repos, npath = rm.get_repository_by_path(path)
860 node = get_allowed_node(repos, npath, rev, perm)
861 if node is not None:
862 raw_href = self._get_download_href(href, repos, node, rev)
863 title = _("Download") if node.isfile \
864 else _("Download as Zip archive")
865 except TracError:
866 pass
867 return (node, raw_href, title)
868
869
870
872 return 'blame', _('Rev'), _('Revision in which the line changed')
873
875 """Cache the annotation data corresponding to each revision."""
876 return BlameAnnotator(self.env, context)
877
878 - def annotate_row(self, context, row, lineno, line, blame_annotator):
879 blame_annotator.annotate(row, lineno)
880
881
882
884 yield "RepositoryIndex"
885
887 description = cleandoc_("""
888 Display the list of available repositories.
889
890 Can be given the following named arguments:
891
892 ''format''::
893 Select the rendering format:
894 - ''compact'' produces a comma-separated list of repository prefix
895 names (default)
896 - ''list'' produces a description list of repository prefix names
897 - ''table'' produces a table view, similar to the one visible in
898 the ''Browse View'' page
899 ''glob''::
900 Do a glob-style filtering on the repository names (defaults to '*')
901 ''order''::
902 Order repositories by the given column (one of "name", "date" or
903 "author")
904 ''desc''::
905 When set to 1, order by descending order
906
907 (''since 0.12'')
908 """)
909 return 'messages', description
910
912 args, kwargs = parse_args(content)
913 format = kwargs.get('format', 'compact')
914 glob = kwargs.get('glob', '*')
915 order = kwargs.get('order')
916 desc = as_bool(kwargs.get('desc', 0))
917
918 rm = RepositoryManager(self.env)
919 all_repos = dict(rdata for rdata in rm.get_all_repositories().items()
920 if fnmatchcase(rdata[0], glob))
921
922 if format == 'table':
923 repo = self._render_repository_index(formatter.context, all_repos,
924 order, desc)
925
926 add_stylesheet(formatter.req, 'common/css/browser.css')
927 wiki_format_messages = self.config['changeset'] \
928 .getbool('wiki_format_messages')
929 data = {'repo': repo, 'order': order, 'desc': 1 if desc else None,
930 'reponame': None, 'path': '/', 'stickyrev': None,
931 'wiki_format_messages': wiki_format_messages}
932 from trac.web.chrome import Chrome
933 return Chrome(self.env).render_template(
934 formatter.req, 'repository_index.html', data, None,
935 fragment=True)
936
937 def get_repository(reponame):
938 try:
939 return rm.get_repository(reponame)
940 except TracError:
941 return
942
943 all_repos = [(reponame, get_repository(reponame))
944 for reponame in all_repos]
945 all_repos = sorted(((reponame, repos) for reponame, repos in all_repos
946 if repos
947 and not as_bool(repos.params.get('hidden'))
948 and repos.is_viewable(formatter.perm)),
949 reverse=desc)
950
951 def repolink(reponame, repos):
952 label = reponame or _('(default)')
953 return Markup(tag.a(label,
954 title=_('View repository %(repo)s', repo=label),
955 href=formatter.href.browser(repos.reponame or None)))
956
957 if format == 'list':
958 return tag.dl([
959 tag(tag.dt(repolink(reponame, repos)),
960 tag.dd(repos.params.get('description')))
961 for reponame, repos in all_repos])
962 else:
963 return Markup(', ').join([repolink(reponame, repos)
964 for reponame, repos in all_repos])
965
966
967
969
984
986 rev = self.rev
987 node = self.repos.get_node(self.path, rev)
988
989
990 self.annotations = node.get_annotations()
991
992
993
994
995 self.changesets = []
996 chgset = self.repos.get_changeset(rev)
997 chgsets = {rev: chgset}
998 self.timerange = TimeRange(chgset.date)
999 for idx in range(len(self.annotations)):
1000 rev = self.annotations[idx]
1001 chgset = chgsets.get(rev)
1002 if not chgset:
1003 chgset = self.repos.get_changeset(rev)
1004 chgsets[rev] = chgset
1005 self.timerange.insert(chgset.date)
1006
1007 self.changesets.append(chgset)
1008
1009
1010 self.paths = {}
1011 for path, rev, chg in node.get_history():
1012 self.paths[rev] = path
1013
1014 browser = BrowserModule(self.env)
1015 self.colorize_age = browser.get_custom_colorizer()
1016
1018 if lineno > len(self.annotations):
1019 row.append(tag.th())
1020 return
1021 rev = self.annotations[lineno-1]
1022 chgset = self.changesets[lineno-1]
1023 path = self.paths.get(rev)
1024
1025
1026
1027
1028 if rev not in self.chgset_data:
1029 chgset_href = \
1030 self.context.href.changeset(rev, self.repos.reponame or None,
1031 path)
1032 short_author = chgset.author.split(' ', 1)[0]
1033 title = shorten_line('%s: %s' % (short_author, chgset.message))
1034 anchor = tag.a('[%s]' % self.repos.short_rev(rev),
1035 title=title, href=chgset_href)
1036 color = self.colorize_age(self.timerange.relative(chgset.date))
1037 style = 'background-color: rgb(%d, %d, %d);' % color
1038 self.chgset_data[rev] = (anchor, style)
1039 else:
1040 anchor, style = self.chgset_data[rev]
1041
1042 if self.prev_chgset != chgset:
1043 self.prev_style = style
1044
1045 if not path or path == self.path:
1046 path = ''
1047
1048 style = self.prev_style
1049 if lineno < len(self.changesets) and self.changesets[lineno] == chgset:
1050 style += ' border-bottom: none;'
1051 blame_col = tag.th(style=style, class_='blame r%s' % rev)
1052 if self.prev_chgset != chgset:
1053 blame_col.append(anchor)
1054 self.prev_chgset = chgset
1055 row.append(blame_col)
1056