| [18251] | 1 | # -*- coding: utf-8 -*- |
|---|
| [13497] | 2 | # |
|---|
| [15212] | 3 | # Copyright (C) 2005-2006 Team5 |
|---|
| [18050] | 4 | # Copyright (C) Cinc |
|---|
| [13497] | 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| [15212] | 7 | # This software is licensed as described in the file COPYING.txt, which |
|---|
| [13497] | 8 | # you should have received as part of this distribution. |
|---|
| 9 | # |
|---|
| [717] | 10 | # Author: Team5 |
|---|
| 11 | # |
|---|
| 12 | |
|---|
| 13 | from __future__ import generators |
|---|
| 14 | import re |
|---|
| [18251] | 15 | try: |
|---|
| 16 | from functools import cmp_to_key |
|---|
| 17 | except ImportError: |
|---|
| 18 | pass |
|---|
| [717] | 19 | from trac.core import * |
|---|
| 20 | from trac.mimeview import * |
|---|
| 21 | from trac.mimeview.api import IHTMLPreviewAnnotator |
|---|
| [15194] | 22 | from trac.resource import ResourceNotFound |
|---|
| [13497] | 23 | from trac.util import embedded_numbers |
|---|
| [18251] | 24 | from trac.util.datefmt import format_datetime, http_date, pretty_timedelta |
|---|
| 25 | from trac.util.html import escape, html as tag |
|---|
| 26 | from trac.util.text import pretty_size |
|---|
| [16616] | 27 | from trac.util.translation import _ |
|---|
| [18050] | 28 | from trac.versioncontrol.api import NoSuchChangeset, RepositoryManager |
|---|
| [13497] | 29 | from trac.versioncontrol.web_ui.util import * |
|---|
| [717] | 30 | from trac.web import IRequestHandler, RequestDone |
|---|
| [18242] | 31 | from trac.web.chrome import add_link, Chrome, web_context |
|---|
| [18050] | 32 | from trac.wiki import format_to_html |
|---|
| [717] | 33 | |
|---|
| [18251] | 34 | from .compat import is_py3, iteritems |
|---|
| 35 | |
|---|
| 36 | |
|---|
| [18249] | 37 | try: |
|---|
| 38 | cmp |
|---|
| 39 | except NameError: |
|---|
| 40 | def cmp(a, b): |
|---|
| 41 | return (a > b) - (a < b) |
|---|
| 42 | |
|---|
| [18251] | 43 | |
|---|
| [717] | 44 | IMG_RE = re.compile(r"\.(gif|jpg|jpeg|png)(\?.*)?$", re.IGNORECASE) |
|---|
| 45 | CHUNK_SIZE = 4096 |
|---|
| [13497] | 46 | DIGITS = re.compile(r'[0-9]+') |
|---|
| [717] | 47 | |
|---|
| [13497] | 48 | |
|---|
| [717] | 49 | def _natural_order(x, y): |
|---|
| 50 | """Comparison function for natural order sorting based on |
|---|
| 51 | http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/214202.""" |
|---|
| 52 | nx = ny = 0 |
|---|
| 53 | while True: |
|---|
| 54 | a = DIGITS.search(x, nx) |
|---|
| 55 | b = DIGITS.search(y, ny) |
|---|
| 56 | if None in (a, b): |
|---|
| 57 | return cmp(x[nx:], y[ny:]) |
|---|
| 58 | r = (cmp(x[nx:a.start()], y[ny:b.start()]) or |
|---|
| 59 | cmp(int(x[a.start():a.end()]), int(y[b.start():b.end()]))) |
|---|
| 60 | if r: |
|---|
| 61 | return r |
|---|
| 62 | nx, ny = a.end(), b.end() |
|---|
| 63 | |
|---|
| 64 | |
|---|
| [17450] | 65 | class PeerRepoBrowser(Component): |
|---|
| [17265] | 66 | """Provide a repository browser for file selection for code reviews. |
|---|
| 67 | |
|---|
| 68 | [[BR]] |
|---|
| 69 | Component used for browsing the repository for files. |
|---|
| 70 | |
|---|
| 71 | '''Note:''' do not disable otherwise no files may be selected for a review. |
|---|
| 72 | """ |
|---|
| 73 | |
|---|
| [15164] | 74 | implements(IRequestHandler, IHTMLPreviewAnnotator) |
|---|
| [717] | 75 | |
|---|
| 76 | # ITextAnnotator methods |
|---|
| 77 | def get_annotation_type(self): |
|---|
| [13497] | 78 | return 'lineno', 'Line', 'Line numbers' |
|---|
| [3449] | 79 | |
|---|
| [3451] | 80 | def get_annotation_data(self, context): |
|---|
| 81 | return None |
|---|
| 82 | |
|---|
| [3449] | 83 | def annotate_row(self, context, row, lineno, line, data): |
|---|
| 84 | row.append(tag.th(id='L%s' % lineno)( |
|---|
| 85 | tag.a(lineno, href='javascript:setLineNum(%s)' % lineno) |
|---|
| 86 | )) |
|---|
| [13497] | 87 | |
|---|
| [717] | 88 | # IRequestHandler methods |
|---|
| 89 | |
|---|
| 90 | def match_request(self, req): |
|---|
| 91 | import re |
|---|
| [15585] | 92 | match = re.match(r'/(peerReviewBrowser|file|adminrepobrowser)(?:(/.*))?', req.path_info) |
|---|
| [717] | 93 | if match: |
|---|
| 94 | req.args['path'] = match.group(2) or '/' |
|---|
| 95 | if match.group(1) == 'file': |
|---|
| 96 | # FIXME: This should be a permanent redirect |
|---|
| [17448] | 97 | req.redirect(req.href.peerReviewBrowser(req.args.get('path'), |
|---|
| [717] | 98 | rev=req.args.get('rev'), |
|---|
| 99 | format=req.args.get('format'))) |
|---|
| [15585] | 100 | elif match.group(1) == 'adminrepobrowser': |
|---|
| 101 | # This one is for the browser on admin pages |
|---|
| 102 | req.args['is_admin_browser'] = True |
|---|
| [717] | 103 | return True |
|---|
| 104 | |
|---|
| 105 | def process_request(self, req): |
|---|
| [3542] | 106 | |
|---|
| [717] | 107 | path = req.args.get('path', '/') |
|---|
| 108 | rev = req.args.get('rev') |
|---|
| [15581] | 109 | cur_repo = req.args.get('repo', '') |
|---|
| [15588] | 110 | is_admin_browser = req.args.get('is_admin_browser', False) # Set when we come from the file admin page |
|---|
| [18050] | 111 | context = web_context(req) |
|---|
| [15588] | 112 | # Depending on from where we are coming we have to preprocess in match_request() thus use different paths |
|---|
| 113 | browse_url_base = 'adminrepobrowser' if is_admin_browser else 'peerReviewBrowser' |
|---|
| [18242] | 114 | if hasattr(Chrome, 'jenv'): |
|---|
| 115 | template_file = 'peeradmin_repobrowser_jinja.html' if is_admin_browser else 'peerrepobrowser_jinja.html' |
|---|
| 116 | else: |
|---|
| 117 | template_file = 'admin_repobrowser.html' if is_admin_browser else 'repobrowser.html' |
|---|
| [717] | 118 | |
|---|
| [15281] | 119 | # display_rev = lambda rev: rev |
|---|
| 120 | |
|---|
| [17448] | 121 | data = {'browse_url': req.href(browse_url_base), |
|---|
| [18252] | 122 | 'is_admin_browser': is_admin_browser, |
|---|
| 123 | 'iteritems': iteritems |
|---|
| [15588] | 124 | } |
|---|
| 125 | |
|---|
| [15581] | 126 | repoman = RepositoryManager(self.env) |
|---|
| [15588] | 127 | |
|---|
| 128 | all_repos = repoman.get_all_repositories() |
|---|
| 129 | if not all_repos: |
|---|
| [15581] | 130 | data['norepo'] = _("No source repository available.") |
|---|
| [18242] | 131 | if hasattr(Chrome, 'jenv'): |
|---|
| 132 | return 'peerrepobrowser_jinja.html', data |
|---|
| 133 | else: |
|---|
| 134 | return 'repobrowser.html', data, None |
|---|
| [15581] | 135 | |
|---|
| [15588] | 136 | # Repositories may be hidden |
|---|
| 137 | filtered_repos = {} |
|---|
| [18251] | 138 | for rname, info in iteritems(all_repos): |
|---|
| [15588] | 139 | try: |
|---|
| 140 | if not info['hidden'] == u'1': |
|---|
| 141 | filtered_repos[rname] = info |
|---|
| 142 | except KeyError: |
|---|
| 143 | # This is the default repo |
|---|
| 144 | filtered_repos[rname] = info |
|---|
| 145 | |
|---|
| 146 | if not filtered_repos: |
|---|
| 147 | data['norepo'] = _("No source repository available.") |
|---|
| [18242] | 148 | if hasattr(Chrome, 'jenv'): |
|---|
| 149 | return 'peerrepobrowser_jinja.html', data |
|---|
| 150 | else: |
|---|
| 151 | return 'repobrowser.html', data, None |
|---|
| [15588] | 152 | |
|---|
| 153 | data['all_repos'] = filtered_repos |
|---|
| 154 | |
|---|
| [15589] | 155 | # if not req.args.get('repo', None): won't work here because of default repo name '' |
|---|
| 156 | if req.args.get('repo', None) == None: |
|---|
| 157 | # We open the page for the first time |
|---|
| [15581] | 158 | data['show_repo_idx'] = True |
|---|
| [18242] | 159 | if hasattr(Chrome, 'jenv'): |
|---|
| 160 | return template_file, data |
|---|
| 161 | else: |
|---|
| 162 | return template_file, data, None |
|---|
| [15581] | 163 | |
|---|
| [15589] | 164 | if cur_repo not in data['all_repos']: |
|---|
| 165 | data['repo_gone'] = cur_repo if cur_repo else '(default)' |
|---|
| 166 | data['show_repo_idx'] = True |
|---|
| [18242] | 167 | if hasattr(Chrome, 'jenv'): |
|---|
| 168 | return template_file, data |
|---|
| 169 | else: |
|---|
| 170 | return template_file, data, None |
|---|
| [15589] | 171 | |
|---|
| [15581] | 172 | # Find node for the requested repo/path/rev |
|---|
| 173 | repo = repoman.get_repository(cur_repo) |
|---|
| 174 | if repo: |
|---|
| [15331] | 175 | try: |
|---|
| [15581] | 176 | node, display_rev, context = get_node_from_repo(req, repo, path, rev) |
|---|
| [17269] | 177 | except NoSuchChangeset as e: |
|---|
| [15331] | 178 | data['norepo'] = _(e.message) |
|---|
| [18242] | 179 | if hasattr(Chrome, 'jenv'): |
|---|
| 180 | return template_file, data |
|---|
| 181 | else: |
|---|
| 182 | return template_file, data, None |
|---|
| [17269] | 183 | except ResourceNotFound as e: # NoSuchNode is converted to this exception by Trac |
|---|
| [15585] | 184 | data['nonode'] = e.message |
|---|
| 185 | node = None |
|---|
| [18276] | 186 | display_rev = repo.display_rev |
|---|
| [15212] | 187 | else: |
|---|
| 188 | data['norepo'] = _("No source repository available.") |
|---|
| [18242] | 189 | if hasattr(Chrome, 'jenv'): |
|---|
| 190 | return template_file, data |
|---|
| 191 | else: |
|---|
| 192 | return template_file, data, None |
|---|
| [15158] | 193 | |
|---|
| [717] | 194 | hidden_properties = [p.strip() for p |
|---|
| 195 | in self.config.get('browser', 'hide_properties', |
|---|
| 196 | 'svk:merge').split(',')] |
|---|
| [3451] | 197 | |
|---|
| [17448] | 198 | path_links = self.get_path_links_CRB(req.href, browse_url_base, path, rev, cur_repo) |
|---|
| [717] | 199 | if len(path_links) > 1: |
|---|
| 200 | add_link(req, 'up', path_links[-2]['href'], 'Parent directory') |
|---|
| 201 | |
|---|
| [15585] | 202 | if node: |
|---|
| [18251] | 203 | props = [{'name': escape(name), 'value': escape(value)} |
|---|
| [15585] | 204 | for name, value in node.get_properties().items() |
|---|
| 205 | if name not in hidden_properties] |
|---|
| 206 | else: |
|---|
| 207 | props = [] |
|---|
| [15581] | 208 | data.update({ |
|---|
| 209 | 'path': path, |
|---|
| [15585] | 210 | 'rev': node.rev if node else rev, |
|---|
| [15581] | 211 | 'stickyrev': rev, |
|---|
| [15194] | 212 | 'context': context, |
|---|
| [15581] | 213 | 'repo': repo, |
|---|
| 214 | 'reponame': repo.reponame, # for included path_links.html |
|---|
| 215 | 'revision': rev or repo.get_youngest_rev(), |
|---|
| [15585] | 216 | 'props': props, |
|---|
| [18251] | 217 | 'log_href': escape(req.href.log(path, rev=rev or None)), |
|---|
| [3542] | 218 | 'path_links': path_links, |
|---|
| [15585] | 219 | 'dir': node and node.isdir and self._render_directory(req, repo, node, rev, cur_repo), |
|---|
| 220 | 'file': node and node.isfile and self._render_file(req, context, repo, node, rev, cur_repo), |
|---|
| [15158] | 221 | 'display_rev': display_rev, |
|---|
| [15581] | 222 | 'wiki_format_messages': self.config['changeset'].getbool('wiki_format_messages'), |
|---|
| 223 | }) |
|---|
| [18242] | 224 | if hasattr(Chrome, 'jenv'): |
|---|
| 225 | return template_file, data |
|---|
| 226 | else: |
|---|
| 227 | return template_file, data, None |
|---|
| [717] | 228 | |
|---|
| 229 | # Internal methods |
|---|
| 230 | |
|---|
| [15585] | 231 | def get_path_links_CRB(self, href, browse_url_base, fullpath, rev, repo): |
|---|
| [717] | 232 | path = '/' |
|---|
| [15581] | 233 | links = [{'name': 'Repository Index', |
|---|
| [15585] | 234 | 'href': href(browse_url_base, path, rev=rev)}] |
|---|
| [3542] | 235 | |
|---|
| 236 | for part in [p for p in fullpath.split('/') if p]: |
|---|
| 237 | path += part + '/' |
|---|
| [717] | 238 | links.append({ |
|---|
| [3542] | 239 | 'name': part, |
|---|
| [15585] | 240 | 'href': href(browse_url_base, path, rev=rev, repo=repo) |
|---|
| [3542] | 241 | }) |
|---|
| [717] | 242 | return links |
|---|
| 243 | |
|---|
| [15581] | 244 | def _render_directory(self, req, repos, node, rev=None, repo=''): |
|---|
| [717] | 245 | req.perm.assert_permission('BROWSER_VIEW') |
|---|
| 246 | |
|---|
| 247 | order = req.args.get('order', 'name').lower() |
|---|
| [18252] | 248 | desc = 'desc' in req.args |
|---|
| [717] | 249 | |
|---|
| 250 | info = [] |
|---|
| [3542] | 251 | |
|---|
| 252 | if order == 'date': |
|---|
| 253 | def file_order(a): |
|---|
| 254 | return changes[a.rev].date |
|---|
| 255 | elif order == 'size': |
|---|
| 256 | def file_order(a): |
|---|
| 257 | return (a.content_length, |
|---|
| 258 | embedded_numbers(a.name.lower())) |
|---|
| 259 | else: |
|---|
| 260 | def file_order(a): |
|---|
| 261 | return embedded_numbers(a.name.lower()) |
|---|
| 262 | |
|---|
| 263 | dir_order = desc and 1 or -1 |
|---|
| 264 | |
|---|
| 265 | def browse_order(a): |
|---|
| 266 | return a.isdir and dir_order or 0, file_order(a) |
|---|
| 267 | |
|---|
| [15585] | 268 | browse_url = "peerReviewBrowser" if not req.args.get('is_admin_browser', False) else "adminrepobrowser" |
|---|
| [717] | 269 | for entry in node.get_entries(): |
|---|
| [15194] | 270 | if entry.can_view(req.perm): |
|---|
| 271 | info.append({ |
|---|
| 272 | 'name': entry.name, |
|---|
| 273 | 'fullpath': entry.path, |
|---|
| 274 | 'is_dir': int(entry.isdir), |
|---|
| 275 | 'content_length': entry.content_length, |
|---|
| [18251] | 276 | 'size': pretty_size(entry.content_length), |
|---|
| [15194] | 277 | 'rev': entry.created_rev, |
|---|
| 278 | 'permission': 1, # FIXME |
|---|
| [18251] | 279 | 'log_href': escape(req.href.log(repo, entry.path, rev=rev)), |
|---|
| [17448] | 280 | 'browser_href': req.href(browse_url, entry.path, rev=rev, repo=repo) |
|---|
| [15194] | 281 | }) |
|---|
| 282 | |
|---|
| [3443] | 283 | changes = get_changes(repos, [i['rev'] for i in info]) |
|---|
| [717] | 284 | |
|---|
| 285 | def cmp_func(a, b): |
|---|
| 286 | dir_cmp = (a['is_dir'] and -1 or 0) + (b['is_dir'] and 1 or 0) |
|---|
| 287 | if dir_cmp: |
|---|
| 288 | return dir_cmp |
|---|
| 289 | neg = desc and -1 or 1 |
|---|
| 290 | if order == 'date': |
|---|
| 291 | return neg * cmp(changes[b['rev']]['date_seconds'], |
|---|
| 292 | changes[a['rev']]['date_seconds']) |
|---|
| 293 | elif order == 'size': |
|---|
| 294 | return neg * cmp(a['content_length'], b['content_length']) |
|---|
| 295 | else: |
|---|
| 296 | return neg * _natural_order(a['name'].lower(), |
|---|
| 297 | b['name'].lower()) |
|---|
| [18251] | 298 | if is_py3: |
|---|
| 299 | info.sort(key=cmp_to_key(cmp_func)) |
|---|
| 300 | else: |
|---|
| 301 | info.sort(cmp_func) |
|---|
| [717] | 302 | |
|---|
| [3542] | 303 | return {'order': order, 'desc': desc and 1 or None, |
|---|
| [13497] | 304 | 'items': info, 'changes': changes} |
|---|
| [717] | 305 | |
|---|
| [15581] | 306 | def _render_file(self, req, context, repos, node, rev=None, repo=''): |
|---|
| [3449] | 307 | req.perm(context.resource).require('FILE_VIEW') |
|---|
| [717] | 308 | |
|---|
| 309 | mime_type = node.content_type |
|---|
| 310 | if not mime_type or mime_type == 'application/octet-stream': |
|---|
| 311 | mime_type = get_mimetype(node.name) or mime_type or 'text/plain' |
|---|
| 312 | |
|---|
| 313 | # We don't have to guess if the charset is specified in the |
|---|
| 314 | # svn:mime-type property |
|---|
| 315 | ctpos = mime_type.find('charset=') |
|---|
| 316 | if ctpos >= 0: |
|---|
| 317 | charset = mime_type[ctpos + 8:] |
|---|
| 318 | else: |
|---|
| 319 | charset = None |
|---|
| 320 | |
|---|
| [3449] | 321 | content = node.get_content() |
|---|
| 322 | chunk = content.read(CHUNK_SIZE) |
|---|
| 323 | |
|---|
| [717] | 324 | format = req.args.get('format') |
|---|
| [3449] | 325 | if format in ('raw', 'txt'): |
|---|
| [717] | 326 | req.send_response(200) |
|---|
| 327 | req.send_header('Content-Type', |
|---|
| 328 | format == 'txt' and 'text/plain' or mime_type) |
|---|
| 329 | req.send_header('Content-Length', node.content_length) |
|---|
| [18251] | 330 | req.send_header('Last-Modified', http_date(node.last_modified)) |
|---|
| [717] | 331 | req.end_headers() |
|---|
| 332 | |
|---|
| 333 | while 1: |
|---|
| 334 | if not chunk: |
|---|
| 335 | raise RequestDone |
|---|
| 336 | req.write(chunk) |
|---|
| [3449] | 337 | chunk = content.read(CHUNK_SIZE) |
|---|
| [717] | 338 | else: |
|---|
| 339 | # Generate HTML preview |
|---|
| 340 | mimeview = Mimeview(self.env) |
|---|
| 341 | |
|---|
| [3449] | 342 | # The changeset corresponding to the last change on `node` |
|---|
| 343 | # is more interesting than the `rev` changeset. |
|---|
| 344 | changeset = repos.get_changeset(node.rev) |
|---|
| 345 | |
|---|
| 346 | # add ''Plain Text'' alternate link if needed |
|---|
| 347 | if not is_binary(chunk) and mime_type != 'text/plain': |
|---|
| 348 | plain_href = req.href.browser(node.path, rev=rev, format='txt') |
|---|
| 349 | add_link(req, 'alternate', plain_href, 'Plain Text', |
|---|
| 350 | 'text/plain') |
|---|
| 351 | |
|---|
| [17448] | 352 | raw_href = req.href.peerReviewBrowser(node.path, rev=rev and node.rev, repo=repo, |
|---|
| [717] | 353 | format='raw') |
|---|
| [3449] | 354 | preview_data = mimeview.preview_data(context, node.get_content(), |
|---|
| 355 | node.get_content_length(), |
|---|
| 356 | mime_type, node.created_path, |
|---|
| 357 | raw_href, |
|---|
| 358 | annotations=['lineno']) |
|---|
| [2270] | 359 | |
|---|
| [717] | 360 | add_link(req, 'alternate', raw_href, 'Original Format', mime_type) |
|---|
| 361 | |
|---|
| [18050] | 362 | # TODO: we have a context as a method parameter. Check if this is duplicate code. |
|---|
| 363 | context = web_context(req) |
|---|
| 364 | msg = format_to_html(self.env, context=context, wikidom=changeset.message or '', escape_newlines=True) |
|---|
| [3449] | 365 | return { |
|---|
| 366 | 'changeset': changeset, |
|---|
| 367 | 'size': node.content_length, |
|---|
| [3542] | 368 | 'preview': preview_data['rendered'], |
|---|
| [15215] | 369 | 'max_file_size_reached': preview_data['max_file_size_reached'], |
|---|
| 370 | 'max_file_size': preview_data['max_file_size'], |
|---|
| [3449] | 371 | 'annotate': False, |
|---|
| [3542] | 372 | 'rev': node.rev, |
|---|
| [18251] | 373 | 'changeset_href': escape(req.href.changeset(node.rev)), |
|---|
| 374 | 'date': format_datetime(changeset.date), |
|---|
| 375 | 'age': pretty_timedelta(changeset.date), |
|---|
| [3542] | 376 | 'author': changeset.author or 'anonymous', |
|---|
| [18050] | 377 | 'message': msg |
|---|
| [13497] | 378 | } |
|---|
| [15281] | 379 | |
|---|
| 380 | |
|---|
| 381 | def get_node_from_repo(req, repos, path, rev): |
|---|
| 382 | |
|---|
| [18050] | 383 | context = web_context(req) |
|---|
| [15281] | 384 | |
|---|
| [15331] | 385 | if rev: |
|---|
| 386 | rev = repos.normalize_rev(rev) |
|---|
| 387 | # If `rev` is `None`, we'll try to reuse `None` consistently, |
|---|
| 388 | # as a special shortcut to the latest revision. |
|---|
| 389 | rev_or_latest = rev or repos.youngest_rev |
|---|
| 390 | node = get_existing_node(req, repos, path, rev_or_latest) |
|---|
| [15281] | 391 | |
|---|
| 392 | context = context(repos.resource.child('source', path, |
|---|
| 393 | version=rev_or_latest)) |
|---|
| 394 | display_rev = repos.display_rev |
|---|
| 395 | |
|---|
| [16616] | 396 | return node, display_rev, context |
|---|