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

Last change on this file was 17471, checked in by Ryan J Ollos, 4 years ago

TracTags 0.11dev: Make autocomplete compatible with Trac 1.4

Refs #13591.

File size: 17.1 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
14
15from genshi.filters.transform import Transformer
16
17from trac.config import BoolOption, ListOption, Option
18from trac.core import implements
19from trac.resource import (
20    Resource, ResourceSystem, get_resource_name, get_resource_url)
21from trac.timeline.api import ITimelineEventProvider
22from trac.util.html import Markup, html as builder
23from trac.util.text import javascript_quote, to_unicode, unicode_quote_plus
24from trac.web.api import IRequestFilter, IRequestHandler, ITemplateStreamFilter
25from trac.web.chrome import (
26    Chrome, INavigationContributor, add_ctxtnav, add_script, add_script_data,
27    add_stylesheet, add_warning, web_context)
28from trac.wiki.formatter import Formatter
29from trac.wiki.model import WikiPage
30
31from tractags.api import REALM_RE, TagSystem, _, tag_, tagn_
32from tractags.macros import TagTemplateProvider, TagWikiMacros, as_int
33from tractags.macros import query_realms
34from tractags.model import tag_changes
35from tractags.query import InvalidQuery, Query
36from tractags.util import split_into_tags
37
38
39class TagInputAutoComplete(TagTemplateProvider):
40    """[opt] Provides auto-complete functionality for tag input fields.
41
42    This module is based on KeywordSuggestModule from KeywordSuggestPlugin
43    0.5dev.
44    """
45
46    implements(IRequestFilter)
47
48    field_opt = Option('tags', 'complete_field', 'keywords',
49        "Ticket field to which a drop-down tag list should be attached.")
50
51    help_opt = Option('tags', 'ticket_help', None,
52        "If specified, 'keywords' label on ticket view will be turned into a "
53        "link to this URL.")
54
55    help_new_window_opt = BoolOption('tags', 'ticket_help_newwindow', False,
56        "If true and keywords_help specified, wiki page will open in a new "
57        "window. Default is false.")
58
59    # Needs to be reimplemented, refs th:#8141.
60    #mustmatch = BoolOption('tags', 'complete_mustmatch', False,
61    #    "If true, input fields accept values from the word list only.")
62
63    match_contains_opt = BoolOption('tags', 'complete_matchcontains', True,
64        "Include partial matches in suggestion list. Default is true.")
65
66    separator_opt = Option('tags', 'separator', ' ',
67        "Character(s) to use as separators between tags. Default is a "
68        "single whitespace.")
69
70    sticky_tags_opt = ListOption('tags', 'complete_sticky_tags', '', ',',
71        doc="A list of comma separated values available for input.")
72
73    def __init__(self):
74        self.tags_enabled = self.env.is_enabled(TagSystem)
75
76    @property
77    def separator(self):
78        return self.separator_opt.strip('\'') or ' '
79
80    # IRequestFilter methods
81
82    def pre_process_request(self, req, handler):
83        return handler
84
85    def post_process_request(self, req, template, data, content_type):
86        if template == 'ticket.html' or \
87                (self.tags_enabled and template == 'wiki_edit.html'):
88            keywords = self._get_keywords(req)
89            if not keywords:
90                self.log.debug("No keywords found. TagInputAutoComplete is "
91                               "disabled.")
92                return template, data, content_type
93
94            def add_autocomplete_script(field, help_href=None):
95                match_from_start = '' if self.match_contains_opt else '"^" +'
96                add_script_data(req, tags={
97                    'autocomplete_field': field,
98                    'keywords': keywords,
99                    'match_from_start': match_from_start,
100                    'separator': self.separator,
101                    'help_href': help_href,
102                    'help_new_window': self.help_new_window_opt,
103                })
104                add_script(req, 'tags/js/autocomplete_tags.js')
105
106            if template == 'ticket.html':
107                help_href = self._get_help_href(req)
108                add_autocomplete_script('field-' + self.field_opt, help_href)
109            elif self.tags_enabled and template == 'wiki_edit.html':
110                add_autocomplete_script('tags')
111
112            Chrome(self.env).add_jquery_ui(req)
113        return template, data, content_type
114
115    # Private methods
116
117    def _get_keywords(self, req):
118        keywords = set(self.sticky_tags_opt)  # prevent duplicates
119        if self.tags_enabled:
120            # Use TagsPlugin >= 0.7 performance-enhanced API.
121            tags = TagSystem(self.env).get_all_tags(req)
122            keywords.update(tags.keys())
123        return sorted(keywords) if keywords else []
124
125
126    def _get_help_href(self, req):
127        if not self.help_opt:
128            return None
129        link = resource_id = None
130        if self.help_opt.startswith('/'):
131            # Assume valid URL to arbitrary resource inside
132            #   of the current Trac environment.
133            link = req.href(self.help_opt)
134        if not link and ':' in self.help_opt:
135            realm, resource_id = self.help_opt.split(':', 1)
136            # Validate realm-like prefix against resource realm list,
137            #   but exclude 'wiki' to allow deferred page creation.
138            rsys = ResourceSystem(self.env)
139            if realm in set(rsys.get_known_realms()) - set('wiki'):
140                mgr = rsys.get_resource_manager(realm)
141                # Handle optional IResourceManager method gracefully.
142                try:
143                    if mgr.resource_exists(Resource(realm, resource_id)):
144                        link = mgr.get_resource_url(resource_id, req.href)
145                except AttributeError:
146                    # Assume generic resource URL build rule.
147                    link = req.href(realm, resource_id)
148        if not link:
149            if not resource_id:
150                # Assume wiki page name for backwards-compatibility.
151                resource_id = self.help_opt
152            if '#' in resource_id:
153                path, anchor = resource_id.split('#', 1)
154                anchor = unicode_quote_plus(anchor, safe="?!~*'()")
155                link = '#'.join((req.href.wiki(path), anchor))
156            else:
157                link = req.href.wiki(resource_id)
158        return link
159
160
161class TagRequestHandler(TagTemplateProvider):
162    """[main] Implements the /tags handler."""
163
164    implements(INavigationContributor, IRequestHandler)
165
166    cloud_mincount = Option('tags', 'cloud_mincount', 1,
167        doc="""Integer threshold to hide tags with smaller count.""")
168    default_cols = Option('tags', 'default_table_cols', 'id|description|tags',
169        doc="""Select columns and order for table format using a "|"-separated
170            list of column names.
171
172            Supported columns: realm, id, description, tags
173            """)
174    default_format = Option('tags', 'default_format', 'oldlist',
175        doc="""Set the default format for the handler of the `/tags` domain.
176
177            || `oldlist` (default value) || The original format with a
178            bulleted-list of "linked-id description (tags)" ||
179            || `compact` || bulleted-list of "linked-description" ||
180            || `table` || table... (see corresponding column option) ||
181            """)
182    exclude_realms = ListOption('tags', 'exclude_realms', [],
183        doc="""Comma-separated list of realms to exclude from tags queries
184            by default, unless specifically included using "realm:realm-name"
185            in a query.""")
186
187    # INavigationContributor methods
188    def get_active_navigation_item(self, req):
189        if 'TAGS_VIEW' in req.perm:
190            return 'tags'
191
192    def get_navigation_items(self, req):
193        if 'TAGS_VIEW' in req.perm:
194            label = tag_("Tags")
195            yield ('mainnav', 'tags',
196                   builder.a(label, href=req.href.tags(), accesskey='T'))
197
198    # IRequestHandler methods
199    def match_request(self, req):
200        return req.path_info.startswith('/tags')
201
202    def process_request(self, req):
203        req.perm.require('TAGS_VIEW')
204
205        match = re.match(r'/tags/?(.*)', req.path_info)
206        tag_id = match.group(1) and match.group(1) or None
207        query = req.args.get('q', '')
208
209        # Consider only providers, that are permitted for display.
210        tag_system = TagSystem(self.env)
211        all_realms = tag_system.get_taggable_realms(req.perm)
212        if not (tag_id or query) or [r for r in all_realms
213                                     if r in req.args] == []:
214            for realm in all_realms:
215                if realm not in self.exclude_realms:
216                    req.args[realm] = 'on'
217        checked_realms = [r for r in all_realms if r in req.args]
218        if query:
219            # Add permitted realms from query expression.
220            checked_realms.extend(query_realms(query, all_realms))
221        realm_args = dict(zip([r for r in checked_realms],
222                              ['on' for r in checked_realms]))
223        # Switch between single tag and tag query expression mode.
224        if tag_id and not re.match(r"""(['"]?)(\S+)\1$""", tag_id, re.UNICODE):
225            # Convert complex, invalid tag ID's --> query expression.
226            req.redirect(req.href.tags(realm_args, q=tag_id))
227        elif query:
228            single_page = re.match(r"""(['"]?)(\S+)\1$""", query, re.UNICODE)
229            if single_page:
230                # Convert simple query --> single tag.
231                req.redirect(req.href.tags(single_page.group(2), realm_args))
232
233        data = dict(page_title=_("Tags"), checked_realms=checked_realms)
234        # Populate the TagsQuery form field.
235        data['tag_query'] = tag_id and tag_id or query
236        data['tag_realms'] = list(dict(name=realm,
237                                       checked=realm in checked_realms)
238                                  for realm in all_realms)
239        if tag_id:
240            data['tag_page'] = WikiPage(self.env,
241                                        tag_system.wiki_page_prefix + tag_id)
242        if query or tag_id:
243            macro = 'ListTagged'
244            # TRANSLATOR: The meta-nav link label.
245            add_ctxtnav(req, _("Back to Cloud"), req.href.tags())
246            args = "%s,format=%s,cols=%s" % \
247                   (tag_id and tag_id or query, self.default_format,
248                    self.default_cols)
249            data['mincount'] = None
250        else:
251            macro = 'TagCloud'
252            mincount = as_int(req.args.get('mincount', None),
253                              self.cloud_mincount)
254            args = mincount and "mincount=%s" % mincount or None
255            data['mincount'] = mincount
256        formatter = Formatter(self.env, web_context(req, Resource('tag')))
257        self.env.log.debug("%s macro arguments: %s", macro,
258                           args and args or '(none)')
259        macros = TagWikiMacros(self.env)
260        try:
261            # Query string without realm throws 'NotImplementedError'.
262            data['tag_body'] = checked_realms and \
263                               macros.expand_macro(formatter, macro, args,
264                                                   realms=checked_realms) \
265                               or ''
266        except InvalidQuery, e:
267            data['tag_query_error'] = to_unicode(e)
268            data['tag_body'] = macros.expand_macro(formatter, 'TagCloud', '')
269        add_stylesheet(req, 'tags/css/tractags.css')
270        return 'tag_view.html', data, None
271
272
273class TagTimelineEventFilter(TagTemplateProvider):
274    """[opt] Filters timeline events by tags associated with listed resources
275    mentioned in the event.
276    """
277
278    implements(IRequestFilter, ITemplateStreamFilter)
279
280    key = 'tag_query'
281
282    # ITemplateStreamFilter method
283    def filter_stream(self, req, method, filename, stream, data):
284        if req.path_info == '/timeline':
285            insert = builder(Markup('<br />'), tag_("matching tags "),
286                             builder.input(type='text', name=self.key,
287                                           value=data.get(self.key)))
288            xpath = '//form[@id="prefs"]/div[1]'
289            stream = stream | Transformer(xpath).append(insert)
290        return stream
291
292    # IRequestFilter methods
293
294    def pre_process_request(self, req, handler):
295        return handler
296
297    def post_process_request(self, req, template, data, content_type):
298        if data and req.path_info == '/timeline' and \
299                'TAGS_VIEW' in req.perm(Resource('tags')):
300
301            def realm_handler(_, node, context):
302                return query.match(node, [context.realm])
303
304            query_str = req.args.getfirst(self.key)
305            if query_str is None and req.args.get('format') != 'rss':
306                query_str = req.session.get('timeline.%s' % self.key)
307            else:
308                query_str = (query_str or '').strip()
309                # Record tag query expression between visits.
310                req.session['timeline.%s' % self.key] = query_str
311
312            if data.get('events') and query_str:
313                tag_system = TagSystem(self.env)
314                try:
315                    query = Query(query_str,
316                                  attribute_handlers={'realm': realm_handler})
317                except InvalidQuery, e:
318                    add_warning(req, _("Tag query syntax error: %s" % e))
319                else:
320                    all_realms = tag_system.get_taggable_realms(req.perm)
321                    query_realms = set()
322                    for m in REALM_RE.finditer(query.as_string()):
323                        query_realms.add(m.group(1))
324                    # Don't care about resources from non-taggable realms.
325                    realms = not query_realms and all_realms or \
326                             query_realms.intersection(all_realms)
327                    events = []
328                    self.log.debug("Filtering timeline events by tags '%s'",
329                                   query_str)
330                    for event in data['events']:
331                        resource = resource_from_event(event)
332                        if resource and resource.realm in realms:
333                            # Shortcut view permission checks here.
334                            tags = tag_system.get_tags(None, resource)
335                            if query(tags, context=resource):
336                                events.append(event)
337                    # Overwrite with filtered list.
338                    data['events'] = events
339            if query_str:
340                # Add current value for next form rendering.
341                data[self.key] = query_str
342            elif self.key in req.session:
343                del req.session[self.key]
344        return template, data, content_type
345
346
347def resource_from_event(event):
348    resource = None
349    event_data = event['data']
350    if not isinstance(event_data, (tuple, list)):
351        event_data = [event_data]
352    for entry in event_data:
353        try:
354            entry.realm
355        except AttributeError:
356            pass
357        else:
358            resource = entry
359            break
360    return resource
361
362
363class TagTimelineEventProvider(TagTemplateProvider):
364    """[opt] Delivers recorded tag change events to timeline view."""
365
366    implements(ITimelineEventProvider)
367
368    # ITimelineEventProvider methods
369
370    def get_timeline_filters(self, req):
371        if 'TAGS_VIEW' in req.perm('tags'):
372            yield ('tags', _("Tag changes"))
373
374    def get_timeline_events(self, req, start, stop, filters):
375        if 'tags' in filters:
376            tags_realm = Resource('tags')
377            if 'TAGS_VIEW' not in req.perm(tags_realm):
378                return
379            add_stylesheet(req, 'tags/css/tractags.css')
380            for time, author, tagspace, name, old_tags, new_tags in \
381                    tag_changes(self.env, None, start, stop):
382                tagged_resource = Resource(tagspace, name)
383                if 'TAGS_VIEW' in req.perm(tagged_resource):
384                    yield ('tags', time, author,
385                           (tagged_resource, old_tags, new_tags), self)
386
387    def render_timeline_event(self, context, field, event):
388        resource = event[3][0]
389        if field == 'url':
390            return get_resource_url(self.env, resource, context.href)
391        elif field == 'title':
392            name = builder.em(get_resource_name(self.env, resource))
393            return tag_("Tag change on %(resource)s", resource=name)
394        elif field == 'description':
395            return render_tag_changes(event[3][1], event[3][2])
396
397
398def render_tag_changes(old_tags, new_tags):
399        old_tags = split_into_tags(old_tags or '')
400        new_tags = split_into_tags(new_tags or '')
401        added = sorted(new_tags - old_tags)
402        added = added and \
403                tagn_("%(tags)s added", "%(tags)s added",
404                      len(added), tags=builder.em(', '.join(added)))
405        removed = sorted(old_tags - new_tags)
406        removed = removed and \
407                  tagn_("%(tags)s removed", "%(tags)s removed",
408                        len(removed), tags=builder.em(', '.join(removed)))
409        # TRANSLATOR: How to delimit added and removed tags.
410        delim = added and removed and _("; ")
411        return builder(builder.strong(_("Tags")), ' ', added,
412                       delim, removed)
Note: See TracBrowser for help on using the repository browser.