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