##// END OF EJS Templates
hgweb: enumerate lines in loop header, not before...
av6 -
r29538:df7d8ea9 default
parent child Browse files
Show More
@@ -1,1312 +1,1311 b''
1 1 #
2 2 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 3 # Copyright 2005-2007 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from __future__ import absolute_import
9 9
10 10 import cgi
11 11 import copy
12 12 import mimetypes
13 13 import os
14 14 import re
15 15
16 16 from ..i18n import _
17 17 from ..node import hex, short
18 18
19 19 from .common import (
20 20 ErrorResponse,
21 21 HTTP_FORBIDDEN,
22 22 HTTP_NOT_FOUND,
23 23 HTTP_OK,
24 24 get_contact,
25 25 paritygen,
26 26 staticfile,
27 27 )
28 28
29 29 from .. import (
30 30 archival,
31 31 encoding,
32 32 error,
33 33 graphmod,
34 34 patch,
35 35 revset,
36 36 scmutil,
37 37 templatefilters,
38 38 templater,
39 39 util,
40 40 )
41 41
42 42 from . import (
43 43 webutil,
44 44 )
45 45
46 46 __all__ = []
47 47 commands = {}
48 48
49 49 class webcommand(object):
50 50 """Decorator used to register a web command handler.
51 51
52 52 The decorator takes as its positional arguments the name/path the
53 53 command should be accessible under.
54 54
55 55 Usage:
56 56
57 57 @webcommand('mycommand')
58 58 def mycommand(web, req, tmpl):
59 59 pass
60 60 """
61 61
62 62 def __init__(self, name):
63 63 self.name = name
64 64
65 65 def __call__(self, func):
66 66 __all__.append(self.name)
67 67 commands[self.name] = func
68 68 return func
69 69
70 70 @webcommand('log')
71 71 def log(web, req, tmpl):
72 72 """
73 73 /log[/{revision}[/{path}]]
74 74 --------------------------
75 75
76 76 Show repository or file history.
77 77
78 78 For URLs of the form ``/log/{revision}``, a list of changesets starting at
79 79 the specified changeset identifier is shown. If ``{revision}`` is not
80 80 defined, the default is ``tip``. This form is equivalent to the
81 81 ``changelog`` handler.
82 82
83 83 For URLs of the form ``/log/{revision}/{file}``, the history for a specific
84 84 file will be shown. This form is equivalent to the ``filelog`` handler.
85 85 """
86 86
87 87 if 'file' in req.form and req.form['file'][0]:
88 88 return filelog(web, req, tmpl)
89 89 else:
90 90 return changelog(web, req, tmpl)
91 91
92 92 @webcommand('rawfile')
93 93 def rawfile(web, req, tmpl):
94 94 guessmime = web.configbool('web', 'guessmime', False)
95 95
96 96 path = webutil.cleanpath(web.repo, req.form.get('file', [''])[0])
97 97 if not path:
98 98 content = manifest(web, req, tmpl)
99 99 req.respond(HTTP_OK, web.ctype)
100 100 return content
101 101
102 102 try:
103 103 fctx = webutil.filectx(web.repo, req)
104 104 except error.LookupError as inst:
105 105 try:
106 106 content = manifest(web, req, tmpl)
107 107 req.respond(HTTP_OK, web.ctype)
108 108 return content
109 109 except ErrorResponse:
110 110 raise inst
111 111
112 112 path = fctx.path()
113 113 text = fctx.data()
114 114 mt = 'application/binary'
115 115 if guessmime:
116 116 mt = mimetypes.guess_type(path)[0]
117 117 if mt is None:
118 118 if util.binary(text):
119 119 mt = 'application/binary'
120 120 else:
121 121 mt = 'text/plain'
122 122 if mt.startswith('text/'):
123 123 mt += '; charset="%s"' % encoding.encoding
124 124
125 125 req.respond(HTTP_OK, mt, path, body=text)
126 126 return []
127 127
128 128 def _filerevision(web, req, tmpl, fctx):
129 129 f = fctx.path()
130 130 text = fctx.data()
131 131 parity = paritygen(web.stripecount)
132 132
133 133 if util.binary(text):
134 134 mt = mimetypes.guess_type(f)[0] or 'application/octet-stream'
135 135 text = '(binary:%s)' % mt
136 136
137 137 def lines():
138 138 for lineno, t in enumerate(text.splitlines(True)):
139 139 yield {"line": t,
140 140 "lineid": "l%d" % (lineno + 1),
141 141 "linenumber": "% 6d" % (lineno + 1),
142 142 "parity": next(parity)}
143 143
144 144 return tmpl("filerevision",
145 145 file=f,
146 146 path=webutil.up(f),
147 147 text=lines(),
148 148 symrev=webutil.symrevorshortnode(req, fctx),
149 149 rename=webutil.renamelink(fctx),
150 150 permissions=fctx.manifest().flags(f),
151 151 **webutil.commonentry(web.repo, fctx))
152 152
153 153 @webcommand('file')
154 154 def file(web, req, tmpl):
155 155 """
156 156 /file/{revision}[/{path}]
157 157 -------------------------
158 158
159 159 Show information about a directory or file in the repository.
160 160
161 161 Info about the ``path`` given as a URL parameter will be rendered.
162 162
163 163 If ``path`` is a directory, information about the entries in that
164 164 directory will be rendered. This form is equivalent to the ``manifest``
165 165 handler.
166 166
167 167 If ``path`` is a file, information about that file will be shown via
168 168 the ``filerevision`` template.
169 169
170 170 If ``path`` is not defined, information about the root directory will
171 171 be rendered.
172 172 """
173 173 path = webutil.cleanpath(web.repo, req.form.get('file', [''])[0])
174 174 if not path:
175 175 return manifest(web, req, tmpl)
176 176 try:
177 177 return _filerevision(web, req, tmpl, webutil.filectx(web.repo, req))
178 178 except error.LookupError as inst:
179 179 try:
180 180 return manifest(web, req, tmpl)
181 181 except ErrorResponse:
182 182 raise inst
183 183
184 184 def _search(web, req, tmpl):
185 185 MODE_REVISION = 'rev'
186 186 MODE_KEYWORD = 'keyword'
187 187 MODE_REVSET = 'revset'
188 188
189 189 def revsearch(ctx):
190 190 yield ctx
191 191
192 192 def keywordsearch(query):
193 193 lower = encoding.lower
194 194 qw = lower(query).split()
195 195
196 196 def revgen():
197 197 cl = web.repo.changelog
198 198 for i in xrange(len(web.repo) - 1, 0, -100):
199 199 l = []
200 200 for j in cl.revs(max(0, i - 99), i):
201 201 ctx = web.repo[j]
202 202 l.append(ctx)
203 203 l.reverse()
204 204 for e in l:
205 205 yield e
206 206
207 207 for ctx in revgen():
208 208 miss = 0
209 209 for q in qw:
210 210 if not (q in lower(ctx.user()) or
211 211 q in lower(ctx.description()) or
212 212 q in lower(" ".join(ctx.files()))):
213 213 miss = 1
214 214 break
215 215 if miss:
216 216 continue
217 217
218 218 yield ctx
219 219
220 220 def revsetsearch(revs):
221 221 for r in revs:
222 222 yield web.repo[r]
223 223
224 224 searchfuncs = {
225 225 MODE_REVISION: (revsearch, 'exact revision search'),
226 226 MODE_KEYWORD: (keywordsearch, 'literal keyword search'),
227 227 MODE_REVSET: (revsetsearch, 'revset expression search'),
228 228 }
229 229
230 230 def getsearchmode(query):
231 231 try:
232 232 ctx = web.repo[query]
233 233 except (error.RepoError, error.LookupError):
234 234 # query is not an exact revision pointer, need to
235 235 # decide if it's a revset expression or keywords
236 236 pass
237 237 else:
238 238 return MODE_REVISION, ctx
239 239
240 240 revdef = 'reverse(%s)' % query
241 241 try:
242 242 tree = revset.parse(revdef)
243 243 except error.ParseError:
244 244 # can't parse to a revset tree
245 245 return MODE_KEYWORD, query
246 246
247 247 if revset.depth(tree) <= 2:
248 248 # no revset syntax used
249 249 return MODE_KEYWORD, query
250 250
251 251 if any((token, (value or '')[:3]) == ('string', 're:')
252 252 for token, value, pos in revset.tokenize(revdef)):
253 253 return MODE_KEYWORD, query
254 254
255 255 funcsused = revset.funcsused(tree)
256 256 if not funcsused.issubset(revset.safesymbols):
257 257 return MODE_KEYWORD, query
258 258
259 259 mfunc = revset.match(web.repo.ui, revdef)
260 260 try:
261 261 revs = mfunc(web.repo)
262 262 return MODE_REVSET, revs
263 263 # ParseError: wrongly placed tokens, wrongs arguments, etc
264 264 # RepoLookupError: no such revision, e.g. in 'revision:'
265 265 # Abort: bookmark/tag not exists
266 266 # LookupError: ambiguous identifier, e.g. in '(bc)' on a large repo
267 267 except (error.ParseError, error.RepoLookupError, error.Abort,
268 268 LookupError):
269 269 return MODE_KEYWORD, query
270 270
271 271 def changelist(**map):
272 272 count = 0
273 273
274 274 for ctx in searchfunc[0](funcarg):
275 275 count += 1
276 276 n = ctx.node()
277 277 showtags = webutil.showtag(web.repo, tmpl, 'changelogtag', n)
278 278 files = webutil.listfilediffs(tmpl, ctx.files(), n, web.maxfiles)
279 279
280 280 yield tmpl('searchentry',
281 281 parity=next(parity),
282 282 changelogtag=showtags,
283 283 files=files,
284 284 **webutil.commonentry(web.repo, ctx))
285 285
286 286 if count >= revcount:
287 287 break
288 288
289 289 query = req.form['rev'][0]
290 290 revcount = web.maxchanges
291 291 if 'revcount' in req.form:
292 292 try:
293 293 revcount = int(req.form.get('revcount', [revcount])[0])
294 294 revcount = max(revcount, 1)
295 295 tmpl.defaults['sessionvars']['revcount'] = revcount
296 296 except ValueError:
297 297 pass
298 298
299 299 lessvars = copy.copy(tmpl.defaults['sessionvars'])
300 300 lessvars['revcount'] = max(revcount / 2, 1)
301 301 lessvars['rev'] = query
302 302 morevars = copy.copy(tmpl.defaults['sessionvars'])
303 303 morevars['revcount'] = revcount * 2
304 304 morevars['rev'] = query
305 305
306 306 mode, funcarg = getsearchmode(query)
307 307
308 308 if 'forcekw' in req.form:
309 309 showforcekw = ''
310 310 showunforcekw = searchfuncs[mode][1]
311 311 mode = MODE_KEYWORD
312 312 funcarg = query
313 313 else:
314 314 if mode != MODE_KEYWORD:
315 315 showforcekw = searchfuncs[MODE_KEYWORD][1]
316 316 else:
317 317 showforcekw = ''
318 318 showunforcekw = ''
319 319
320 320 searchfunc = searchfuncs[mode]
321 321
322 322 tip = web.repo['tip']
323 323 parity = paritygen(web.stripecount)
324 324
325 325 return tmpl('search', query=query, node=tip.hex(), symrev='tip',
326 326 entries=changelist, archives=web.archivelist("tip"),
327 327 morevars=morevars, lessvars=lessvars,
328 328 modedesc=searchfunc[1],
329 329 showforcekw=showforcekw, showunforcekw=showunforcekw)
330 330
331 331 @webcommand('changelog')
332 332 def changelog(web, req, tmpl, shortlog=False):
333 333 """
334 334 /changelog[/{revision}]
335 335 -----------------------
336 336
337 337 Show information about multiple changesets.
338 338
339 339 If the optional ``revision`` URL argument is absent, information about
340 340 all changesets starting at ``tip`` will be rendered. If the ``revision``
341 341 argument is present, changesets will be shown starting from the specified
342 342 revision.
343 343
344 344 If ``revision`` is absent, the ``rev`` query string argument may be
345 345 defined. This will perform a search for changesets.
346 346
347 347 The argument for ``rev`` can be a single revision, a revision set,
348 348 or a literal keyword to search for in changeset data (equivalent to
349 349 :hg:`log -k`).
350 350
351 351 The ``revcount`` query string argument defines the maximum numbers of
352 352 changesets to render.
353 353
354 354 For non-searches, the ``changelog`` template will be rendered.
355 355 """
356 356
357 357 query = ''
358 358 if 'node' in req.form:
359 359 ctx = webutil.changectx(web.repo, req)
360 360 symrev = webutil.symrevorshortnode(req, ctx)
361 361 elif 'rev' in req.form:
362 362 return _search(web, req, tmpl)
363 363 else:
364 364 ctx = web.repo['tip']
365 365 symrev = 'tip'
366 366
367 367 def changelist():
368 368 revs = []
369 369 if pos != -1:
370 370 revs = web.repo.changelog.revs(pos, 0)
371 371 curcount = 0
372 372 for rev in revs:
373 373 curcount += 1
374 374 if curcount > revcount + 1:
375 375 break
376 376
377 377 entry = webutil.changelistentry(web, web.repo[rev], tmpl)
378 378 entry['parity'] = next(parity)
379 379 yield entry
380 380
381 381 if shortlog:
382 382 revcount = web.maxshortchanges
383 383 else:
384 384 revcount = web.maxchanges
385 385
386 386 if 'revcount' in req.form:
387 387 try:
388 388 revcount = int(req.form.get('revcount', [revcount])[0])
389 389 revcount = max(revcount, 1)
390 390 tmpl.defaults['sessionvars']['revcount'] = revcount
391 391 except ValueError:
392 392 pass
393 393
394 394 lessvars = copy.copy(tmpl.defaults['sessionvars'])
395 395 lessvars['revcount'] = max(revcount / 2, 1)
396 396 morevars = copy.copy(tmpl.defaults['sessionvars'])
397 397 morevars['revcount'] = revcount * 2
398 398
399 399 count = len(web.repo)
400 400 pos = ctx.rev()
401 401 parity = paritygen(web.stripecount)
402 402
403 403 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
404 404
405 405 entries = list(changelist())
406 406 latestentry = entries[:1]
407 407 if len(entries) > revcount:
408 408 nextentry = entries[-1:]
409 409 entries = entries[:-1]
410 410 else:
411 411 nextentry = []
412 412
413 413 return tmpl(shortlog and 'shortlog' or 'changelog', changenav=changenav,
414 414 node=ctx.hex(), rev=pos, symrev=symrev, changesets=count,
415 415 entries=entries,
416 416 latestentry=latestentry, nextentry=nextentry,
417 417 archives=web.archivelist("tip"), revcount=revcount,
418 418 morevars=morevars, lessvars=lessvars, query=query)
419 419
420 420 @webcommand('shortlog')
421 421 def shortlog(web, req, tmpl):
422 422 """
423 423 /shortlog
424 424 ---------
425 425
426 426 Show basic information about a set of changesets.
427 427
428 428 This accepts the same parameters as the ``changelog`` handler. The only
429 429 difference is the ``shortlog`` template will be rendered instead of the
430 430 ``changelog`` template.
431 431 """
432 432 return changelog(web, req, tmpl, shortlog=True)
433 433
434 434 @webcommand('changeset')
435 435 def changeset(web, req, tmpl):
436 436 """
437 437 /changeset[/{revision}]
438 438 -----------------------
439 439
440 440 Show information about a single changeset.
441 441
442 442 A URL path argument is the changeset identifier to show. See ``hg help
443 443 revisions`` for possible values. If not defined, the ``tip`` changeset
444 444 will be shown.
445 445
446 446 The ``changeset`` template is rendered. Contents of the ``changesettag``,
447 447 ``changesetbookmark``, ``filenodelink``, ``filenolink``, and the many
448 448 templates related to diffs may all be used to produce the output.
449 449 """
450 450 ctx = webutil.changectx(web.repo, req)
451 451
452 452 return tmpl('changeset', **webutil.changesetentry(web, req, tmpl, ctx))
453 453
454 454 rev = webcommand('rev')(changeset)
455 455
456 456 def decodepath(path):
457 457 """Hook for mapping a path in the repository to a path in the
458 458 working copy.
459 459
460 460 Extensions (e.g., largefiles) can override this to remap files in
461 461 the virtual file system presented by the manifest command below."""
462 462 return path
463 463
464 464 @webcommand('manifest')
465 465 def manifest(web, req, tmpl):
466 466 """
467 467 /manifest[/{revision}[/{path}]]
468 468 -------------------------------
469 469
470 470 Show information about a directory.
471 471
472 472 If the URL path arguments are omitted, information about the root
473 473 directory for the ``tip`` changeset will be shown.
474 474
475 475 Because this handler can only show information for directories, it
476 476 is recommended to use the ``file`` handler instead, as it can handle both
477 477 directories and files.
478 478
479 479 The ``manifest`` template will be rendered for this handler.
480 480 """
481 481 if 'node' in req.form:
482 482 ctx = webutil.changectx(web.repo, req)
483 483 symrev = webutil.symrevorshortnode(req, ctx)
484 484 else:
485 485 ctx = web.repo['tip']
486 486 symrev = 'tip'
487 487 path = webutil.cleanpath(web.repo, req.form.get('file', [''])[0])
488 488 mf = ctx.manifest()
489 489 node = ctx.node()
490 490
491 491 files = {}
492 492 dirs = {}
493 493 parity = paritygen(web.stripecount)
494 494
495 495 if path and path[-1] != "/":
496 496 path += "/"
497 497 l = len(path)
498 498 abspath = "/" + path
499 499
500 500 for full, n in mf.iteritems():
501 501 # the virtual path (working copy path) used for the full
502 502 # (repository) path
503 503 f = decodepath(full)
504 504
505 505 if f[:l] != path:
506 506 continue
507 507 remain = f[l:]
508 508 elements = remain.split('/')
509 509 if len(elements) == 1:
510 510 files[remain] = full
511 511 else:
512 512 h = dirs # need to retain ref to dirs (root)
513 513 for elem in elements[0:-1]:
514 514 if elem not in h:
515 515 h[elem] = {}
516 516 h = h[elem]
517 517 if len(h) > 1:
518 518 break
519 519 h[None] = None # denotes files present
520 520
521 521 if mf and not files and not dirs:
522 522 raise ErrorResponse(HTTP_NOT_FOUND, 'path not found: ' + path)
523 523
524 524 def filelist(**map):
525 525 for f in sorted(files):
526 526 full = files[f]
527 527
528 528 fctx = ctx.filectx(full)
529 529 yield {"file": full,
530 530 "parity": next(parity),
531 531 "basename": f,
532 532 "date": fctx.date(),
533 533 "size": fctx.size(),
534 534 "permissions": mf.flags(full)}
535 535
536 536 def dirlist(**map):
537 537 for d in sorted(dirs):
538 538
539 539 emptydirs = []
540 540 h = dirs[d]
541 541 while isinstance(h, dict) and len(h) == 1:
542 542 k, v = h.items()[0]
543 543 if v:
544 544 emptydirs.append(k)
545 545 h = v
546 546
547 547 path = "%s%s" % (abspath, d)
548 548 yield {"parity": next(parity),
549 549 "path": path,
550 550 "emptydirs": "/".join(emptydirs),
551 551 "basename": d}
552 552
553 553 return tmpl("manifest",
554 554 symrev=symrev,
555 555 path=abspath,
556 556 up=webutil.up(abspath),
557 557 upparity=next(parity),
558 558 fentries=filelist,
559 559 dentries=dirlist,
560 560 archives=web.archivelist(hex(node)),
561 561 **webutil.commonentry(web.repo, ctx))
562 562
563 563 @webcommand('tags')
564 564 def tags(web, req, tmpl):
565 565 """
566 566 /tags
567 567 -----
568 568
569 569 Show information about tags.
570 570
571 571 No arguments are accepted.
572 572
573 573 The ``tags`` template is rendered.
574 574 """
575 575 i = list(reversed(web.repo.tagslist()))
576 576 parity = paritygen(web.stripecount)
577 577
578 578 def entries(notip, latestonly, **map):
579 579 t = i
580 580 if notip:
581 581 t = [(k, n) for k, n in i if k != "tip"]
582 582 if latestonly:
583 583 t = t[:1]
584 584 for k, n in t:
585 585 yield {"parity": next(parity),
586 586 "tag": k,
587 587 "date": web.repo[n].date(),
588 588 "node": hex(n)}
589 589
590 590 return tmpl("tags",
591 591 node=hex(web.repo.changelog.tip()),
592 592 entries=lambda **x: entries(False, False, **x),
593 593 entriesnotip=lambda **x: entries(True, False, **x),
594 594 latestentry=lambda **x: entries(True, True, **x))
595 595
596 596 @webcommand('bookmarks')
597 597 def bookmarks(web, req, tmpl):
598 598 """
599 599 /bookmarks
600 600 ----------
601 601
602 602 Show information about bookmarks.
603 603
604 604 No arguments are accepted.
605 605
606 606 The ``bookmarks`` template is rendered.
607 607 """
608 608 i = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
609 609 sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
610 610 i = sorted(i, key=sortkey, reverse=True)
611 611 parity = paritygen(web.stripecount)
612 612
613 613 def entries(latestonly, **map):
614 614 t = i
615 615 if latestonly:
616 616 t = i[:1]
617 617 for k, n in t:
618 618 yield {"parity": next(parity),
619 619 "bookmark": k,
620 620 "date": web.repo[n].date(),
621 621 "node": hex(n)}
622 622
623 623 if i:
624 624 latestrev = i[0][1]
625 625 else:
626 626 latestrev = -1
627 627
628 628 return tmpl("bookmarks",
629 629 node=hex(web.repo.changelog.tip()),
630 630 lastchange=[{"date": web.repo[latestrev].date()}],
631 631 entries=lambda **x: entries(latestonly=False, **x),
632 632 latestentry=lambda **x: entries(latestonly=True, **x))
633 633
634 634 @webcommand('branches')
635 635 def branches(web, req, tmpl):
636 636 """
637 637 /branches
638 638 ---------
639 639
640 640 Show information about branches.
641 641
642 642 All known branches are contained in the output, even closed branches.
643 643
644 644 No arguments are accepted.
645 645
646 646 The ``branches`` template is rendered.
647 647 """
648 648 entries = webutil.branchentries(web.repo, web.stripecount)
649 649 latestentry = webutil.branchentries(web.repo, web.stripecount, 1)
650 650 return tmpl('branches', node=hex(web.repo.changelog.tip()),
651 651 entries=entries, latestentry=latestentry)
652 652
653 653 @webcommand('summary')
654 654 def summary(web, req, tmpl):
655 655 """
656 656 /summary
657 657 --------
658 658
659 659 Show a summary of repository state.
660 660
661 661 Information about the latest changesets, bookmarks, tags, and branches
662 662 is captured by this handler.
663 663
664 664 The ``summary`` template is rendered.
665 665 """
666 666 i = reversed(web.repo.tagslist())
667 667
668 668 def tagentries(**map):
669 669 parity = paritygen(web.stripecount)
670 670 count = 0
671 671 for k, n in i:
672 672 if k == "tip": # skip tip
673 673 continue
674 674
675 675 count += 1
676 676 if count > 10: # limit to 10 tags
677 677 break
678 678
679 679 yield tmpl("tagentry",
680 680 parity=next(parity),
681 681 tag=k,
682 682 node=hex(n),
683 683 date=web.repo[n].date())
684 684
685 685 def bookmarks(**map):
686 686 parity = paritygen(web.stripecount)
687 687 marks = [b for b in web.repo._bookmarks.items() if b[1] in web.repo]
688 688 sortkey = lambda b: (web.repo[b[1]].rev(), b[0])
689 689 marks = sorted(marks, key=sortkey, reverse=True)
690 690 for k, n in marks[:10]: # limit to 10 bookmarks
691 691 yield {'parity': next(parity),
692 692 'bookmark': k,
693 693 'date': web.repo[n].date(),
694 694 'node': hex(n)}
695 695
696 696 def changelist(**map):
697 697 parity = paritygen(web.stripecount, offset=start - end)
698 698 l = [] # build a list in forward order for efficiency
699 699 revs = []
700 700 if start < end:
701 701 revs = web.repo.changelog.revs(start, end - 1)
702 702 for i in revs:
703 703 ctx = web.repo[i]
704 704
705 705 l.append(tmpl(
706 706 'shortlogentry',
707 707 parity=next(parity),
708 708 **webutil.commonentry(web.repo, ctx)))
709 709
710 710 for entry in reversed(l):
711 711 yield entry
712 712
713 713 tip = web.repo['tip']
714 714 count = len(web.repo)
715 715 start = max(0, count - web.maxchanges)
716 716 end = min(count, start + web.maxchanges)
717 717
718 718 return tmpl("summary",
719 719 desc=web.config("web", "description", "unknown"),
720 720 owner=get_contact(web.config) or "unknown",
721 721 lastchange=tip.date(),
722 722 tags=tagentries,
723 723 bookmarks=bookmarks,
724 724 branches=webutil.branchentries(web.repo, web.stripecount, 10),
725 725 shortlog=changelist,
726 726 node=tip.hex(),
727 727 symrev='tip',
728 728 archives=web.archivelist("tip"),
729 729 labels=web.configlist('web', 'labels'))
730 730
731 731 @webcommand('filediff')
732 732 def filediff(web, req, tmpl):
733 733 """
734 734 /diff/{revision}/{path}
735 735 -----------------------
736 736
737 737 Show how a file changed in a particular commit.
738 738
739 739 The ``filediff`` template is rendered.
740 740
741 741 This handler is registered under both the ``/diff`` and ``/filediff``
742 742 paths. ``/diff`` is used in modern code.
743 743 """
744 744 fctx, ctx = None, None
745 745 try:
746 746 fctx = webutil.filectx(web.repo, req)
747 747 except LookupError:
748 748 ctx = webutil.changectx(web.repo, req)
749 749 path = webutil.cleanpath(web.repo, req.form['file'][0])
750 750 if path not in ctx.files():
751 751 raise
752 752
753 753 if fctx is not None:
754 754 path = fctx.path()
755 755 ctx = fctx.changectx()
756 756
757 757 parity = paritygen(web.stripecount)
758 758 style = web.config('web', 'style', 'paper')
759 759 if 'style' in req.form:
760 760 style = req.form['style'][0]
761 761
762 762 diffs = webutil.diffs(web.repo, tmpl, ctx, None, [path], parity, style)
763 763 if fctx is not None:
764 764 rename = webutil.renamelink(fctx)
765 765 ctx = fctx
766 766 else:
767 767 rename = []
768 768 ctx = ctx
769 769 return tmpl("filediff",
770 770 file=path,
771 771 symrev=webutil.symrevorshortnode(req, ctx),
772 772 rename=rename,
773 773 diff=diffs,
774 774 **webutil.commonentry(web.repo, ctx))
775 775
776 776 diff = webcommand('diff')(filediff)
777 777
778 778 @webcommand('comparison')
779 779 def comparison(web, req, tmpl):
780 780 """
781 781 /comparison/{revision}/{path}
782 782 -----------------------------
783 783
784 784 Show a comparison between the old and new versions of a file from changes
785 785 made on a particular revision.
786 786
787 787 This is similar to the ``diff`` handler. However, this form features
788 788 a split or side-by-side diff rather than a unified diff.
789 789
790 790 The ``context`` query string argument can be used to control the lines of
791 791 context in the diff.
792 792
793 793 The ``filecomparison`` template is rendered.
794 794 """
795 795 ctx = webutil.changectx(web.repo, req)
796 796 if 'file' not in req.form:
797 797 raise ErrorResponse(HTTP_NOT_FOUND, 'file not given')
798 798 path = webutil.cleanpath(web.repo, req.form['file'][0])
799 799
800 800 parsecontext = lambda v: v == 'full' and -1 or int(v)
801 801 if 'context' in req.form:
802 802 context = parsecontext(req.form['context'][0])
803 803 else:
804 804 context = parsecontext(web.config('web', 'comparisoncontext', '5'))
805 805
806 806 def filelines(f):
807 807 if util.binary(f.data()):
808 808 mt = mimetypes.guess_type(f.path())[0]
809 809 if not mt:
810 810 mt = 'application/octet-stream'
811 811 return [_('(binary file %s, hash: %s)') % (mt, hex(f.filenode()))]
812 812 return f.data().splitlines()
813 813
814 814 fctx = None
815 815 parent = ctx.p1()
816 816 leftrev = parent.rev()
817 817 leftnode = parent.node()
818 818 rightrev = ctx.rev()
819 819 rightnode = ctx.node()
820 820 if path in ctx:
821 821 fctx = ctx[path]
822 822 rightlines = filelines(fctx)
823 823 if path not in parent:
824 824 leftlines = ()
825 825 else:
826 826 pfctx = parent[path]
827 827 leftlines = filelines(pfctx)
828 828 else:
829 829 rightlines = ()
830 830 pfctx = ctx.parents()[0][path]
831 831 leftlines = filelines(pfctx)
832 832
833 833 comparison = webutil.compare(tmpl, context, leftlines, rightlines)
834 834 if fctx is not None:
835 835 rename = webutil.renamelink(fctx)
836 836 ctx = fctx
837 837 else:
838 838 rename = []
839 839 ctx = ctx
840 840 return tmpl('filecomparison',
841 841 file=path,
842 842 symrev=webutil.symrevorshortnode(req, ctx),
843 843 rename=rename,
844 844 leftrev=leftrev,
845 845 leftnode=hex(leftnode),
846 846 rightrev=rightrev,
847 847 rightnode=hex(rightnode),
848 848 comparison=comparison,
849 849 **webutil.commonentry(web.repo, ctx))
850 850
851 851 @webcommand('annotate')
852 852 def annotate(web, req, tmpl):
853 853 """
854 854 /annotate/{revision}/{path}
855 855 ---------------------------
856 856
857 857 Show changeset information for each line in a file.
858 858
859 859 The ``fileannotate`` template is rendered.
860 860 """
861 861 fctx = webutil.filectx(web.repo, req)
862 862 f = fctx.path()
863 863 parity = paritygen(web.stripecount)
864 864 diffopts = patch.difffeatureopts(web.repo.ui, untrusted=True,
865 865 section='annotate', whitespace=True)
866 866
867 867 def parents(f):
868 868 for p in f.parents():
869 869 yield {
870 870 "node": p.hex(),
871 871 "rev": p.rev(),
872 872 }
873 873
874 874 def annotate(**map):
875 875 if util.binary(fctx.data()):
876 876 mt = (mimetypes.guess_type(fctx.path())[0]
877 877 or 'application/octet-stream')
878 lines = enumerate([((fctx.filectx(fctx.filerev()), 1),
879 '(binary:%s)' % mt)])
878 lines = [((fctx.filectx(fctx.filerev()), 1), '(binary:%s)' % mt)]
880 879 else:
881 lines = enumerate(fctx.annotate(follow=True, linenumber=True,
882 diffopts=diffopts))
880 lines = fctx.annotate(follow=True, linenumber=True,
881 diffopts=diffopts)
883 882 previousrev = None
884 for lineno, ((f, targetline), l) in lines:
883 for lineno, ((f, targetline), l) in enumerate(lines):
885 884 rev = f.rev()
886 885 blockhead = rev != previousrev or None
887 886 previousrev = rev
888 887 yield {"parity": next(parity),
889 888 "node": f.hex(),
890 889 "rev": rev,
891 890 "author": f.user(),
892 891 "parents": parents(f),
893 892 "desc": f.description(),
894 893 "extra": f.extra(),
895 894 "file": f.path(),
896 895 "blockhead": blockhead,
897 896 "targetline": targetline,
898 897 "line": l,
899 898 "lineno": lineno + 1,
900 899 "lineid": "l%d" % (lineno + 1),
901 900 "linenumber": "% 6d" % (lineno + 1),
902 901 "revdate": f.date()}
903 902
904 903 return tmpl("fileannotate",
905 904 file=f,
906 905 annotate=annotate,
907 906 path=webutil.up(f),
908 907 symrev=webutil.symrevorshortnode(req, fctx),
909 908 rename=webutil.renamelink(fctx),
910 909 permissions=fctx.manifest().flags(f),
911 910 **webutil.commonentry(web.repo, fctx))
912 911
913 912 @webcommand('filelog')
914 913 def filelog(web, req, tmpl):
915 914 """
916 915 /filelog/{revision}/{path}
917 916 --------------------------
918 917
919 918 Show information about the history of a file in the repository.
920 919
921 920 The ``revcount`` query string argument can be defined to control the
922 921 maximum number of entries to show.
923 922
924 923 The ``filelog`` template will be rendered.
925 924 """
926 925
927 926 try:
928 927 fctx = webutil.filectx(web.repo, req)
929 928 f = fctx.path()
930 929 fl = fctx.filelog()
931 930 except error.LookupError:
932 931 f = webutil.cleanpath(web.repo, req.form['file'][0])
933 932 fl = web.repo.file(f)
934 933 numrevs = len(fl)
935 934 if not numrevs: # file doesn't exist at all
936 935 raise
937 936 rev = webutil.changectx(web.repo, req).rev()
938 937 first = fl.linkrev(0)
939 938 if rev < first: # current rev is from before file existed
940 939 raise
941 940 frev = numrevs - 1
942 941 while fl.linkrev(frev) > rev:
943 942 frev -= 1
944 943 fctx = web.repo.filectx(f, fl.linkrev(frev))
945 944
946 945 revcount = web.maxshortchanges
947 946 if 'revcount' in req.form:
948 947 try:
949 948 revcount = int(req.form.get('revcount', [revcount])[0])
950 949 revcount = max(revcount, 1)
951 950 tmpl.defaults['sessionvars']['revcount'] = revcount
952 951 except ValueError:
953 952 pass
954 953
955 954 lessvars = copy.copy(tmpl.defaults['sessionvars'])
956 955 lessvars['revcount'] = max(revcount / 2, 1)
957 956 morevars = copy.copy(tmpl.defaults['sessionvars'])
958 957 morevars['revcount'] = revcount * 2
959 958
960 959 count = fctx.filerev() + 1
961 960 start = max(0, fctx.filerev() - revcount + 1) # first rev on this page
962 961 end = min(count, start + revcount) # last rev on this page
963 962 parity = paritygen(web.stripecount, offset=start - end)
964 963
965 964 def entries():
966 965 l = []
967 966
968 967 repo = web.repo
969 968 revs = fctx.filelog().revs(start, end - 1)
970 969 for i in revs:
971 970 iterfctx = fctx.filectx(i)
972 971
973 972 l.append(dict(
974 973 parity=next(parity),
975 974 filerev=i,
976 975 file=f,
977 976 rename=webutil.renamelink(iterfctx),
978 977 **webutil.commonentry(repo, iterfctx)))
979 978 for e in reversed(l):
980 979 yield e
981 980
982 981 entries = list(entries())
983 982 latestentry = entries[:1]
984 983
985 984 revnav = webutil.filerevnav(web.repo, fctx.path())
986 985 nav = revnav.gen(end - 1, revcount, count)
987 986 return tmpl("filelog",
988 987 file=f,
989 988 nav=nav,
990 989 symrev=webutil.symrevorshortnode(req, fctx),
991 990 entries=entries,
992 991 latestentry=latestentry,
993 992 revcount=revcount,
994 993 morevars=morevars,
995 994 lessvars=lessvars,
996 995 **webutil.commonentry(web.repo, fctx))
997 996
998 997 @webcommand('archive')
999 998 def archive(web, req, tmpl):
1000 999 """
1001 1000 /archive/{revision}.{format}[/{path}]
1002 1001 -------------------------------------
1003 1002
1004 1003 Obtain an archive of repository content.
1005 1004
1006 1005 The content and type of the archive is defined by a URL path parameter.
1007 1006 ``format`` is the file extension of the archive type to be generated. e.g.
1008 1007 ``zip`` or ``tar.bz2``. Not all archive types may be allowed by your
1009 1008 server configuration.
1010 1009
1011 1010 The optional ``path`` URL parameter controls content to include in the
1012 1011 archive. If omitted, every file in the specified revision is present in the
1013 1012 archive. If included, only the specified file or contents of the specified
1014 1013 directory will be included in the archive.
1015 1014
1016 1015 No template is used for this handler. Raw, binary content is generated.
1017 1016 """
1018 1017
1019 1018 type_ = req.form.get('type', [None])[0]
1020 1019 allowed = web.configlist("web", "allow_archive")
1021 1020 key = req.form['node'][0]
1022 1021
1023 1022 if type_ not in web.archives:
1024 1023 msg = 'Unsupported archive type: %s' % type_
1025 1024 raise ErrorResponse(HTTP_NOT_FOUND, msg)
1026 1025
1027 1026 if not ((type_ in allowed or
1028 1027 web.configbool("web", "allow" + type_, False))):
1029 1028 msg = 'Archive type not allowed: %s' % type_
1030 1029 raise ErrorResponse(HTTP_FORBIDDEN, msg)
1031 1030
1032 1031 reponame = re.sub(r"\W+", "-", os.path.basename(web.reponame))
1033 1032 cnode = web.repo.lookup(key)
1034 1033 arch_version = key
1035 1034 if cnode == key or key == 'tip':
1036 1035 arch_version = short(cnode)
1037 1036 name = "%s-%s" % (reponame, arch_version)
1038 1037
1039 1038 ctx = webutil.changectx(web.repo, req)
1040 1039 pats = []
1041 1040 matchfn = scmutil.match(ctx, [])
1042 1041 file = req.form.get('file', None)
1043 1042 if file:
1044 1043 pats = ['path:' + file[0]]
1045 1044 matchfn = scmutil.match(ctx, pats, default='path')
1046 1045 if pats:
1047 1046 files = [f for f in ctx.manifest().keys() if matchfn(f)]
1048 1047 if not files:
1049 1048 raise ErrorResponse(HTTP_NOT_FOUND,
1050 1049 'file(s) not found: %s' % file[0])
1051 1050
1052 1051 mimetype, artype, extension, encoding = web.archivespecs[type_]
1053 1052 headers = [
1054 1053 ('Content-Disposition', 'attachment; filename=%s%s' % (name, extension))
1055 1054 ]
1056 1055 if encoding:
1057 1056 headers.append(('Content-Encoding', encoding))
1058 1057 req.headers.extend(headers)
1059 1058 req.respond(HTTP_OK, mimetype)
1060 1059
1061 1060 archival.archive(web.repo, req, cnode, artype, prefix=name,
1062 1061 matchfn=matchfn,
1063 1062 subrepos=web.configbool("web", "archivesubrepos"))
1064 1063 return []
1065 1064
1066 1065
1067 1066 @webcommand('static')
1068 1067 def static(web, req, tmpl):
1069 1068 fname = req.form['file'][0]
1070 1069 # a repo owner may set web.static in .hg/hgrc to get any file
1071 1070 # readable by the user running the CGI script
1072 1071 static = web.config("web", "static", None, untrusted=False)
1073 1072 if not static:
1074 1073 tp = web.templatepath or templater.templatepaths()
1075 1074 if isinstance(tp, str):
1076 1075 tp = [tp]
1077 1076 static = [os.path.join(p, 'static') for p in tp]
1078 1077 staticfile(static, fname, req)
1079 1078 return []
1080 1079
1081 1080 @webcommand('graph')
1082 1081 def graph(web, req, tmpl):
1083 1082 """
1084 1083 /graph[/{revision}]
1085 1084 -------------------
1086 1085
1087 1086 Show information about the graphical topology of the repository.
1088 1087
1089 1088 Information rendered by this handler can be used to create visual
1090 1089 representations of repository topology.
1091 1090
1092 1091 The ``revision`` URL parameter controls the starting changeset.
1093 1092
1094 1093 The ``revcount`` query string argument can define the number of changesets
1095 1094 to show information for.
1096 1095
1097 1096 This handler will render the ``graph`` template.
1098 1097 """
1099 1098
1100 1099 if 'node' in req.form:
1101 1100 ctx = webutil.changectx(web.repo, req)
1102 1101 symrev = webutil.symrevorshortnode(req, ctx)
1103 1102 else:
1104 1103 ctx = web.repo['tip']
1105 1104 symrev = 'tip'
1106 1105 rev = ctx.rev()
1107 1106
1108 1107 bg_height = 39
1109 1108 revcount = web.maxshortchanges
1110 1109 if 'revcount' in req.form:
1111 1110 try:
1112 1111 revcount = int(req.form.get('revcount', [revcount])[0])
1113 1112 revcount = max(revcount, 1)
1114 1113 tmpl.defaults['sessionvars']['revcount'] = revcount
1115 1114 except ValueError:
1116 1115 pass
1117 1116
1118 1117 lessvars = copy.copy(tmpl.defaults['sessionvars'])
1119 1118 lessvars['revcount'] = max(revcount / 2, 1)
1120 1119 morevars = copy.copy(tmpl.defaults['sessionvars'])
1121 1120 morevars['revcount'] = revcount * 2
1122 1121
1123 1122 count = len(web.repo)
1124 1123 pos = rev
1125 1124
1126 1125 uprev = min(max(0, count - 1), rev + revcount)
1127 1126 downrev = max(0, rev - revcount)
1128 1127 changenav = webutil.revnav(web.repo).gen(pos, revcount, count)
1129 1128
1130 1129 tree = []
1131 1130 if pos != -1:
1132 1131 allrevs = web.repo.changelog.revs(pos, 0)
1133 1132 revs = []
1134 1133 for i in allrevs:
1135 1134 revs.append(i)
1136 1135 if len(revs) >= revcount:
1137 1136 break
1138 1137
1139 1138 # We have to feed a baseset to dagwalker as it is expecting smartset
1140 1139 # object. This does not have a big impact on hgweb performance itself
1141 1140 # since hgweb graphing code is not itself lazy yet.
1142 1141 dag = graphmod.dagwalker(web.repo, revset.baseset(revs))
1143 1142 # As we said one line above... not lazy.
1144 1143 tree = list(graphmod.colored(dag, web.repo))
1145 1144
1146 1145 def getcolumns(tree):
1147 1146 cols = 0
1148 1147 for (id, type, ctx, vtx, edges) in tree:
1149 1148 if type != graphmod.CHANGESET:
1150 1149 continue
1151 1150 cols = max(cols, max([edge[0] for edge in edges] or [0]),
1152 1151 max([edge[1] for edge in edges] or [0]))
1153 1152 return cols
1154 1153
1155 1154 def graphdata(usetuples, encodestr):
1156 1155 data = []
1157 1156
1158 1157 row = 0
1159 1158 for (id, type, ctx, vtx, edges) in tree:
1160 1159 if type != graphmod.CHANGESET:
1161 1160 continue
1162 1161 node = str(ctx)
1163 1162 age = encodestr(templatefilters.age(ctx.date()))
1164 1163 desc = templatefilters.firstline(encodestr(ctx.description()))
1165 1164 desc = cgi.escape(templatefilters.nonempty(desc))
1166 1165 user = cgi.escape(templatefilters.person(encodestr(ctx.user())))
1167 1166 branch = cgi.escape(encodestr(ctx.branch()))
1168 1167 try:
1169 1168 branchnode = web.repo.branchtip(branch)
1170 1169 except error.RepoLookupError:
1171 1170 branchnode = None
1172 1171 branch = branch, branchnode == ctx.node()
1173 1172
1174 1173 if usetuples:
1175 1174 data.append((node, vtx, edges, desc, user, age, branch,
1176 1175 [cgi.escape(encodestr(x)) for x in ctx.tags()],
1177 1176 [cgi.escape(encodestr(x))
1178 1177 for x in ctx.bookmarks()]))
1179 1178 else:
1180 1179 edgedata = [{'col': edge[0], 'nextcol': edge[1],
1181 1180 'color': (edge[2] - 1) % 6 + 1,
1182 1181 'width': edge[3], 'bcolor': edge[4]}
1183 1182 for edge in edges]
1184 1183
1185 1184 data.append(
1186 1185 {'node': node,
1187 1186 'col': vtx[0],
1188 1187 'color': (vtx[1] - 1) % 6 + 1,
1189 1188 'edges': edgedata,
1190 1189 'row': row,
1191 1190 'nextrow': row + 1,
1192 1191 'desc': desc,
1193 1192 'user': user,
1194 1193 'age': age,
1195 1194 'bookmarks': webutil.nodebookmarksdict(
1196 1195 web.repo, ctx.node()),
1197 1196 'branches': webutil.nodebranchdict(web.repo, ctx),
1198 1197 'inbranch': webutil.nodeinbranch(web.repo, ctx),
1199 1198 'tags': webutil.nodetagsdict(web.repo, ctx.node())})
1200 1199
1201 1200 row += 1
1202 1201
1203 1202 return data
1204 1203
1205 1204 cols = getcolumns(tree)
1206 1205 rows = len(tree)
1207 1206 canvasheight = (rows + 1) * bg_height - 27
1208 1207
1209 1208 return tmpl('graph', rev=rev, symrev=symrev, revcount=revcount,
1210 1209 uprev=uprev,
1211 1210 lessvars=lessvars, morevars=morevars, downrev=downrev,
1212 1211 cols=cols, rows=rows,
1213 1212 canvaswidth=(cols + 1) * bg_height,
1214 1213 truecanvasheight=rows * bg_height,
1215 1214 canvasheight=canvasheight, bg_height=bg_height,
1216 1215 # {jsdata} will be passed to |json, so it must be in utf-8
1217 1216 jsdata=lambda **x: graphdata(True, encoding.fromlocal),
1218 1217 nodes=lambda **x: graphdata(False, str),
1219 1218 node=ctx.hex(), changenav=changenav)
1220 1219
1221 1220 def _getdoc(e):
1222 1221 doc = e[0].__doc__
1223 1222 if doc:
1224 1223 doc = _(doc).partition('\n')[0]
1225 1224 else:
1226 1225 doc = _('(no help text available)')
1227 1226 return doc
1228 1227
1229 1228 @webcommand('help')
1230 1229 def help(web, req, tmpl):
1231 1230 """
1232 1231 /help[/{topic}]
1233 1232 ---------------
1234 1233
1235 1234 Render help documentation.
1236 1235
1237 1236 This web command is roughly equivalent to :hg:`help`. If a ``topic``
1238 1237 is defined, that help topic will be rendered. If not, an index of
1239 1238 available help topics will be rendered.
1240 1239
1241 1240 The ``help`` template will be rendered when requesting help for a topic.
1242 1241 ``helptopics`` will be rendered for the index of help topics.
1243 1242 """
1244 1243 from .. import commands, help as helpmod # avoid cycle
1245 1244
1246 1245 topicname = req.form.get('node', [None])[0]
1247 1246 if not topicname:
1248 1247 def topics(**map):
1249 1248 for entries, summary, _doc in helpmod.helptable:
1250 1249 yield {'topic': entries[0], 'summary': summary}
1251 1250
1252 1251 early, other = [], []
1253 1252 primary = lambda s: s.partition('|')[0]
1254 1253 for c, e in commands.table.iteritems():
1255 1254 doc = _getdoc(e)
1256 1255 if 'DEPRECATED' in doc or c.startswith('debug'):
1257 1256 continue
1258 1257 cmd = primary(c)
1259 1258 if cmd.startswith('^'):
1260 1259 early.append((cmd[1:], doc))
1261 1260 else:
1262 1261 other.append((cmd, doc))
1263 1262
1264 1263 early.sort()
1265 1264 other.sort()
1266 1265
1267 1266 def earlycommands(**map):
1268 1267 for c, doc in early:
1269 1268 yield {'topic': c, 'summary': doc}
1270 1269
1271 1270 def othercommands(**map):
1272 1271 for c, doc in other:
1273 1272 yield {'topic': c, 'summary': doc}
1274 1273
1275 1274 return tmpl('helptopics', topics=topics, earlycommands=earlycommands,
1276 1275 othercommands=othercommands, title='Index')
1277 1276
1278 1277 # Render an index of sub-topics.
1279 1278 if topicname in helpmod.subtopics:
1280 1279 topics = []
1281 1280 for entries, summary, _doc in helpmod.subtopics[topicname]:
1282 1281 topics.append({
1283 1282 'topic': '%s.%s' % (topicname, entries[0]),
1284 1283 'basename': entries[0],
1285 1284 'summary': summary,
1286 1285 })
1287 1286
1288 1287 return tmpl('helptopics', topics=topics, title=topicname,
1289 1288 subindex=True)
1290 1289
1291 1290 u = webutil.wsgiui()
1292 1291 u.verbose = True
1293 1292
1294 1293 # Render a page from a sub-topic.
1295 1294 if '.' in topicname:
1296 1295 # TODO implement support for rendering sections, like
1297 1296 # `hg help` works.
1298 1297 topic, subtopic = topicname.split('.', 1)
1299 1298 if topic not in helpmod.subtopics:
1300 1299 raise ErrorResponse(HTTP_NOT_FOUND)
1301 1300 else:
1302 1301 topic = topicname
1303 1302 subtopic = None
1304 1303
1305 1304 try:
1306 1305 doc = helpmod.help_(u, topic, subtopic=subtopic)
1307 1306 except error.UnknownCommand:
1308 1307 raise ErrorResponse(HTTP_NOT_FOUND)
1309 1308 return tmpl('help', topic=topicname, doc=doc)
1310 1309
1311 1310 # tell hggettext to extract docstrings from these functions:
1312 1311 i18nfunctions = commands.values()
General Comments 0
You need to be logged in to leave comments. Login now