1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2006-2021 Edgewall Software 4# Copyright (C) 2006 Christopher Lenz <cmlenz@gmx.de> 5# All rights reserved. 6# 7# This software is licensed as described in the file COPYING, which 8# you should have received as part of this distribution. The terms 9# are also available at https://trac.edgewall.org/wiki/TracLicense. 10# 11# This software consists of voluntary contributions made by many 12# individuals. For the exact contribution history, see the revision 13# history and logs, available at https://trac.edgewall.org/log/. 14 15"""Various utility functions and classes that support common presentation 16tasks such as grouping or pagination. 17""" 18 19from json import JSONEncoder 20from datetime import datetime 21from math import ceil 22import re 23 24from jinja2 import Undefined, contextfilter, evalcontextfilter 25from jinja2.filters import make_attrgetter 26 27from trac.core import TracError 28from .datefmt import to_utimestamp, utc 29from .html import (Fragment, Markup, classes, html_attribute, soft_unicode, 30 styles, tag) 31from .text import javascript_quote 32 33__all__ = ['captioned_button', 'classes', 'first_last', 'group', 'istext', 34 'prepared_paginate', 'paginate', 'Paginator'] 35__no_apidoc__ = 'prepared_paginate' 36 37 38def jinja2_update(jenv): 39 """Augment a Jinja2 environment with filters, tests and global functions 40 defined in this module. 41 42 """ 43 jenv.filters.update( 44 flatten=flatten_filter, 45 groupattr=groupattr_filter, 46 htmlattr=htmlattr_filter, 47 max=max_filter, 48 mix=min_filter, 49 trim=trim_filter, 50 ) 51 jenv.tests.update( 52 greaterthan=is_greaterthan, 53 greaterthanorequal=is_greaterthanorequal, 54 lessthan=is_lessthan, 55 lessthanorequal=is_lessthanorequal, 56 not_equalto=is_not_equalto, 57 not_in=is_not_in, 58 text=istext, 59 ) 60 jenv.globals.update( 61 classes=classes, 62 first_last=first_last, 63 group=group, 64 istext=istext, 65 paginate=paginate, 66 separated=separated, 67 styles=styles, 68 tag=tag, 69 to_json=to_json, 70 ) 71 72 73# -- Jinja2 custom filters 74 75@evalcontextfilter 76def htmlattr_filter(_eval_ctx, d, autospace=True): 77 """Create an SGML/XML attribute string based on the items in a dict. 78 79 If the dict itself is `none` or `undefined`, it returns the empty 80 string. ``d`` can also be an iterable or a mapping, in which case 81 it will be converted to a ``dict``. 82 83 All values that are neither `none` nor `undefined` are 84 automatically escaped. 85 86 For HTML attributes like `'checked'` and `'selected'`, a truth 87 value will be converted to the key value itself. For others it 88 will be `'true'` or `'on'`. For `'class'`, the `classes` 89 processing will be applied. 90 91 Example: 92 93 .. sourcecode:: html+jinja 94 95 <ul${{'class': {'my': 1, 'list': True, 'empty': False}, 96 'missing': none, 'checked': 1, 'selected': False, 97 'autocomplete': True, 'id': 'list-%d'|format(variable), 98 'style': {'border-radius': '3px' if rounded, 99 'background': '#f7f7f7'} 100 }|htmlattr}> 101 ... 102 </ul> 103 104 Results in something like this: 105 106 .. sourcecode:: html 107 108 <ul class="my list" id="list-42" checked="checked" autocomplete="on" 109 style="border-radius: 3px; background: #f7f7f7"> 110 ... 111 </ul> 112 113 As you can see it automatically prepends a space in front of the item 114 if the filter returned something unless the second parameter is false. 115 116 Adapted from Jinja2's builtin ``do_xmlattr`` filter. 117 118 """ 119 if not d: 120 return '' 121 d = d if isinstance(d, dict) else dict(d) 122 # Note: at some point, switch to 123 # https://www.w3.org/TR/html-markup/syntax.html#syntax-attr-empty 124 attrs = [] 125 for key in sorted(d): 126 val = d[key] 127 val = html_attribute(key, None if isinstance(val, Undefined) else val) 128 if val is not None: 129 attrs.append('%s="%s"' % (key, val)) 130 rv = ' '.join(attrs) 131 if autospace and rv: 132 rv = ' ' + rv 133 if _eval_ctx.autoescape: 134 rv = Markup(rv) 135 return rv 136 137 138def max_filter(seq, default=None): 139 """Returns the max value from the sequence.""" 140 if len(seq): 141 return max(seq) 142 return default 143 144def min_filter(seq, default=None): 145 """Returns the min value from the sequence.""" 146 if len(seq): 147 return min(seq) 148 return default 149 150 151def trim_filter(value, what=None): 152 """Strip leading and trailing whitespace or other specified character. 153 154 Adapted from Jinja2's builtin ``trim`` filter. 155 """ 156 return soft_unicode(value).strip(what) 157 158def flatten_filter(value): 159 """Combine incoming sequences in one.""" 160 seq = [] 161 for s in value: 162 seq.extend(s) 163 return seq 164 165 166# -- Jinja2 custom tests 167 168def is_not_equalto(a, b): 169 return a != b 170 171def is_greaterthan(a, b): 172 return a > b 173 174def is_greaterthanorequal(a, b): 175 return a >= b 176 177def is_lessthan(a, b): 178 return a < b 179 180def is_lessthanorequal(a, b): 181 return a <= b 182 183def is_in(a, b): 184 return a in b 185 186def is_not_in(a, b): 187 return a not in b 188 189# Note: see which of the following should become Jinja2 filters 190 191def captioned_button(req, symbol, text): 192 """Return symbol and text or only symbol, according to user preferences.""" 193 return symbol if req.session.get('ui.use_symbols') \ 194 else '%s %s' % (symbol, text) 195 196 197def first_last(idx, seq): 198 """Generate ``first`` or ``last`` or both, according to the 199 position `idx` in sequence `seq`. 200 201 In Jinja2 templates, rather use: 202 203 .. sourcecode:: html+jinja 204 205 <li ${{'class': {'first': loop.first, 'last': loop.last}}|htmlattr}> 206 207 This is less error prone, as the sequence remains implicit and 208 therefore can't be wrong. 209 210 """ 211 return classes(first=idx == 0, last=idx == len(seq) - 1) 212 213 214def group(iterable, num, predicate=None): 215 """Combines the elements produced by the given iterable so that every `n` 216 items are returned as a tuple. 217 218 >>> items = [1, 2, 3, 4] 219 >>> for item in group(items, 2): 220 ... print(item) 221 (1, 2) 222 (3, 4) 223 224 The last tuple is padded with `None` values if its' length is smaller than 225 `num`. 226 227 >>> items = [1, 2, 3, 4, 5] 228 >>> for item in group(items, 2): 229 ... print(item) 230 (1, 2) 231 (3, 4) 232 (5, None) 233 234 The optional `predicate` parameter can be used to flag elements that should 235 not be packed together with other items. Only those elements where the 236 predicate function returns True are grouped with other elements, otherwise 237 they are returned as a tuple of length 1: 238 239 >>> items = [1, 2, 3, 4] 240 >>> for item in group(items, 2, lambda x: x != 3): 241 ... print(item) 242 (1, 2) 243 (3,) 244 (4, None) 245 """ 246 buf = [] 247 for item in iterable: 248 flush = predicate and not predicate(item) 249 if buf and flush: 250 buf += [None] * (num - len(buf)) 251 yield tuple(buf) 252 del buf[:] 253 buf.append(item) 254 if flush or len(buf) == num: 255 yield tuple(buf) 256 del buf[:] 257 if buf: 258 buf += [None] * (num - len(buf)) 259 yield tuple(buf) 260 261 262@contextfilter 263def groupattr_filter(_eval_ctx, iterable, num, attr, *args, **kwargs): 264 """Similar to `group`, but as an attribute filter.""" 265 attr_getter = make_attrgetter(_eval_ctx.environment, attr) 266 try: 267 name = args[0] 268 args = args[1:] 269 test_func = lambda item: _eval_ctx.environment.call_test(name, item, 270 args, kwargs) 271 except LookupError: 272 test_func = bool 273 return group(iterable, num, lambda item: test_func(attr_getter(item))) 274 275 276def istext(text): 277 """`True` for text (`str` and `bytes`), but `False` for `Markup`.""" 278 return isinstance(text, str) and not isinstance(text, Markup) 279 280def prepared_paginate(items, num_items, max_per_page): 281 if max_per_page == 0: 282 num_pages = 1 283 else: 284 num_pages = int(ceil(float(num_items) / max_per_page)) 285 return items, num_items, num_pages 286 287def paginate(items, page=0, max_per_page=10): 288 """Simple generic pagination. 289 290 Given an iterable, this function returns: 291 * the slice of objects on the requested page, 292 * the total number of items, and 293 * the total number of pages. 294 295 The `items` parameter can be a list, tuple, or iterator: 296 297 >>> items = list(range(12)) 298 >>> items 299 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 300 >>> paginate(items) 301 ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 12, 2) 302 >>> paginate(items, page=1) 303 ([10, 11], 12, 2) 304 >>> paginate(iter(items)) 305 ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 12, 2) 306 >>> paginate(iter(items), page=1) 307 ([10, 11], 12, 2) 308 309 This function also works with generators: 310 311 >>> def generate(): 312 ... for idx in range(12): 313 ... yield idx 314 >>> paginate(generate()) 315 ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 12, 2) 316 >>> paginate(generate(), page=1) 317 ([10, 11], 12, 2) 318 319 The `max_per_page` parameter can be used to set the number of items that 320 should be displayed per page: 321 322 >>> items = list(range(12)) 323 >>> paginate(items, page=0, max_per_page=6) 324 ([0, 1, 2, 3, 4, 5], 12, 2) 325 >>> paginate(items, page=1, max_per_page=6) 326 ([6, 7, 8, 9, 10, 11], 12, 2) 327 328 :raises TracError: if `page` is out of the range of the paginated 329 results. 330 """ 331 if not page: 332 page = 0 333 start = page * max_per_page 334 stop = start + max_per_page 335 336 count = None 337 if hasattr(items, '__len__'): 338 count = len(items) 339 if count and start >= count: 340 from trac.util.translation import _ 341 raise TracError(_("Page %(page)s is out of range.", page=page)) 342 343 try: # Try slicing first for better performance 344 retval = items[start:stop] 345 except TypeError: # Slicing not supported, so iterate through the whole list 346 retval = [] 347 idx = -1 # Needed if items = [] 348 for idx, item in enumerate(items): 349 if start <= idx < stop: 350 retval.append(item) 351 # If we already obtained the total number of items via `len()`, 352 # we can break out of the loop as soon as we've got the last item 353 # for the requested page 354 if count is not None and idx >= stop: 355 break 356 if count is None: 357 count = idx + 1 358 359 return retval, count, int(ceil(float(count) / max_per_page)) 360 361 362class Paginator(object): 363 """Pagination controller""" 364 365 def __init__(self, items, page=0, max_per_page=10, num_items=None): 366 if not page: 367 page = 0 368 369 if num_items is None: 370 items, num_items, num_pages = paginate(items, page, max_per_page) 371 else: 372 items, num_items, num_pages = prepared_paginate(items, num_items, 373 max_per_page) 374 offset = page * max_per_page 375 self.page = page 376 self.max_per_page = max_per_page 377 self.items = items 378 self.num_items = num_items 379 self.num_pages = num_pages 380 self.span = offset, offset + len(items) 381 self.show_index = True 382 383 def __iter__(self): 384 return iter(self.items) 385 386 def __len__(self): 387 return len(self.items) 388 389 def __nonzero__(self): 390 return len(self.items) > 0 391 392 def __setitem__(self, idx, value): 393 self.items[idx] = value 394 395 @property 396 def has_more_pages(self): 397 return self.num_pages > 1 398 399 @property 400 def has_next_page(self): 401 return self.page + 1 < self.num_pages 402 403 @property 404 def has_previous_page(self): 405 return self.page > 0 406 407 def get_shown_pages(self, page_index_count = 11): 408 if not self.has_more_pages: 409 return list(range(1, 2)) 410 411 min_page = 1 412 max_page = int(ceil(float(self.num_items) / self.max_per_page)) 413 current_page = self.page + 1 414 start_page = current_page - page_index_count // 2 415 end_page = current_page + page_index_count // 2 + \ 416 (page_index_count % 2 - 1) 417 418 if start_page < min_page: 419 start_page = min_page 420 if end_page > max_page: 421 end_page = max_page 422 423 return list(range(start_page, end_page + 1)) 424 425 def displayed_items(self): 426 from trac.util.translation import _ 427 start, stop = self.span 428 total = self.num_items 429 if start + 1 == stop: 430 return _("%(last)d of %(total)d", last=stop, total=total) 431 else: 432 return _("%(start)d - %(stop)d of %(total)d", 433 start=self.span[0] + 1, stop=self.span[1], total=total) 434 435 436def separated(items, sep=',', last=None): 437 """Yield `(item, sep)` tuples, one for each element in `items`. 438 439 The separator after the last item is specified by the `last` parameter, 440 which defaults to `None`. (Since 1.1.3) 441 442 >>> list(separated([1, 2])) 443 [(1, ','), (2, None)] 444 445 >>> list(separated([1])) 446 [(1, None)] 447 448 >>> list(separated('abc', ':')) 449 [('a', ':'), ('b', ':'), ('c', None)] 450 451 >>> list(separated((1, 2, 3), sep=';', last='.')) 452 [(1, ';'), (2, ';'), (3, '.')] 453 """ 454 items = iter(items) 455 try: 456 nextval = next(items) 457 except StopIteration: 458 return 459 for i in items: 460 yield nextval, sep 461 nextval = i 462 yield nextval, last 463 464 465_js_quote = {c: '\\u%04x' % ord(c) for c in '&<>'} 466_js_quote_re = re.compile('[' + ''.join(_js_quote) + ']') 467 468class TracJSONEncoder(JSONEncoder): 469 def default(self, o): 470 if isinstance(o, Undefined): 471 return '' 472 elif isinstance(o, datetime): 473 return to_utimestamp(o if o.tzinfo else o.replace(tzinfo=utc)) 474 elif isinstance(o, Fragment): 475 return '"%s"' % javascript_quote(str(o)) 476 return JSONEncoder.default(self, o) 477 478def to_json(value): 479 """Encode `value` to JSON.""" 480 def replace(match): 481 return _js_quote[match.group(0)] 482 text = TracJSONEncoder(sort_keys=True, separators=(',', ':')).encode(value) 483 return _js_quote_re.sub(replace, text) 484