source: tagsplugin/tags/0.7/tractags/macros.py

Last change on this file was 13803, checked in by Steffen Hoffmann, 10 years ago

TagsPlugin: Restore signature of TagWikiMacros.render_cloud() for compatibility, refs #11659.

As we found this plugin rather deeply integrated into TracHacksPlugin, this
is the way to go.

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