##// END OF EJS Templates
hgweb: wrap {changelogtag}, {changesettag}, and {changesetbookmark}...
Yuya Nishihara -
r37930:26aed0d5 default
parent child Browse files
Show More
@@ -1,735 +1,737 b''
1 # hgweb/webutil.py - utility library for the web interface.
1 # hgweb/webutil.py - utility library for the web interface.
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import copy
11 import copy
12 import difflib
12 import difflib
13 import os
13 import os
14 import re
14 import re
15
15
16 from ..i18n import _
16 from ..i18n import _
17 from ..node import hex, nullid, short
17 from ..node import hex, nullid, short
18
18
19 from .common import (
19 from .common import (
20 ErrorResponse,
20 ErrorResponse,
21 HTTP_BAD_REQUEST,
21 HTTP_BAD_REQUEST,
22 HTTP_NOT_FOUND,
22 HTTP_NOT_FOUND,
23 paritygen,
23 paritygen,
24 )
24 )
25
25
26 from .. import (
26 from .. import (
27 context,
27 context,
28 error,
28 error,
29 match,
29 match,
30 mdiff,
30 mdiff,
31 obsutil,
31 obsutil,
32 patch,
32 patch,
33 pathutil,
33 pathutil,
34 pycompat,
34 pycompat,
35 scmutil,
35 scmutil,
36 templatefilters,
36 templatefilters,
37 templatekw,
37 templatekw,
38 templateutil,
38 templateutil,
39 ui as uimod,
39 ui as uimod,
40 util,
40 util,
41 )
41 )
42
42
43 from ..utils import (
43 from ..utils import (
44 stringutil,
44 stringutil,
45 )
45 )
46
46
47 archivespecs = util.sortdict((
47 archivespecs = util.sortdict((
48 ('zip', ('application/zip', 'zip', '.zip', None)),
48 ('zip', ('application/zip', 'zip', '.zip', None)),
49 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
49 ('gz', ('application/x-gzip', 'tgz', '.tar.gz', None)),
50 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
50 ('bz2', ('application/x-bzip2', 'tbz2', '.tar.bz2', None)),
51 ))
51 ))
52
52
53 def archivelist(ui, nodeid, url=None):
53 def archivelist(ui, nodeid, url=None):
54 allowed = ui.configlist('web', 'allow_archive', untrusted=True)
54 allowed = ui.configlist('web', 'allow_archive', untrusted=True)
55 archives = []
55 archives = []
56
56
57 for typ, spec in archivespecs.iteritems():
57 for typ, spec in archivespecs.iteritems():
58 if typ in allowed or ui.configbool('web', 'allow' + typ,
58 if typ in allowed or ui.configbool('web', 'allow' + typ,
59 untrusted=True):
59 untrusted=True):
60 archives.append({
60 archives.append({
61 'type': typ,
61 'type': typ,
62 'extension': spec[2],
62 'extension': spec[2],
63 'node': nodeid,
63 'node': nodeid,
64 'url': url,
64 'url': url,
65 })
65 })
66
66
67 return templateutil.mappinglist(archives)
67 return templateutil.mappinglist(archives)
68
68
69 def up(p):
69 def up(p):
70 if p[0:1] != "/":
70 if p[0:1] != "/":
71 p = "/" + p
71 p = "/" + p
72 if p[-1:] == "/":
72 if p[-1:] == "/":
73 p = p[:-1]
73 p = p[:-1]
74 up = os.path.dirname(p)
74 up = os.path.dirname(p)
75 if up == "/":
75 if up == "/":
76 return "/"
76 return "/"
77 return up + "/"
77 return up + "/"
78
78
79 def _navseq(step, firststep=None):
79 def _navseq(step, firststep=None):
80 if firststep:
80 if firststep:
81 yield firststep
81 yield firststep
82 if firststep >= 20 and firststep <= 40:
82 if firststep >= 20 and firststep <= 40:
83 firststep = 50
83 firststep = 50
84 yield firststep
84 yield firststep
85 assert step > 0
85 assert step > 0
86 assert firststep > 0
86 assert firststep > 0
87 while step <= firststep:
87 while step <= firststep:
88 step *= 10
88 step *= 10
89 while True:
89 while True:
90 yield 1 * step
90 yield 1 * step
91 yield 3 * step
91 yield 3 * step
92 step *= 10
92 step *= 10
93
93
94 class revnav(object):
94 class revnav(object):
95
95
96 def __init__(self, repo):
96 def __init__(self, repo):
97 """Navigation generation object
97 """Navigation generation object
98
98
99 :repo: repo object we generate nav for
99 :repo: repo object we generate nav for
100 """
100 """
101 # used for hex generation
101 # used for hex generation
102 self._revlog = repo.changelog
102 self._revlog = repo.changelog
103
103
104 def __nonzero__(self):
104 def __nonzero__(self):
105 """return True if any revision to navigate over"""
105 """return True if any revision to navigate over"""
106 return self._first() is not None
106 return self._first() is not None
107
107
108 __bool__ = __nonzero__
108 __bool__ = __nonzero__
109
109
110 def _first(self):
110 def _first(self):
111 """return the minimum non-filtered changeset or None"""
111 """return the minimum non-filtered changeset or None"""
112 try:
112 try:
113 return next(iter(self._revlog))
113 return next(iter(self._revlog))
114 except StopIteration:
114 except StopIteration:
115 return None
115 return None
116
116
117 def hex(self, rev):
117 def hex(self, rev):
118 return hex(self._revlog.node(rev))
118 return hex(self._revlog.node(rev))
119
119
120 def gen(self, pos, pagelen, limit):
120 def gen(self, pos, pagelen, limit):
121 """computes label and revision id for navigation link
121 """computes label and revision id for navigation link
122
122
123 :pos: is the revision relative to which we generate navigation.
123 :pos: is the revision relative to which we generate navigation.
124 :pagelen: the size of each navigation page
124 :pagelen: the size of each navigation page
125 :limit: how far shall we link
125 :limit: how far shall we link
126
126
127 The return is:
127 The return is:
128 - a single element mappinglist
128 - a single element mappinglist
129 - containing a dictionary with a `before` and `after` key
129 - containing a dictionary with a `before` and `after` key
130 - values are dictionaries with `label` and `node` keys
130 - values are dictionaries with `label` and `node` keys
131 """
131 """
132 if not self:
132 if not self:
133 # empty repo
133 # empty repo
134 return templateutil.mappinglist([
134 return templateutil.mappinglist([
135 {'before': templateutil.mappinglist([]),
135 {'before': templateutil.mappinglist([]),
136 'after': templateutil.mappinglist([])},
136 'after': templateutil.mappinglist([])},
137 ])
137 ])
138
138
139 targets = []
139 targets = []
140 for f in _navseq(1, pagelen):
140 for f in _navseq(1, pagelen):
141 if f > limit:
141 if f > limit:
142 break
142 break
143 targets.append(pos + f)
143 targets.append(pos + f)
144 targets.append(pos - f)
144 targets.append(pos - f)
145 targets.sort()
145 targets.sort()
146
146
147 first = self._first()
147 first = self._first()
148 navbefore = [{'label': '(%i)' % first, 'node': self.hex(first)}]
148 navbefore = [{'label': '(%i)' % first, 'node': self.hex(first)}]
149 navafter = []
149 navafter = []
150 for rev in targets:
150 for rev in targets:
151 if rev not in self._revlog:
151 if rev not in self._revlog:
152 continue
152 continue
153 if pos < rev < limit:
153 if pos < rev < limit:
154 navafter.append({'label': '+%d' % abs(rev - pos),
154 navafter.append({'label': '+%d' % abs(rev - pos),
155 'node': self.hex(rev)})
155 'node': self.hex(rev)})
156 if 0 < rev < pos:
156 if 0 < rev < pos:
157 navbefore.append({'label': '-%d' % abs(rev - pos),
157 navbefore.append({'label': '-%d' % abs(rev - pos),
158 'node': self.hex(rev)})
158 'node': self.hex(rev)})
159
159
160 navafter.append({'label': 'tip', 'node': 'tip'})
160 navafter.append({'label': 'tip', 'node': 'tip'})
161
161
162 # TODO: maybe this can be a scalar object supporting tomap()
162 # TODO: maybe this can be a scalar object supporting tomap()
163 return templateutil.mappinglist([
163 return templateutil.mappinglist([
164 {'before': templateutil.mappinglist(navbefore),
164 {'before': templateutil.mappinglist(navbefore),
165 'after': templateutil.mappinglist(navafter)},
165 'after': templateutil.mappinglist(navafter)},
166 ])
166 ])
167
167
168 class filerevnav(revnav):
168 class filerevnav(revnav):
169
169
170 def __init__(self, repo, path):
170 def __init__(self, repo, path):
171 """Navigation generation object
171 """Navigation generation object
172
172
173 :repo: repo object we generate nav for
173 :repo: repo object we generate nav for
174 :path: path of the file we generate nav for
174 :path: path of the file we generate nav for
175 """
175 """
176 # used for iteration
176 # used for iteration
177 self._changelog = repo.unfiltered().changelog
177 self._changelog = repo.unfiltered().changelog
178 # used for hex generation
178 # used for hex generation
179 self._revlog = repo.file(path)
179 self._revlog = repo.file(path)
180
180
181 def hex(self, rev):
181 def hex(self, rev):
182 return hex(self._changelog.node(self._revlog.linkrev(rev)))
182 return hex(self._changelog.node(self._revlog.linkrev(rev)))
183
183
184 # TODO: maybe this can be a wrapper class for changectx/filectx list, which
184 # TODO: maybe this can be a wrapper class for changectx/filectx list, which
185 # yields {'ctx': ctx}
185 # yields {'ctx': ctx}
186 def _ctxsgen(context, ctxs):
186 def _ctxsgen(context, ctxs):
187 for s in ctxs:
187 for s in ctxs:
188 d = {
188 d = {
189 'node': s.hex(),
189 'node': s.hex(),
190 'rev': s.rev(),
190 'rev': s.rev(),
191 'user': s.user(),
191 'user': s.user(),
192 'date': s.date(),
192 'date': s.date(),
193 'description': s.description(),
193 'description': s.description(),
194 'branch': s.branch(),
194 'branch': s.branch(),
195 }
195 }
196 if util.safehasattr(s, 'path'):
196 if util.safehasattr(s, 'path'):
197 d['file'] = s.path()
197 d['file'] = s.path()
198 yield d
198 yield d
199
199
200 def _siblings(siblings=None, hiderev=None):
200 def _siblings(siblings=None, hiderev=None):
201 if siblings is None:
201 if siblings is None:
202 siblings = []
202 siblings = []
203 siblings = [s for s in siblings if s.node() != nullid]
203 siblings = [s for s in siblings if s.node() != nullid]
204 if len(siblings) == 1 and siblings[0].rev() == hiderev:
204 if len(siblings) == 1 and siblings[0].rev() == hiderev:
205 siblings = []
205 siblings = []
206 return templateutil.mappinggenerator(_ctxsgen, args=(siblings,))
206 return templateutil.mappinggenerator(_ctxsgen, args=(siblings,))
207
207
208 def difffeatureopts(req, ui, section):
208 def difffeatureopts(req, ui, section):
209 diffopts = patch.difffeatureopts(ui, untrusted=True,
209 diffopts = patch.difffeatureopts(ui, untrusted=True,
210 section=section, whitespace=True)
210 section=section, whitespace=True)
211
211
212 for k in ('ignorews', 'ignorewsamount', 'ignorewseol', 'ignoreblanklines'):
212 for k in ('ignorews', 'ignorewsamount', 'ignorewseol', 'ignoreblanklines'):
213 v = req.qsparams.get(k)
213 v = req.qsparams.get(k)
214 if v is not None:
214 if v is not None:
215 v = stringutil.parsebool(v)
215 v = stringutil.parsebool(v)
216 setattr(diffopts, k, v if v is not None else True)
216 setattr(diffopts, k, v if v is not None else True)
217
217
218 return diffopts
218 return diffopts
219
219
220 def annotate(req, fctx, ui):
220 def annotate(req, fctx, ui):
221 diffopts = difffeatureopts(req, ui, 'annotate')
221 diffopts = difffeatureopts(req, ui, 'annotate')
222 return fctx.annotate(follow=True, diffopts=diffopts)
222 return fctx.annotate(follow=True, diffopts=diffopts)
223
223
224 def parents(ctx, hide=None):
224 def parents(ctx, hide=None):
225 if isinstance(ctx, context.basefilectx):
225 if isinstance(ctx, context.basefilectx):
226 introrev = ctx.introrev()
226 introrev = ctx.introrev()
227 if ctx.changectx().rev() != introrev:
227 if ctx.changectx().rev() != introrev:
228 return _siblings([ctx.repo()[introrev]], hide)
228 return _siblings([ctx.repo()[introrev]], hide)
229 return _siblings(ctx.parents(), hide)
229 return _siblings(ctx.parents(), hide)
230
230
231 def children(ctx, hide=None):
231 def children(ctx, hide=None):
232 return _siblings(ctx.children(), hide)
232 return _siblings(ctx.children(), hide)
233
233
234 def renamelink(fctx):
234 def renamelink(fctx):
235 r = fctx.renamed()
235 r = fctx.renamed()
236 if r:
236 if r:
237 return templateutil.mappinglist([{'file': r[0], 'node': hex(r[1])}])
237 return templateutil.mappinglist([{'file': r[0], 'node': hex(r[1])}])
238 return templateutil.mappinglist([])
238 return templateutil.mappinglist([])
239
239
240 def nodetagsdict(repo, node):
240 def nodetagsdict(repo, node):
241 return templateutil.hybridlist(repo.nodetags(node), name='name')
241 return templateutil.hybridlist(repo.nodetags(node), name='name')
242
242
243 def nodebookmarksdict(repo, node):
243 def nodebookmarksdict(repo, node):
244 return templateutil.hybridlist(repo.nodebookmarks(node), name='name')
244 return templateutil.hybridlist(repo.nodebookmarks(node), name='name')
245
245
246 def nodebranchdict(repo, ctx):
246 def nodebranchdict(repo, ctx):
247 branches = []
247 branches = []
248 branch = ctx.branch()
248 branch = ctx.branch()
249 # If this is an empty repo, ctx.node() == nullid,
249 # If this is an empty repo, ctx.node() == nullid,
250 # ctx.branch() == 'default'.
250 # ctx.branch() == 'default'.
251 try:
251 try:
252 branchnode = repo.branchtip(branch)
252 branchnode = repo.branchtip(branch)
253 except error.RepoLookupError:
253 except error.RepoLookupError:
254 branchnode = None
254 branchnode = None
255 if branchnode == ctx.node():
255 if branchnode == ctx.node():
256 branches.append(branch)
256 branches.append(branch)
257 return templateutil.hybridlist(branches, name='name')
257 return templateutil.hybridlist(branches, name='name')
258
258
259 def nodeinbranch(repo, ctx):
259 def nodeinbranch(repo, ctx):
260 branches = []
260 branches = []
261 branch = ctx.branch()
261 branch = ctx.branch()
262 try:
262 try:
263 branchnode = repo.branchtip(branch)
263 branchnode = repo.branchtip(branch)
264 except error.RepoLookupError:
264 except error.RepoLookupError:
265 branchnode = None
265 branchnode = None
266 if branch != 'default' and branchnode != ctx.node():
266 if branch != 'default' and branchnode != ctx.node():
267 branches.append(branch)
267 branches.append(branch)
268 return templateutil.hybridlist(branches, name='name')
268 return templateutil.hybridlist(branches, name='name')
269
269
270 def nodebranchnodefault(ctx):
270 def nodebranchnodefault(ctx):
271 branches = []
271 branches = []
272 branch = ctx.branch()
272 branch = ctx.branch()
273 if branch != 'default':
273 if branch != 'default':
274 branches.append(branch)
274 branches.append(branch)
275 return templateutil.hybridlist(branches, name='name')
275 return templateutil.hybridlist(branches, name='name')
276
276
277 def _nodenamesgen(context, f, node, name):
278 for t in f(node):
279 yield {name: t}
280
277 def showtag(repo, tmpl, t1, node=nullid):
281 def showtag(repo, tmpl, t1, node=nullid):
278 for t in repo.nodetags(node):
282 args = (repo.nodetags, node, 'tag')
279 lm = {'tag': t}
283 return templateutil.mappinggenerator(_nodenamesgen, args=args, name=t1)
280 yield tmpl.generate(t1, lm)
281
284
282 def showbookmark(repo, tmpl, t1, node=nullid):
285 def showbookmark(repo, tmpl, t1, node=nullid):
283 for t in repo.nodebookmarks(node):
286 args = (repo.nodebookmarks, node, 'bookmark')
284 lm = {'bookmark': t}
287 return templateutil.mappinggenerator(_nodenamesgen, args=args, name=t1)
285 yield tmpl.generate(t1, lm)
286
288
287 def branchentries(repo, stripecount, limit=0):
289 def branchentries(repo, stripecount, limit=0):
288 tips = []
290 tips = []
289 heads = repo.heads()
291 heads = repo.heads()
290 parity = paritygen(stripecount)
292 parity = paritygen(stripecount)
291 sortkey = lambda item: (not item[1], item[0].rev())
293 sortkey = lambda item: (not item[1], item[0].rev())
292
294
293 def entries(**map):
295 def entries(**map):
294 count = 0
296 count = 0
295 if not tips:
297 if not tips:
296 for tag, hs, tip, closed in repo.branchmap().iterbranches():
298 for tag, hs, tip, closed in repo.branchmap().iterbranches():
297 tips.append((repo[tip], closed))
299 tips.append((repo[tip], closed))
298 for ctx, closed in sorted(tips, key=sortkey, reverse=True):
300 for ctx, closed in sorted(tips, key=sortkey, reverse=True):
299 if limit > 0 and count >= limit:
301 if limit > 0 and count >= limit:
300 return
302 return
301 count += 1
303 count += 1
302 if closed:
304 if closed:
303 status = 'closed'
305 status = 'closed'
304 elif ctx.node() not in heads:
306 elif ctx.node() not in heads:
305 status = 'inactive'
307 status = 'inactive'
306 else:
308 else:
307 status = 'open'
309 status = 'open'
308 yield {
310 yield {
309 'parity': next(parity),
311 'parity': next(parity),
310 'branch': ctx.branch(),
312 'branch': ctx.branch(),
311 'status': status,
313 'status': status,
312 'node': ctx.hex(),
314 'node': ctx.hex(),
313 'date': ctx.date()
315 'date': ctx.date()
314 }
316 }
315
317
316 return entries
318 return entries
317
319
318 def cleanpath(repo, path):
320 def cleanpath(repo, path):
319 path = path.lstrip('/')
321 path = path.lstrip('/')
320 return pathutil.canonpath(repo.root, '', path)
322 return pathutil.canonpath(repo.root, '', path)
321
323
322 def changectx(repo, req):
324 def changectx(repo, req):
323 changeid = "tip"
325 changeid = "tip"
324 if 'node' in req.qsparams:
326 if 'node' in req.qsparams:
325 changeid = req.qsparams['node']
327 changeid = req.qsparams['node']
326 ipos = changeid.find(':')
328 ipos = changeid.find(':')
327 if ipos != -1:
329 if ipos != -1:
328 changeid = changeid[(ipos + 1):]
330 changeid = changeid[(ipos + 1):]
329
331
330 return scmutil.revsymbol(repo, changeid)
332 return scmutil.revsymbol(repo, changeid)
331
333
332 def basechangectx(repo, req):
334 def basechangectx(repo, req):
333 if 'node' in req.qsparams:
335 if 'node' in req.qsparams:
334 changeid = req.qsparams['node']
336 changeid = req.qsparams['node']
335 ipos = changeid.find(':')
337 ipos = changeid.find(':')
336 if ipos != -1:
338 if ipos != -1:
337 changeid = changeid[:ipos]
339 changeid = changeid[:ipos]
338 return scmutil.revsymbol(repo, changeid)
340 return scmutil.revsymbol(repo, changeid)
339
341
340 return None
342 return None
341
343
342 def filectx(repo, req):
344 def filectx(repo, req):
343 if 'file' not in req.qsparams:
345 if 'file' not in req.qsparams:
344 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
346 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
345 path = cleanpath(repo, req.qsparams['file'])
347 path = cleanpath(repo, req.qsparams['file'])
346 if 'node' in req.qsparams:
348 if 'node' in req.qsparams:
347 changeid = req.qsparams['node']
349 changeid = req.qsparams['node']
348 elif 'filenode' in req.qsparams:
350 elif 'filenode' in req.qsparams:
349 changeid = req.qsparams['filenode']
351 changeid = req.qsparams['filenode']
350 else:
352 else:
351 raise ErrorResponse(HTTP_NOT_FOUND, 'node or filenode not given')
353 raise ErrorResponse(HTTP_NOT_FOUND, 'node or filenode not given')
352 try:
354 try:
353 fctx = scmutil.revsymbol(repo, changeid)[path]
355 fctx = scmutil.revsymbol(repo, changeid)[path]
354 except error.RepoError:
356 except error.RepoError:
355 fctx = repo.filectx(path, fileid=changeid)
357 fctx = repo.filectx(path, fileid=changeid)
356
358
357 return fctx
359 return fctx
358
360
359 def linerange(req):
361 def linerange(req):
360 linerange = req.qsparams.getall('linerange')
362 linerange = req.qsparams.getall('linerange')
361 if not linerange:
363 if not linerange:
362 return None
364 return None
363 if len(linerange) > 1:
365 if len(linerange) > 1:
364 raise ErrorResponse(HTTP_BAD_REQUEST,
366 raise ErrorResponse(HTTP_BAD_REQUEST,
365 'redundant linerange parameter')
367 'redundant linerange parameter')
366 try:
368 try:
367 fromline, toline = map(int, linerange[0].split(':', 1))
369 fromline, toline = map(int, linerange[0].split(':', 1))
368 except ValueError:
370 except ValueError:
369 raise ErrorResponse(HTTP_BAD_REQUEST,
371 raise ErrorResponse(HTTP_BAD_REQUEST,
370 'invalid linerange parameter')
372 'invalid linerange parameter')
371 try:
373 try:
372 return util.processlinerange(fromline, toline)
374 return util.processlinerange(fromline, toline)
373 except error.ParseError as exc:
375 except error.ParseError as exc:
374 raise ErrorResponse(HTTP_BAD_REQUEST, pycompat.bytestr(exc))
376 raise ErrorResponse(HTTP_BAD_REQUEST, pycompat.bytestr(exc))
375
377
376 def formatlinerange(fromline, toline):
378 def formatlinerange(fromline, toline):
377 return '%d:%d' % (fromline + 1, toline)
379 return '%d:%d' % (fromline + 1, toline)
378
380
379 def succsandmarkers(context, mapping):
381 def succsandmarkers(context, mapping):
380 repo = context.resource(mapping, 'repo')
382 repo = context.resource(mapping, 'repo')
381 itemmappings = templatekw.showsuccsandmarkers(context, mapping)
383 itemmappings = templatekw.showsuccsandmarkers(context, mapping)
382 for item in itemmappings.tovalue(context, mapping):
384 for item in itemmappings.tovalue(context, mapping):
383 item['successors'] = _siblings(repo[successor]
385 item['successors'] = _siblings(repo[successor]
384 for successor in item['successors'])
386 for successor in item['successors'])
385 yield item
387 yield item
386
388
387 # teach templater succsandmarkers is switched to (context, mapping) API
389 # teach templater succsandmarkers is switched to (context, mapping) API
388 succsandmarkers._requires = {'repo', 'ctx'}
390 succsandmarkers._requires = {'repo', 'ctx'}
389
391
390 def whyunstable(context, mapping):
392 def whyunstable(context, mapping):
391 repo = context.resource(mapping, 'repo')
393 repo = context.resource(mapping, 'repo')
392 ctx = context.resource(mapping, 'ctx')
394 ctx = context.resource(mapping, 'ctx')
393
395
394 entries = obsutil.whyunstable(repo, ctx)
396 entries = obsutil.whyunstable(repo, ctx)
395 for entry in entries:
397 for entry in entries:
396 if entry.get('divergentnodes'):
398 if entry.get('divergentnodes'):
397 entry['divergentnodes'] = _siblings(entry['divergentnodes'])
399 entry['divergentnodes'] = _siblings(entry['divergentnodes'])
398 yield entry
400 yield entry
399
401
400 whyunstable._requires = {'repo', 'ctx'}
402 whyunstable._requires = {'repo', 'ctx'}
401
403
402 def commonentry(repo, ctx):
404 def commonentry(repo, ctx):
403 node = ctx.node()
405 node = ctx.node()
404 return {
406 return {
405 # TODO: perhaps ctx.changectx() should be assigned if ctx is a
407 # TODO: perhaps ctx.changectx() should be assigned if ctx is a
406 # filectx, but I'm not pretty sure if that would always work because
408 # filectx, but I'm not pretty sure if that would always work because
407 # fctx.parents() != fctx.changectx.parents() for example.
409 # fctx.parents() != fctx.changectx.parents() for example.
408 'ctx': ctx,
410 'ctx': ctx,
409 'rev': ctx.rev(),
411 'rev': ctx.rev(),
410 'node': hex(node),
412 'node': hex(node),
411 'author': ctx.user(),
413 'author': ctx.user(),
412 'desc': ctx.description(),
414 'desc': ctx.description(),
413 'date': ctx.date(),
415 'date': ctx.date(),
414 'extra': ctx.extra(),
416 'extra': ctx.extra(),
415 'phase': ctx.phasestr(),
417 'phase': ctx.phasestr(),
416 'obsolete': ctx.obsolete(),
418 'obsolete': ctx.obsolete(),
417 'succsandmarkers': succsandmarkers,
419 'succsandmarkers': succsandmarkers,
418 'instabilities': [{"instability": i} for i in ctx.instabilities()],
420 'instabilities': [{"instability": i} for i in ctx.instabilities()],
419 'whyunstable': whyunstable,
421 'whyunstable': whyunstable,
420 'branch': nodebranchnodefault(ctx),
422 'branch': nodebranchnodefault(ctx),
421 'inbranch': nodeinbranch(repo, ctx),
423 'inbranch': nodeinbranch(repo, ctx),
422 'branches': nodebranchdict(repo, ctx),
424 'branches': nodebranchdict(repo, ctx),
423 'tags': nodetagsdict(repo, node),
425 'tags': nodetagsdict(repo, node),
424 'bookmarks': nodebookmarksdict(repo, node),
426 'bookmarks': nodebookmarksdict(repo, node),
425 'parent': lambda **x: parents(ctx),
427 'parent': lambda **x: parents(ctx),
426 'child': lambda **x: children(ctx),
428 'child': lambda **x: children(ctx),
427 }
429 }
428
430
429 def changelistentry(web, ctx):
431 def changelistentry(web, ctx):
430 '''Obtain a dictionary to be used for entries in a changelist.
432 '''Obtain a dictionary to be used for entries in a changelist.
431
433
432 This function is called when producing items for the "entries" list passed
434 This function is called when producing items for the "entries" list passed
433 to the "shortlog" and "changelog" templates.
435 to the "shortlog" and "changelog" templates.
434 '''
436 '''
435 repo = web.repo
437 repo = web.repo
436 rev = ctx.rev()
438 rev = ctx.rev()
437 n = ctx.node()
439 n = ctx.node()
438 showtags = showtag(repo, web.tmpl, 'changelogtag', n)
440 showtags = showtag(repo, web.tmpl, 'changelogtag', n)
439 files = listfilediffs(web.tmpl, ctx.files(), n, web.maxfiles)
441 files = listfilediffs(web.tmpl, ctx.files(), n, web.maxfiles)
440
442
441 entry = commonentry(repo, ctx)
443 entry = commonentry(repo, ctx)
442 entry.update(
444 entry.update(
443 allparents=lambda **x: parents(ctx),
445 allparents=lambda **x: parents(ctx),
444 parent=lambda **x: parents(ctx, rev - 1),
446 parent=lambda **x: parents(ctx, rev - 1),
445 child=lambda **x: children(ctx, rev + 1),
447 child=lambda **x: children(ctx, rev + 1),
446 changelogtag=showtags,
448 changelogtag=showtags,
447 files=files,
449 files=files,
448 )
450 )
449 return entry
451 return entry
450
452
451 def symrevorshortnode(req, ctx):
453 def symrevorshortnode(req, ctx):
452 if 'node' in req.qsparams:
454 if 'node' in req.qsparams:
453 return templatefilters.revescape(req.qsparams['node'])
455 return templatefilters.revescape(req.qsparams['node'])
454 else:
456 else:
455 return short(ctx.node())
457 return short(ctx.node())
456
458
457 def changesetentry(web, ctx):
459 def changesetentry(web, ctx):
458 '''Obtain a dictionary to be used to render the "changeset" template.'''
460 '''Obtain a dictionary to be used to render the "changeset" template.'''
459
461
460 showtags = showtag(web.repo, web.tmpl, 'changesettag', ctx.node())
462 showtags = showtag(web.repo, web.tmpl, 'changesettag', ctx.node())
461 showbookmarks = showbookmark(web.repo, web.tmpl, 'changesetbookmark',
463 showbookmarks = showbookmark(web.repo, web.tmpl, 'changesetbookmark',
462 ctx.node())
464 ctx.node())
463 showbranch = nodebranchnodefault(ctx)
465 showbranch = nodebranchnodefault(ctx)
464
466
465 files = []
467 files = []
466 parity = paritygen(web.stripecount)
468 parity = paritygen(web.stripecount)
467 for blockno, f in enumerate(ctx.files()):
469 for blockno, f in enumerate(ctx.files()):
468 template = 'filenodelink' if f in ctx else 'filenolink'
470 template = 'filenodelink' if f in ctx else 'filenolink'
469 files.append(web.tmpl.generate(template, {
471 files.append(web.tmpl.generate(template, {
470 'node': ctx.hex(),
472 'node': ctx.hex(),
471 'file': f,
473 'file': f,
472 'blockno': blockno + 1,
474 'blockno': blockno + 1,
473 'parity': next(parity),
475 'parity': next(parity),
474 }))
476 }))
475
477
476 basectx = basechangectx(web.repo, web.req)
478 basectx = basechangectx(web.repo, web.req)
477 if basectx is None:
479 if basectx is None:
478 basectx = ctx.p1()
480 basectx = ctx.p1()
479
481
480 style = web.config('web', 'style')
482 style = web.config('web', 'style')
481 if 'style' in web.req.qsparams:
483 if 'style' in web.req.qsparams:
482 style = web.req.qsparams['style']
484 style = web.req.qsparams['style']
483
485
484 diff = diffs(web, ctx, basectx, None, style)
486 diff = diffs(web, ctx, basectx, None, style)
485
487
486 parity = paritygen(web.stripecount)
488 parity = paritygen(web.stripecount)
487 diffstatsgen = diffstatgen(ctx, basectx)
489 diffstatsgen = diffstatgen(ctx, basectx)
488 diffstats = diffstat(web.tmpl, ctx, diffstatsgen, parity)
490 diffstats = diffstat(web.tmpl, ctx, diffstatsgen, parity)
489
491
490 return dict(
492 return dict(
491 diff=diff,
493 diff=diff,
492 symrev=symrevorshortnode(web.req, ctx),
494 symrev=symrevorshortnode(web.req, ctx),
493 basenode=basectx.hex(),
495 basenode=basectx.hex(),
494 changesettag=showtags,
496 changesettag=showtags,
495 changesetbookmark=showbookmarks,
497 changesetbookmark=showbookmarks,
496 changesetbranch=showbranch,
498 changesetbranch=showbranch,
497 files=files,
499 files=files,
498 diffsummary=lambda **x: diffsummary(diffstatsgen),
500 diffsummary=lambda **x: diffsummary(diffstatsgen),
499 diffstat=diffstats,
501 diffstat=diffstats,
500 archives=web.archivelist(ctx.hex()),
502 archives=web.archivelist(ctx.hex()),
501 **pycompat.strkwargs(commonentry(web.repo, ctx)))
503 **pycompat.strkwargs(commonentry(web.repo, ctx)))
502
504
503 def listfilediffs(tmpl, files, node, max):
505 def listfilediffs(tmpl, files, node, max):
504 for f in files[:max]:
506 for f in files[:max]:
505 yield tmpl.generate('filedifflink', {'node': hex(node), 'file': f})
507 yield tmpl.generate('filedifflink', {'node': hex(node), 'file': f})
506 if len(files) > max:
508 if len(files) > max:
507 yield tmpl.generate('fileellipses', {})
509 yield tmpl.generate('fileellipses', {})
508
510
509 def diffs(web, ctx, basectx, files, style, linerange=None,
511 def diffs(web, ctx, basectx, files, style, linerange=None,
510 lineidprefix=''):
512 lineidprefix=''):
511
513
512 def prettyprintlines(lines, blockno):
514 def prettyprintlines(lines, blockno):
513 for lineno, l in enumerate(lines, 1):
515 for lineno, l in enumerate(lines, 1):
514 difflineno = "%d.%d" % (blockno, lineno)
516 difflineno = "%d.%d" % (blockno, lineno)
515 if l.startswith('+'):
517 if l.startswith('+'):
516 ltype = "difflineplus"
518 ltype = "difflineplus"
517 elif l.startswith('-'):
519 elif l.startswith('-'):
518 ltype = "difflineminus"
520 ltype = "difflineminus"
519 elif l.startswith('@'):
521 elif l.startswith('@'):
520 ltype = "difflineat"
522 ltype = "difflineat"
521 else:
523 else:
522 ltype = "diffline"
524 ltype = "diffline"
523 yield web.tmpl.generate(ltype, {
525 yield web.tmpl.generate(ltype, {
524 'line': l,
526 'line': l,
525 'lineno': lineno,
527 'lineno': lineno,
526 'lineid': lineidprefix + "l%s" % difflineno,
528 'lineid': lineidprefix + "l%s" % difflineno,
527 'linenumber': "% 8s" % difflineno,
529 'linenumber': "% 8s" % difflineno,
528 })
530 })
529
531
530 repo = web.repo
532 repo = web.repo
531 if files:
533 if files:
532 m = match.exact(repo.root, repo.getcwd(), files)
534 m = match.exact(repo.root, repo.getcwd(), files)
533 else:
535 else:
534 m = match.always(repo.root, repo.getcwd())
536 m = match.always(repo.root, repo.getcwd())
535
537
536 diffopts = patch.diffopts(repo.ui, untrusted=True)
538 diffopts = patch.diffopts(repo.ui, untrusted=True)
537 node1 = basectx.node()
539 node1 = basectx.node()
538 node2 = ctx.node()
540 node2 = ctx.node()
539 parity = paritygen(web.stripecount)
541 parity = paritygen(web.stripecount)
540
542
541 diffhunks = patch.diffhunks(repo, node1, node2, m, opts=diffopts)
543 diffhunks = patch.diffhunks(repo, node1, node2, m, opts=diffopts)
542 for blockno, (fctx1, fctx2, header, hunks) in enumerate(diffhunks, 1):
544 for blockno, (fctx1, fctx2, header, hunks) in enumerate(diffhunks, 1):
543 if style != 'raw':
545 if style != 'raw':
544 header = header[1:]
546 header = header[1:]
545 lines = [h + '\n' for h in header]
547 lines = [h + '\n' for h in header]
546 for hunkrange, hunklines in hunks:
548 for hunkrange, hunklines in hunks:
547 if linerange is not None and hunkrange is not None:
549 if linerange is not None and hunkrange is not None:
548 s1, l1, s2, l2 = hunkrange
550 s1, l1, s2, l2 = hunkrange
549 if not mdiff.hunkinrange((s2, l2), linerange):
551 if not mdiff.hunkinrange((s2, l2), linerange):
550 continue
552 continue
551 lines.extend(hunklines)
553 lines.extend(hunklines)
552 if lines:
554 if lines:
553 yield web.tmpl.generate('diffblock', {
555 yield web.tmpl.generate('diffblock', {
554 'parity': next(parity),
556 'parity': next(parity),
555 'blockno': blockno,
557 'blockno': blockno,
556 'lines': prettyprintlines(lines, blockno),
558 'lines': prettyprintlines(lines, blockno),
557 })
559 })
558
560
559 def compare(tmpl, context, leftlines, rightlines):
561 def compare(tmpl, context, leftlines, rightlines):
560 '''Generator function that provides side-by-side comparison data.'''
562 '''Generator function that provides side-by-side comparison data.'''
561
563
562 def compline(type, leftlineno, leftline, rightlineno, rightline):
564 def compline(type, leftlineno, leftline, rightlineno, rightline):
563 lineid = leftlineno and ("l%d" % leftlineno) or ''
565 lineid = leftlineno and ("l%d" % leftlineno) or ''
564 lineid += rightlineno and ("r%d" % rightlineno) or ''
566 lineid += rightlineno and ("r%d" % rightlineno) or ''
565 llno = '%d' % leftlineno if leftlineno else ''
567 llno = '%d' % leftlineno if leftlineno else ''
566 rlno = '%d' % rightlineno if rightlineno else ''
568 rlno = '%d' % rightlineno if rightlineno else ''
567 return tmpl.generate('comparisonline', {
569 return tmpl.generate('comparisonline', {
568 'type': type,
570 'type': type,
569 'lineid': lineid,
571 'lineid': lineid,
570 'leftlineno': leftlineno,
572 'leftlineno': leftlineno,
571 'leftlinenumber': "% 6s" % llno,
573 'leftlinenumber': "% 6s" % llno,
572 'leftline': leftline or '',
574 'leftline': leftline or '',
573 'rightlineno': rightlineno,
575 'rightlineno': rightlineno,
574 'rightlinenumber': "% 6s" % rlno,
576 'rightlinenumber': "% 6s" % rlno,
575 'rightline': rightline or '',
577 'rightline': rightline or '',
576 })
578 })
577
579
578 def getblock(opcodes):
580 def getblock(opcodes):
579 for type, llo, lhi, rlo, rhi in opcodes:
581 for type, llo, lhi, rlo, rhi in opcodes:
580 len1 = lhi - llo
582 len1 = lhi - llo
581 len2 = rhi - rlo
583 len2 = rhi - rlo
582 count = min(len1, len2)
584 count = min(len1, len2)
583 for i in xrange(count):
585 for i in xrange(count):
584 yield compline(type=type,
586 yield compline(type=type,
585 leftlineno=llo + i + 1,
587 leftlineno=llo + i + 1,
586 leftline=leftlines[llo + i],
588 leftline=leftlines[llo + i],
587 rightlineno=rlo + i + 1,
589 rightlineno=rlo + i + 1,
588 rightline=rightlines[rlo + i])
590 rightline=rightlines[rlo + i])
589 if len1 > len2:
591 if len1 > len2:
590 for i in xrange(llo + count, lhi):
592 for i in xrange(llo + count, lhi):
591 yield compline(type=type,
593 yield compline(type=type,
592 leftlineno=i + 1,
594 leftlineno=i + 1,
593 leftline=leftlines[i],
595 leftline=leftlines[i],
594 rightlineno=None,
596 rightlineno=None,
595 rightline=None)
597 rightline=None)
596 elif len2 > len1:
598 elif len2 > len1:
597 for i in xrange(rlo + count, rhi):
599 for i in xrange(rlo + count, rhi):
598 yield compline(type=type,
600 yield compline(type=type,
599 leftlineno=None,
601 leftlineno=None,
600 leftline=None,
602 leftline=None,
601 rightlineno=i + 1,
603 rightlineno=i + 1,
602 rightline=rightlines[i])
604 rightline=rightlines[i])
603
605
604 s = difflib.SequenceMatcher(None, leftlines, rightlines)
606 s = difflib.SequenceMatcher(None, leftlines, rightlines)
605 if context < 0:
607 if context < 0:
606 yield tmpl.generate('comparisonblock',
608 yield tmpl.generate('comparisonblock',
607 {'lines': getblock(s.get_opcodes())})
609 {'lines': getblock(s.get_opcodes())})
608 else:
610 else:
609 for oc in s.get_grouped_opcodes(n=context):
611 for oc in s.get_grouped_opcodes(n=context):
610 yield tmpl.generate('comparisonblock', {'lines': getblock(oc)})
612 yield tmpl.generate('comparisonblock', {'lines': getblock(oc)})
611
613
612 def diffstatgen(ctx, basectx):
614 def diffstatgen(ctx, basectx):
613 '''Generator function that provides the diffstat data.'''
615 '''Generator function that provides the diffstat data.'''
614
616
615 stats = patch.diffstatdata(
617 stats = patch.diffstatdata(
616 util.iterlines(ctx.diff(basectx, noprefix=False)))
618 util.iterlines(ctx.diff(basectx, noprefix=False)))
617 maxname, maxtotal, addtotal, removetotal, binary = patch.diffstatsum(stats)
619 maxname, maxtotal, addtotal, removetotal, binary = patch.diffstatsum(stats)
618 while True:
620 while True:
619 yield stats, maxname, maxtotal, addtotal, removetotal, binary
621 yield stats, maxname, maxtotal, addtotal, removetotal, binary
620
622
621 def diffsummary(statgen):
623 def diffsummary(statgen):
622 '''Return a short summary of the diff.'''
624 '''Return a short summary of the diff.'''
623
625
624 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
626 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
625 return _(' %d files changed, %d insertions(+), %d deletions(-)\n') % (
627 return _(' %d files changed, %d insertions(+), %d deletions(-)\n') % (
626 len(stats), addtotal, removetotal)
628 len(stats), addtotal, removetotal)
627
629
628 def diffstat(tmpl, ctx, statgen, parity):
630 def diffstat(tmpl, ctx, statgen, parity):
629 '''Return a diffstat template for each file in the diff.'''
631 '''Return a diffstat template for each file in the diff.'''
630
632
631 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
633 stats, maxname, maxtotal, addtotal, removetotal, binary = next(statgen)
632 files = ctx.files()
634 files = ctx.files()
633
635
634 def pct(i):
636 def pct(i):
635 if maxtotal == 0:
637 if maxtotal == 0:
636 return 0
638 return 0
637 return (float(i) / maxtotal) * 100
639 return (float(i) / maxtotal) * 100
638
640
639 fileno = 0
641 fileno = 0
640 for filename, adds, removes, isbinary in stats:
642 for filename, adds, removes, isbinary in stats:
641 template = 'diffstatlink' if filename in files else 'diffstatnolink'
643 template = 'diffstatlink' if filename in files else 'diffstatnolink'
642 total = adds + removes
644 total = adds + removes
643 fileno += 1
645 fileno += 1
644 yield tmpl.generate(template, {
646 yield tmpl.generate(template, {
645 'node': ctx.hex(),
647 'node': ctx.hex(),
646 'file': filename,
648 'file': filename,
647 'fileno': fileno,
649 'fileno': fileno,
648 'total': total,
650 'total': total,
649 'addpct': pct(adds),
651 'addpct': pct(adds),
650 'removepct': pct(removes),
652 'removepct': pct(removes),
651 'parity': next(parity),
653 'parity': next(parity),
652 })
654 })
653
655
654 class sessionvars(templateutil.wrapped):
656 class sessionvars(templateutil.wrapped):
655 def __init__(self, vars, start='?'):
657 def __init__(self, vars, start='?'):
656 self._start = start
658 self._start = start
657 self._vars = vars
659 self._vars = vars
658
660
659 def __getitem__(self, key):
661 def __getitem__(self, key):
660 return self._vars[key]
662 return self._vars[key]
661
663
662 def __setitem__(self, key, value):
664 def __setitem__(self, key, value):
663 self._vars[key] = value
665 self._vars[key] = value
664
666
665 def __copy__(self):
667 def __copy__(self):
666 return sessionvars(copy.copy(self._vars), self._start)
668 return sessionvars(copy.copy(self._vars), self._start)
667
669
668 def itermaps(self, context):
670 def itermaps(self, context):
669 separator = self._start
671 separator = self._start
670 for key, value in sorted(self._vars.iteritems()):
672 for key, value in sorted(self._vars.iteritems()):
671 yield {'name': key,
673 yield {'name': key,
672 'value': pycompat.bytestr(value),
674 'value': pycompat.bytestr(value),
673 'separator': separator,
675 'separator': separator,
674 }
676 }
675 separator = '&'
677 separator = '&'
676
678
677 def join(self, context, mapping, sep):
679 def join(self, context, mapping, sep):
678 # could be '{separator}{name}={value|urlescape}'
680 # could be '{separator}{name}={value|urlescape}'
679 raise error.ParseError(_('not displayable without template'))
681 raise error.ParseError(_('not displayable without template'))
680
682
681 def show(self, context, mapping):
683 def show(self, context, mapping):
682 return self.join(context, '')
684 return self.join(context, '')
683
685
684 def tovalue(self, context, mapping):
686 def tovalue(self, context, mapping):
685 return self._vars
687 return self._vars
686
688
687 class wsgiui(uimod.ui):
689 class wsgiui(uimod.ui):
688 # default termwidth breaks under mod_wsgi
690 # default termwidth breaks under mod_wsgi
689 def termwidth(self):
691 def termwidth(self):
690 return 80
692 return 80
691
693
692 def getwebsubs(repo):
694 def getwebsubs(repo):
693 websubtable = []
695 websubtable = []
694 websubdefs = repo.ui.configitems('websub')
696 websubdefs = repo.ui.configitems('websub')
695 # we must maintain interhg backwards compatibility
697 # we must maintain interhg backwards compatibility
696 websubdefs += repo.ui.configitems('interhg')
698 websubdefs += repo.ui.configitems('interhg')
697 for key, pattern in websubdefs:
699 for key, pattern in websubdefs:
698 # grab the delimiter from the character after the "s"
700 # grab the delimiter from the character after the "s"
699 unesc = pattern[1:2]
701 unesc = pattern[1:2]
700 delim = re.escape(unesc)
702 delim = re.escape(unesc)
701
703
702 # identify portions of the pattern, taking care to avoid escaped
704 # identify portions of the pattern, taking care to avoid escaped
703 # delimiters. the replace format and flags are optional, but
705 # delimiters. the replace format and flags are optional, but
704 # delimiters are required.
706 # delimiters are required.
705 match = re.match(
707 match = re.match(
706 br'^s%s(.+)(?:(?<=\\\\)|(?<!\\))%s(.*)%s([ilmsux])*$'
708 br'^s%s(.+)(?:(?<=\\\\)|(?<!\\))%s(.*)%s([ilmsux])*$'
707 % (delim, delim, delim), pattern)
709 % (delim, delim, delim), pattern)
708 if not match:
710 if not match:
709 repo.ui.warn(_("websub: invalid pattern for %s: %s\n")
711 repo.ui.warn(_("websub: invalid pattern for %s: %s\n")
710 % (key, pattern))
712 % (key, pattern))
711 continue
713 continue
712
714
713 # we need to unescape the delimiter for regexp and format
715 # we need to unescape the delimiter for regexp and format
714 delim_re = re.compile(br'(?<!\\)\\%s' % delim)
716 delim_re = re.compile(br'(?<!\\)\\%s' % delim)
715 regexp = delim_re.sub(unesc, match.group(1))
717 regexp = delim_re.sub(unesc, match.group(1))
716 format = delim_re.sub(unesc, match.group(2))
718 format = delim_re.sub(unesc, match.group(2))
717
719
718 # the pattern allows for 6 regexp flags, so set them if necessary
720 # the pattern allows for 6 regexp flags, so set them if necessary
719 flagin = match.group(3)
721 flagin = match.group(3)
720 flags = 0
722 flags = 0
721 if flagin:
723 if flagin:
722 for flag in flagin.upper():
724 for flag in flagin.upper():
723 flags |= re.__dict__[flag]
725 flags |= re.__dict__[flag]
724
726
725 try:
727 try:
726 regexp = re.compile(regexp, flags)
728 regexp = re.compile(regexp, flags)
727 websubtable.append((regexp, format))
729 websubtable.append((regexp, format))
728 except re.error:
730 except re.error:
729 repo.ui.warn(_("websub: invalid regexp for %s: %s\n")
731 repo.ui.warn(_("websub: invalid regexp for %s: %s\n")
730 % (key, regexp))
732 % (key, regexp))
731 return websubtable
733 return websubtable
732
734
733 def getgraphnode(repo, ctx):
735 def getgraphnode(repo, ctx):
734 return (templatekw.getgraphnodecurrent(repo, ctx) +
736 return (templatekw.getgraphnodecurrent(repo, ctx) +
735 templatekw.getgraphnodesymbol(ctx))
737 templatekw.getgraphnodesymbol(ctx))
General Comments 0
You need to be logged in to leave comments. Login now