source: tagsplugin/trunk/tractags/macros.py

Last change on this file was 18143, checked in by Cinc-th, 2 years ago

TagsPlugin: replace unicode and dict.iteritems() statements which are no longer available in Python 3. Fix exception handling now requiring Exception as e. Fixed imports.

Refs #13994

File size: 18.0 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2011 Itamar Ostricher <itamarost@gmail.com>
5# Copyright (C) 2011-2015 Steffen Hoffmann <hoff.st@web.de>
6# Copyright (C) 2015 Ryan J Ollos <ryan.j.ollos@gmail.com>
7#
8# This software is licensed as described in the file COPYING, which
9# you should have received as part of this distribution.
10#
11
12import collections
13import re
14
15from fnmatch import fnmatchcase
16from pkg_resources import resource_filename
17
18from trac.config import BoolOption, ListOption, Option
19from trac.core import Component, TracError, implements
20from trac.resource import Resource, get_resource_url, render_resource_link
21from trac.ticket.api import TicketSystem
22from trac.ticket.model import Ticket
23from trac.util import as_int, embedded_numbers
24from trac.util.html import html as builder
25from trac.util.presentation import Paginator
26from trac.util.text import shorten_line, to_unicode
27from trac.web.chrome import Chrome, ITemplateProvider, add_link, \
28                            add_stylesheet
29from trac.wiki.api import IWikiMacroProvider, parse_args
30from trac.wiki.formatter import format_to_oneliner, system_message
31
32from tractags.api import InvalidTagRealm, TagSystem, N_, _, gettext
33from tractags.query import InvalidQuery
34from tractags.util import query_realms
35
36# Check for unsupported pre-tags-0.6 macro keyword arguments.
37_OBSOLETE_ARGS_RE = re.compile(r"""
38    (expression|operation|showheadings|tagspace|tagspaces)=
39    """, re.VERBOSE)
40
41try:
42    dict.iteritems
43except AttributeError:
44    # Python 3
45    def iteritems(d):
46        return iter(d.items())
47else:
48    # Python 2
49    def iteritems(d):
50        return d.iteritems()
51
52
53class TagTemplateProvider(Component):
54    """Provides templates and static resources for the tags plugin."""
55
56    implements(ITemplateProvider)
57
58    abstract = True
59
60    # ITemplateProvider methods
61
62    def get_templates_dirs(self):
63        """Return the absolute path of the directory containing the provided
64        Genshi templates.
65        """
66        return [resource_filename(__name__, 'templates')]
67
68    def get_htdocs_dirs(self):
69        """Return the absolute path of a directory containing additional
70        static resources (such as images, style sheets, etc).
71        """
72        return [('tags', resource_filename(__name__, 'htdocs'))]
73
74
75class TagWikiMacros(TagTemplateProvider):
76    """[opt] Provides macros, that utilize the tag system in wiki markup."""
77
78    implements(IWikiMacroProvider)
79
80    caseless_sort = BoolOption('tags', 'cloud_caseless_sort', default=False,
81        doc="Whether the tag cloud should be sorted case-sensitive.")
82    default_cols = Option('tags', 'listtagged_default_table_cols',
83        'id|description|tags',
84        doc="Select columns and column order for table format.\n\n"
85            "See `ListTagged` description (WikiMacros) for supported values.")
86    default_format = Option('tags', 'listtagged_default_format', 'oldlist',
87        doc="Set default format for the handler of the `/tags` domain.\n\n"
88            "See `ListTagged` description (WikiMacros) for supported values.")
89    exclude_realms = ListOption('tags', 'listtagged_exclude_realms', [],
90        doc="Comma-separated list of realms to exclude from tags queries "
91            "by default, unless specifically included using 'realm:<realm>' "
92            "in a query.")
93    items_per_page = Option('tags', 'listtagged_items_per_page', 100,
94        doc="Number of tagged resources displayed per page of tag query "
95            "results requested by `ListTagged` macros and from `/tags`.")
96    items_per_page = as_int(items_per_page, 100)
97    supported_cols = frozenset(['realm', 'id', 'description', 'tags'])
98
99    def __init__(self):
100        # TRANSLATOR: Keep macro doc style formatting here, please.
101        self.doc_cloud = N_("""Display a tag cloud.
102
103    Show a tag cloud for all tags on resources matching query.
104
105    Usage:
106
107    {{{
108    [[TagCloud(<query>[,caseless_sort=<bool>][,mincount=<n>])]]
109    }}}
110    caseless_sort::
111      Whether the tag cloud should be sorted case-sensitive.
112    mincount::
113      Optional integer threshold to hide tags with smaller count.
114
115    See tags documentation for the query syntax.
116    """)
117        self.doc_listtagged = N_("""List tagged resources.
118
119    Usage:
120
121    {{{
122    [[ListTagged(<query>[,exclude=<list>],[[format=<format>],cols=<columns>])]]
123    }}}
124    format::
125      result list presentation; supported values:
126
127    || `compact` || comma-separated inline list of "linked-description" ||
128    || `oldlist` (default) || " * linked-id description (tags)" list ||
129    || `table` || table... (see corresponding column option too) ||
130    || `short` or other value || bulleted list of "linked-description" ||
131    cols::
132      columns for 'table' format using a "|"-separated list of column names
133      (order matters); supported columns: realm, id, description, tags
134    exclude::
135      exclude tagged resources that match a name in the colon-separated list
136      of resource ids, accepts shell-style patterns
137
138    See tags documentation for the query syntax.
139    """)
140
141    # IWikiMacroProvider
142
143    def get_macros(self):
144        yield 'ListTagged'
145        yield 'TagCloud'
146
147    def get_macro_description(self, name):
148        if name == 'ListTagged':
149            return gettext(self.doc_listtagged)
150        if name == 'TagCloud':
151            return gettext(self.doc_cloud)
152
153    def expand_macro(self, formatter, name, content, realms=[]):
154        """Evaluate macro call and render results.
155
156        Calls from web-UI come with pre-processed realm selection.
157        """
158        env = self.env
159        req = formatter.req
160        tag_system = TagSystem(env)
161
162        all_realms = tag_system.get_taggable_realms()
163        if not all_realms:
164            # Tag providers are required, no result without at least one.
165            return ''
166        args, kw = parse_args(content)
167
168        query = args and args[0].strip() or None
169        if not realms:
170            # Check macro arguments for realms (typical wiki macro call).
171            realms = 'realm' in kw and kw['realm'].split('|') or []
172        if query:
173            # Add realms from query expression.
174            realms.extend(query_realms(query, all_realms))
175            # Remove redundant realm selection for performance.
176            if set(realms) == all_realms:
177                query = re.sub('(^|\W)realm:\S+(\W|$)', ' ', query).strip()
178        if name == 'TagCloud':
179            # Set implicit 'all tagged realms' as default.
180            if not realms:
181                realms = all_realms
182            if query:
183                all_tags = collections.Counter()
184                # Require per resource query including view permission checks.
185                for resource, tags in tag_system.query(req, query):
186                    all_tags.update(tags)
187            else:
188                # Allow faster per tag query, side steps permission checks.
189                all_tags = tag_system.get_all_tags(req, realms=realms)
190            mincount = 'mincount' in kw and kw['mincount'] or None
191            return self.render_cloud(req, all_tags,
192                                     caseless_sort=self.caseless_sort,
193                                     mincount=mincount, realms=realms)
194        elif name == 'ListTagged':
195            if content and _OBSOLETE_ARGS_RE.search(content):
196                data = {'warning': 'obsolete_args'}
197            else:
198                data = {'warning': None}
199            context = formatter.context
200            # Use TagsQuery arguments (most likely wiki macro calls).
201            cols = 'cols' in kw and kw['cols'] or self.default_cols
202            format = 'format' in kw and kw['format'] or self.default_format
203            if not realms:
204                # Apply ListTagged defaults to macro call w/o realm.
205                realms = list(set(all_realms)-set(self.exclude_realms))
206                if not realms:
207                    return ''
208            query = '(%s) (%s)' % (query or '', ' or '.join(['realm:%s' % (r)
209                                                             for r in realms]))
210            query_result = tag_system.query(req, query)
211            excludes = [exc.strip()
212                        for exc in kw.get('exclude', '' ).split(':')
213                        if exc.strip()]
214            if excludes and query_result:
215                filtered_result = [(resource, tags)
216                                   for resource, tags in query_result
217                                   if not any(fnmatchcase(resource.id, exc)
218                                              for exc in excludes)]
219                query_result = filtered_result
220            if not query_result:
221                return ''
222
223            def _link(resource):
224                if resource.realm == 'tag':
225                    # Keep realm selection in tag links.
226                    return builder.a(resource.id,
227                                     href=self.get_href(req, realms,
228                                                        tag=resource))
229                elif resource.realm == 'ticket':
230                    # Return resource link including ticket status dependend
231                    #   class to allow for common Trac ticket link style.
232                    ticket = Ticket(env, resource.id)
233                    return builder.a('#%s' % ticket.id,
234                                     class_=ticket['status'],
235                                     href=formatter.href.ticket(ticket.id),
236                                     title=shorten_line(ticket['summary']))
237                return render_resource_link(env, context, resource, 'compact')
238
239            if format == 'table':
240                cols = [col for col in cols.split('|')
241                        if col in self.supported_cols]
242                # Use available translations from Trac core.
243                try:
244                    labels = TicketSystem(env).get_ticket_field_labels()
245                    labels['id'] = _('Id')
246                except AttributeError:
247                    # Trac 0.11 neither has the attribute nor uses i18n.
248                    labels = {'id': 'Id', 'description': 'Description'}
249                labels['realm'] = _('Realm')
250                labels['tags'] = _('Tags')
251                headers = [{'label': labels.get(col)}
252                           for col in cols]
253                data.update({'cols': cols,
254                             'headers': headers})
255
256            try:
257                results = sorted(query_result, key=lambda r:
258                                 embedded_numbers(to_unicode(r[0].id)))
259            except (InvalidQuery, InvalidTagRealm) as e:
260                return system_message(_("ListTagged macro error"), e)
261            results = self._paginate(req, results, realms)
262            rows = []
263            for resource, tags in results:
264                desc = tag_system.describe_tagged_resource(req, resource)
265                tags = sorted(tags)
266                wiki_desc = format_to_oneliner(env, context, desc)
267                if tags:
268                    rendered_tags = [_link(Resource('tag', tag))
269                                     for tag in tags]
270                    if 'oldlist' == format:
271                        resource_link = _link(resource)
272                    else:
273                        resource_link = builder.a(wiki_desc,
274                                                  href=get_resource_url(
275                                                  env, resource, context.href))
276                        if 'table' == format:
277                            cells = []
278                            for col in cols:
279                                if col == 'id':
280                                    cells.append(_link(resource))
281                                # Don't duplicate links to resource in both.
282                                elif col == 'description' and 'id' in cols:
283                                    cells.append(wiki_desc)
284                                elif col == 'description':
285                                    cells.append(resource_link)
286                                elif col == 'realm':
287                                    cells.append(resource.realm)
288                                elif col == 'tags':
289                                    cells.append(
290                                        builder([(tag, ' ')
291                                                 for tag in rendered_tags]))
292                            rows.append({'cells': cells})
293                            continue
294                rows.append({'desc': wiki_desc,
295                             'rendered_tags': None,
296                             'resource_link': _link(resource)})
297            data.update({'format': format,
298                         'paginator': results,
299                         'results': rows,
300                         'tags_url': req.href('tags')})
301
302            # Work around a bug in trac/templates/layout.html, that causes a
303            # TypeError for the wiki macro call, if we use add_link() alone.
304            add_stylesheet(req, 'common/css/search.css')
305
306            return Chrome(env).render_template(
307                req, 'listtagged_results.html', data, {'fragment': True,
308                                                       'domain': 'tractags'})
309
310    def get_href(self, req, realms, query=None, per_page=None, page=None,
311                 tag=None, **kwargs):
312        """Prepare href objects for tag links and pager navigation.
313
314        Generate form-related arguments, strip arguments with default values.
315        """
316        # Prepare realm arguments to keep form data consistent.
317        form_realms = dict((realm, 'on') for realm in realms)
318        if not page and not per_page:
319            # We're not serving pager navigation here.
320            return get_resource_url(self.env, tag, req.href,
321                                    form_realms=form_realms, **kwargs)
322        if page == 1:
323            page = None
324        if per_page == self.items_per_page:
325            per_page = None
326        return req.href(req.path_info, form_realms, q=query,
327                        realms=realms, listtagged_per_page=per_page,
328                        listtagged_page=page, **kwargs)
329
330    def render_cloud(self, req, cloud, renderer=None, caseless_sort=False,
331                     mincount=None, realms=()):
332        """Render a tag cloud.
333
334        :cloud: Dictionary of {object: count} representing the cloud.
335        :param renderer: A callable with signature (tag, count, percent)
336                         used to render the cloud objects.
337        :param caseless_sort: Boolean, whether tag cloud should be sorted
338                              case-sensitive.
339        :param mincount: Integer threshold to hide tags with smaller count.
340        """
341        min_px = 10.0
342        max_px = 30.0
343        scale = 1.0
344
345        if renderer is None:
346            def default_renderer(tag, count, percent):
347                href = self.get_href(req, realms, tag=Resource('tag', tag))
348                return builder.a(tag, rel='tag', title='%i' % count,
349                                 href=href, style='font-size: %ipx'
350                                 % int(min_px + percent * (max_px - min_px)))
351            renderer = default_renderer
352
353        # A LUT from count to n/len(cloud)
354        size_lut = dict([(c, float(i)) for i, c in
355                         enumerate(sorted(set([r for r in cloud.values()])))])
356        if size_lut:
357            scale = 1.0 / len(size_lut)
358
359        if caseless_sort:
360            # Preserve upper-case precedence within similar tags.
361            items = reversed(sorted(iteritems(cloud),
362                                    key=lambda t: t[0].lower(), reverse=True))
363        else:
364            items = sorted(iteritems(cloud))
365        ul = li = None
366        for i, (tag, count) in enumerate(items):
367            percent = size_lut[count] * scale
368            if mincount and count < as_int(mincount, 1):
369                # Tag count is too low.
370                continue
371            if ul:
372                # Found new tag for cloud; now add previously prepared one.
373                ul('\n', li)
374            else:
375                # Found first tag for cloud; now create the list.
376                ul = builder.ul(class_='tagcloud')
377            # Prepare current tag entry.
378            li = builder.li(renderer(tag, count, percent))
379        if li:
380            # All tags checked; mark latest tag as last one (no tailing colon).
381            li(class_='last')
382            ul('\n', li, '\n')
383        return ul and ul or _("No tags found")
384
385    def _paginate(self, req, results, realms):
386        query = req.args.get('q', None)
387        current_page = as_int(req.args.get('listtagged_page'), 1, min=1)
388        items_per_page = as_int(req.args.get('listtagged_per_page'),
389                                self.items_per_page)
390        if items_per_page < 1:
391            items_per_page = self.items_per_page
392        try:
393            result = Paginator(results, current_page - 1, items_per_page)
394        except (AssertionError, TracError) as e:
395            # AssertionError raised in Trac < 1.0.10, TracError otherwise
396            self.log.warn("ListTagged macro: %s", e)
397            current_page = 1
398            result = Paginator(results, current_page - 1, items_per_page)
399
400        pagedata = []
401        shown_pages = result.get_shown_pages(21)
402        for page in shown_pages:
403            page_href = self.get_href(req, realms, query, items_per_page, page)
404            pagedata.append([page_href, None, str(page),
405                             _("Page %(num)d", num=page)])
406
407        attributes = ['href', 'class', 'string', 'title']
408        result.shown_pages = [dict(zip(attributes, p)) for p in pagedata]
409
410        result.current_page = {'href': None, 'class': 'current',
411                               'string': str(result.page + 1), 'title': None}
412
413        if result.has_next_page:
414            next_href = self.get_href(req, realms, query, items_per_page,
415                                      current_page + 1)
416            add_link(req, 'next', next_href, _('Next Page'))
417
418        if result.has_previous_page:
419            prev_href = self.get_href(req, realms, query, items_per_page,
420                                      current_page - 1)
421            add_link(req, 'prev', prev_href, _('Previous Page'))
422        return result
Note: See TracBrowser for help on using the repository browser.