source: tagsplugin/tags/0.8/tractags/web_ui.py

Last change on this file was 14942, checked in by Ryan J Ollos, 8 years ago

0.8dev: Skip post_process_request during exception handling.

Refs #12486.

File size: 21.8 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2008 Dmitry Dianov
5# Copyright (C) 2011 Itamar Ostricher <itamarost@gmail.com>
6# Copyright (C) 2011-2012 Ryan J Ollos <ryan.j.ollos@gmail.com>
7# Copyright (C) 2011-2014 Steffen Hoffmann <hoff.st@web.de>
8#
9# This software is licensed as described in the file COPYING, which
10# you should have received as part of this distribution.
11#
12
13import re
14import math
15
16from genshi.builder import tag as builder
17from genshi.core import Markup
18from genshi.filters.transform import Transformer
19
20from trac import __version__ as trac_version
21from trac.config import BoolOption, ListOption, Option
22from trac.core import implements
23from trac.mimeview import Context
24from trac.resource import Resource, ResourceSystem, get_resource_name
25from trac.resource import get_resource_url
26from trac.timeline.api import ITimelineEventProvider
27from trac.util import to_unicode
28from trac.util.text import CRLF, unicode_quote_plus
29from trac.web import IRequestFilter
30from trac.web.api import IRequestHandler, ITemplateStreamFilter
31from trac.web.api import ITemplateStreamFilter
32from trac.web.chrome import Chrome, INavigationContributor
33from trac.web.chrome import add_ctxtnav, add_script, add_stylesheet
34from trac.web.chrome import add_warning
35from trac.wiki.formatter import Formatter
36from trac.wiki.model import WikiPage
37
38from tractags.api import REALM_RE, TagSystem, _, tag_, tagn_
39from tractags.compat import is_enabled
40from tractags.macros import TagTemplateProvider, TagWikiMacros, as_int
41from tractags.macros import query_realms
42from tractags.model import tag_changes
43from tractags.query import InvalidQuery, Query
44from tractags.util import split_into_tags
45
46try:
47    from trac.util.text import javascript_quote
48except ImportError:
49    # Fallback for Trac<0.11.3 - verbatim copy from Trac 1.0.
50    _js_quote = {'\\': '\\\\', '"': '\\"', '\b': '\\b', '\f': '\\f',
51                 '\n': '\\n', '\r': '\\r', '\t': '\\t', "'": "\\'"}
52    for i in range(0x20) + [ord(c) for c in '&<>']:
53        _js_quote.setdefault(chr(i), '\\u%04x' % i)
54    _js_quote_re = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t\'&<>]')
55
56    def javascript_quote(text):
57        """Quote strings for inclusion in javascript."""
58        if not text:
59            return ''
60        def replace(match):
61            return _js_quote[match.group(0)]
62        return _js_quote_re.sub(replace, text)
63
64
65class TagInputAutoComplete(TagTemplateProvider):
66    """[opt] Provides auto-complete functionality for tag input fields.
67
68    This module is based on KeywordSuggestModule from KeywordSuggestPlugin
69    0.5dev.
70    """
71
72    implements (IRequestFilter, ITemplateStreamFilter)
73
74    field_opt = Option('tags', 'complete_field', 'keywords',
75        "Ticket field to which a drop-down tag list should be attached.")
76
77    help_opt = Option('tags','ticket_help', None,
78        "If specified, 'keywords' label on ticket view will be turned into a "
79        "link to this URL.")
80
81    helpnewwindow_opt = BoolOption('tags','ticket_help_newwindow', False,
82        "If true and keywords_help specified, wiki page will open in a new "
83        "window. Default is false.")
84
85    # Needs to be reimplemented, refs th:#8141.
86    #mustmatch = BoolOption('tags', 'complete_mustmatch', False,
87    #    "If true, input fields accept values from the word list only.")
88
89    matchcontains_opt = BoolOption('tags','complete_matchcontains', True,
90        "Include partial matches in suggestion list. Default is true.")
91
92    separator_opt = Option('tags','separator', ' ',
93        "Character(s) to use as separators between tags. Default is a "
94        "single whitespace.")
95
96    sticky_tags_opt = ListOption('tags', 'complete_sticky_tags', '', ',',
97        doc="A list of comma separated values available for input.")
98
99    def __init__(self):
100        self.tags_enabled = is_enabled(self.env, TagSystem)
101
102    @property
103    def separator(self):
104        return self.separator_opt.strip('\'') or ' '
105
106    # IRequestFilter methods
107
108    def pre_process_request(self, req, handler):
109        return handler
110
111    def post_process_request(self, req, template, data, content_type):
112        if template is not None and \
113                (req.path_info.startswith('/ticket/') or
114                 req.path_info.startswith('/newticket') or
115                 (self.tags_enabled and req.path_info.startswith('/wiki/'))):
116            # In Trac 1.0 and later, jQuery-UI is included from the core.
117            if trac_version >= '1.0':
118                Chrome(self.env).add_jquery_ui(req)
119            else:
120                add_script(req, 'tags/js/jquery-ui-1.8.16.custom.min.js')
121                add_stylesheet(req, 'tags/css/jquery-ui-1.8.16.custom.css')
122        return template, data, content_type
123
124    # ITemplateStreamFilter method
125    def filter_stream(self, req, method, filename, stream, data):
126
127        if not (filename == 'ticket.html' or
128                (self.tags_enabled and filename == 'wiki_edit.html')):
129            return stream
130
131        keywords = self._get_keywords_string(req)
132        if not keywords:
133            self.log.debug(
134                "No keywords found. TagInputAutoComplete is disabled.")
135            return stream
136
137        matchfromstart = '"^" +'
138        if self.matchcontains_opt:
139            matchfromstart = ''
140
141        js = """
142            jQuery(document).ready(function($) {
143                var keywords = [ %(keywords)s ]
144                var sep = '%(separator)s'.trim() + ' '
145                function split( val ) {
146                    return val.split( /%(separator)s\s*|\s+/ );
147                }
148                function extractLast( term ) {
149                    return split( term ).pop();
150                }
151                $('%(field)s')
152                    // don't navigate away from field on tab when selecting
153                    // an item
154                    .bind( "keydown", function( event ) {
155                        if ( event.keyCode === $.ui.keyCode.TAB &&
156                             $( this ).data( "autocomplete" ).menu.active ) {
157                            event.preventDefault();
158                        }
159                    })
160                    .autocomplete({
161                        delay: 0,
162                        minLength: 0,
163                        source: function( request, response ) {
164                            // delegate back to autocomplete, but extract
165                            // the last term
166                            response( $.ui.autocomplete.filter(
167                                keywords, extractLast( request.term ) ) );
168                        },
169                        focus: function() {
170                            // prevent value inserted on focus
171                            return false;
172                        },
173                        select: function( event, ui ) {
174                            var terms = split( this.value );
175                            // remove the current input
176                            terms.pop();
177                            // add the selected item
178                            terms.push( ui.item.value );
179                            // add placeholder to get the comma-and-space at
180                            // the end
181                            terms.push( "" );
182                            this.value = terms.join( sep );
183                            return false;
184                        }
185                    });
186            });"""
187
188        # Inject transient part of JavaScript into ticket.html template.
189        if req.path_info.startswith('/ticket/') or \
190           req.path_info.startswith('/newticket'):
191            js_ticket =  js % {'field': '#field-' + self.field_opt,
192                               'keywords': keywords,
193                               'matchfromstart': matchfromstart,
194                               'separator': self.separator}
195            stream = stream | Transformer('.//head').append\
196                              (builder.script(Markup(js_ticket),
197                               type='text/javascript'))
198
199            # Turn keywords field label into link to an arbitrary resource.
200            if self.help_opt:
201                link = self._get_help_link(req)
202                if self.helpnewwindow_opt:
203                    link = builder.a(href=link, target='blank')
204                else:
205                    link = builder.a(href=link)
206                stream = stream | Transformer\
207                     ('//label[@for="field-keywords"]/text()').wrap(link)
208
209        # Inject transient part of JavaScript into wiki.html template.
210        elif self.tags_enabled and req.path_info.startswith('/wiki/'):
211            js_wiki =  js % {'field': '#tags',
212                             'keywords': keywords,
213                             'matchfromstart': matchfromstart,
214                             'separator': self.separator}
215            stream = stream | Transformer('.//head').append \
216                              (builder.script(Markup(js_wiki),
217                               type='text/javascript'))
218        return stream
219
220    # Private methods
221
222    def _get_keywords_string(self, req):
223        keywords = set(self.sticky_tags_opt) # prevent duplicates
224        if self.tags_enabled:
225            # Use TagsPlugin >= 0.7 performance-enhanced API.
226            tags = TagSystem(self.env).get_all_tags(req)
227            keywords.update(tags.keys())
228
229        if keywords:
230            keywords = sorted(keywords)
231            keywords = ','.join(("'%s'" % javascript_quote(_keyword)
232                                 for _keyword in keywords))
233        else:
234            keywords = ''
235
236        return keywords
237
238    def _get_help_link(self, req):
239        link = realm = resource_id = None
240        if self.help_opt.startswith('/'):
241            # Assume valid URL to arbitrary resource inside
242            #   of the current Trac environment.
243            link = req.href(self.help_opt)
244        if not link and ':' in self.help_opt:
245            realm, resource_id = self.help_opt.split(':', 1)
246            # Validate realm-like prefix against resource realm list,
247            #   but exclude 'wiki' to allow deferred page creation.
248            rsys = ResourceSystem(self.env)
249            if realm in set(rsys.get_known_realms()) - set('wiki'):
250                mgr = rsys.get_resource_manager(realm)
251                # Handle optional IResourceManager method gracefully.
252                try:
253                    if mgr.resource_exists(Resource(realm, resource_id)):
254                        link = mgr.get_resource_url(resource_id, req.href)
255                except AttributeError:
256                    # Assume generic resource URL build rule.
257                    link = req.href(realm, resource_id)
258        if not link:
259            if not resource_id:
260                # Assume wiki page name for backwards-compatibility.
261                resource_id = self.help_opt
262            # Preserve anchor without 'path_safe' arg (since Trac 0.12.2dev).
263            if '#' in resource_id:
264                path, anchor = resource_id.split('#', 1)
265            else:
266                anchor = None
267                path = resource_id
268            if hasattr(unicode_quote_plus, "safe"):
269                # Use method for query string quoting (since Trac 0.13dev).
270                anchor = unicode_quote_plus(anchor, safe="?!~*'()")
271            else:
272                anchor = unicode_quote_plus(anchor)
273            link = '#'.join([req.href.wiki(path), anchor])
274        return link
275
276
277class TagRequestHandler(TagTemplateProvider):
278    """[main] Implements the /tags handler."""
279
280    implements(INavigationContributor, IRequestHandler)
281
282    cloud_mincount = Option('tags', 'cloud_mincount', 1,
283        doc="""Integer threshold to hide tags with smaller count.""")
284    default_cols = Option('tags', 'default_table_cols', 'id|description|tags',
285        doc="""Select columns and order for table format using a "|"-separated
286            list of column names.
287
288            Supported columns: realm, id, description, tags
289            """)
290    default_format = Option('tags', 'default_format', 'oldlist',
291        doc="""Set the default format for the handler of the `/tags` domain.
292
293            || `oldlist` (default value) || The original format with a
294            bulleted-list of "linked-id description (tags)" ||
295            || `compact` || bulleted-list of "linked-description" ||
296            || `table` || table... (see corresponding column option) ||
297            """)
298    exclude_realms = ListOption('tags', 'exclude_realms', [],
299        doc="""Comma-separated list of realms to exclude from tags queries
300            by default, unless specifically included using "realm:realm-name"
301            in a query.""")
302
303    # INavigationContributor methods
304    def get_active_navigation_item(self, req):
305        if 'TAGS_VIEW' in req.perm:
306            return 'tags'
307
308    def get_navigation_items(self, req):
309        if 'TAGS_VIEW' in req.perm:
310            label = tag_("Tags")
311            yield ('mainnav', 'tags',
312                   builder.a(label, href=req.href.tags(), accesskey='T'))
313
314    # IRequestHandler methods
315    def match_request(self, req):
316        return req.path_info.startswith('/tags')
317
318    def process_request(self, req):
319        req.perm.require('TAGS_VIEW')
320
321        match = re.match(r'/tags/?(.*)', req.path_info)
322        tag_id = match.group(1) and match.group(1) or None
323        query = req.args.get('q', '')
324
325        # Consider only providers, that are permitted for display.
326        tag_system = TagSystem(self.env)
327        all_realms = tag_system.get_taggable_realms(req.perm)
328        if not (tag_id or query) or [r for r in all_realms
329                                     if r in req.args] == []:
330            for realm in all_realms:
331                if not realm in self.exclude_realms:
332                    req.args[realm] = 'on'
333        checked_realms = [r for r in all_realms if r in req.args]
334        if query:
335            # Add permitted realms from query expression.
336            checked_realms.extend(query_realms(query, all_realms))
337        realm_args = dict(zip([r for r in checked_realms],
338                              ['on' for r in checked_realms]))
339        # Switch between single tag and tag query expression mode.
340        if tag_id and not re.match(r"""(['"]?)(\S+)\1$""", tag_id, re.UNICODE):
341            # Convert complex, invalid tag ID's --> query expression.
342            req.redirect(req.href.tags(realm_args, q=tag_id))
343        elif query:
344            single_page = re.match(r"""(['"]?)(\S+)\1$""", query, re.UNICODE)
345            if single_page:
346                # Convert simple query --> single tag.
347                req.redirect(req.href.tags(single_page.group(2), realm_args))
348
349        data = dict(page_title=_("Tags"), checked_realms=checked_realms)
350        # Populate the TagsQuery form field.
351        data['tag_query'] = tag_id and tag_id or query
352        data['tag_realms'] = list(dict(name=realm,
353                                       checked=realm in checked_realms)
354                                  for realm in all_realms)
355        if tag_id:
356            data['tag_page'] = WikiPage(self.env,
357                                        tag_system.wiki_page_prefix + tag_id)
358        if query or tag_id:
359            macro = 'ListTagged'
360            # TRANSLATOR: The meta-nav link label.
361            add_ctxtnav(req, _("Back to Cloud"), req.href.tags())
362            args = "%s,format=%s,cols=%s" % \
363                   (tag_id and tag_id or query, self.default_format,
364                    self.default_cols)
365            data['mincount'] = None
366        else:
367            macro = 'TagCloud'
368            mincount = as_int(req.args.get('mincount', None),
369                              self.cloud_mincount)
370            args = mincount and "mincount=%s" % mincount or None
371            data['mincount'] = mincount
372        formatter = Formatter(self.env, Context.from_request(req,
373                                                             Resource('tag')))
374        self.env.log.debug(
375            "%s macro arguments: %s" % (macro, args and args or '(none)'))
376        macros = TagWikiMacros(self.env)
377        try:
378            # Query string without realm throws 'NotImplementedError'.
379            data['tag_body'] = checked_realms and \
380                               macros.expand_macro(formatter, macro, args,
381                                                   realms=checked_realms) \
382                               or ''
383        except InvalidQuery, e:
384            data['tag_query_error'] = to_unicode(e)
385            data['tag_body'] = macros.expand_macro(formatter, 'TagCloud', '')
386        add_stylesheet(req, 'tags/css/tractags.css')
387        return 'tag_view.html', data, None
388
389
390class TagTimelineEventFilter(TagTemplateProvider):
391    """[opt] Filters timeline events by tags associated with listed resources
392    mentioned in the event.
393    """
394
395    implements(IRequestFilter, ITemplateStreamFilter)
396
397    key = 'tag_query'
398
399    # ITemplateStreamFilter method
400    def filter_stream(self, req, method, filename, stream, data):
401        if req.path_info == '/timeline':
402            insert = builder(Markup('<br />'), tag_("matching tags "),
403                             builder.input(type='text', name=self.key,
404                                           value=data.get(self.key)))
405            xpath = '//form[@id="prefs"]/div[1]'
406            stream = stream | Transformer(xpath).append(insert)
407        return stream
408
409    # IRequestFilter methods
410
411    def pre_process_request(self, req, handler):
412        return handler
413
414    def post_process_request(self, req, template, data, content_type):
415        if data and req.path_info == '/timeline' and \
416                'TAGS_VIEW' in req.perm(Resource('tags')):
417
418            def realm_handler(_, node, context):
419                return query.match(node, [context.realm])
420
421            query_str = req.args.get(self.key)
422            if query_str is None and req.args.get('format') != 'rss':
423                query_str = req.session.get('timeline.%s' % self.key)
424            else:
425                query_str = (query_str or '').strip()
426                # Record tag query expression between visits.
427                req.session['timeline.%s' % self.key] = query_str
428
429            if data.get('events') and query_str:
430                tag_system = TagSystem(self.env)
431                try:
432                    query = Query(query_str,
433                                  attribute_handlers=dict(realm=realm_handler)
434                            )
435                except InvalidQuery, e:
436                    add_warning(req, _("Tag query syntax error: %s"
437                                       % to_unicode(e)))
438                else:
439                    all_realms = tag_system.get_taggable_realms(req.perm)
440                    query_realms = set()
441                    for m in REALM_RE.finditer(query.as_string()):
442                        query_realms.add(m.group(1))
443                    # Don't care about resources from non-taggable realms.
444                    realms = not query_realms and all_realms or \
445                             query_realms.intersection(all_realms)
446                    events = []
447                    self.log.debug("Filtering timeline events by tags '%s'"
448                                   % query_str)
449                    for event in data['events']:
450                        resource = event['data'][0]
451                        if resource.realm in realms:
452                            # Shortcut view permission checks here.
453                            tags = tag_system.get_tags(None, resource)
454                            if query(tags, context=resource):
455                                events.append(event)
456                    # Overwrite with filtered list.
457                    data['events'] = events
458            if query_str:
459                # Add current value for next form rendering.
460                data[self.key] = query_str
461            elif self.key in req.session:
462                del req.session[self.key]
463        return template, data, content_type
464
465
466class TagTimelineEventProvider(TagTemplateProvider):
467    """[opt] Delivers recorded tag change events to timeline view."""
468
469    implements(ITimelineEventProvider)
470
471    # ITimelineEventProvider methods
472
473    def get_timeline_filters(self, req):
474        if 'TAGS_VIEW' in req.perm('tags'):
475            yield ('tags', _("Tag changes"))
476
477    def get_timeline_events(self, req, start, stop, filters):
478        if 'tags' in filters:
479            tags_realm = Resource('tags')
480            if not 'TAGS_VIEW' in req.perm(tags_realm):
481                return
482            add_stylesheet(req, 'tags/css/tractags.css')
483            for time, author, tagspace, name, old_tags, new_tags in \
484                    tag_changes(self.env, None, start, stop):
485                tagged_resource = Resource(tagspace, name)
486                if 'TAGS_VIEW' in req.perm(tagged_resource):
487                    yield ('tags', time, author,
488                           (tagged_resource, old_tags, new_tags), self)
489
490    def render_timeline_event(self, context, field, event):
491        resource = event[3][0]
492        if field == 'url':
493            return get_resource_url(self.env, resource, context.href)
494        elif field == 'title':
495            name = builder.em(get_resource_name(self.env, resource))
496            return tag_("Tag change on %(resource)s", resource=name)
497        elif field == 'description':
498            return render_tag_changes(event[3][1], event[3][2])
499
500
501def render_tag_changes(old_tags, new_tags):
502        old_tags = split_into_tags(old_tags or '')
503        new_tags = split_into_tags(new_tags or '')
504        added = sorted(new_tags - old_tags)
505        added = added and \
506                tagn_("%(tags)s added", "%(tags)s added",
507                      len(added), tags=builder.em(', '.join(added)))
508        removed = sorted(old_tags - new_tags)
509        removed = removed and \
510                  tagn_("%(tags)s removed", "%(tags)s removed",
511                        len(removed), tags=builder.em(', '.join(removed)))
512        # TRANSLATOR: How to delimit added and removed tags.
513        delim = added and removed and _("; ")
514        return builder(builder.strong(_("Tags")), ' ', added,
515                       delim, removed)
Note: See TracBrowser for help on using the repository browser.