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