source: tagsplugin/trunk/tractags/web_ui.py

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

TagsPlugin: fixed a string/bytes issue. Added more classifiers to setup.py.

Closes #13994

File size: 17.7 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# Copyright (C) 2021 Cinc
9#
10# This software is licensed as described in the file COPYING, which
11# you should have received as part of this distribution.
12#
13
14import re
15
16from trac.config import BoolOption, ListOption, Option
17from trac.core import implements
18from trac.resource import (
19    Resource, ResourceSystem, get_resource_name, get_resource_url)
20from trac.test import MockRequest
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
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 JTransformer, 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'] = 0
250        else:
251            macro = 'TagCloud'
252            mincount = as_int(req.args.get('mincount', 0),
253                              self.cloud_mincount)
254            args = mincount and "mincount=%s" % mincount or None
255            data['mincount'] = mincount
256
257        # When using the given req the page isn't rendered properly. The call
258        # to expand_macro() leads to Chrome().render_template(req, ...).
259        # The function render_template() breaks something in the request handling.
260        # That used to work with Genshi.
261        #
262        # With this mocked req everything is just fine.
263        mock_req = MockRequest(self.env, path_info=req.path_info,
264                           authname=req.authname, script_name=req.href())
265        formatter = Formatter(self.env, web_context(mock_req, Resource('tag')))
266        self.env.log.debug("%s macro arguments: %s", macro,
267                           args and args or '(none)')
268        macros = TagWikiMacros(self.env)
269        try:
270            # Query string without realm throws 'NotImplementedError'.
271            data['tag_body'] = checked_realms and \
272                               macros.expand_macro(formatter, macro, args,
273                                                   realms=checked_realms) \
274                               or ''
275            data['tag_body'] = Markup(to_unicode(data['tag_body']))
276        except InvalidQuery as e:
277            data['tag_query_error'] = to_unicode(e)
278            data['tag_body'] = macros.expand_macro(formatter, 'TagCloud', '')
279
280        data['realm_args'] = realm_args
281        add_stylesheet(req, 'tags/css/tractags.css')
282        return 'tag_view.html', data, {'domain': 'tractags'}
283
284
285class TagTimelineEventFilter(TagTemplateProvider):
286    """[opt] Filters timeline events by tags associated with listed resources
287    mentioned in the event.
288    """
289
290    implements(IRequestFilter)
291
292    key = 'tag_query'
293
294    # IRequestFilter methods
295
296    def pre_process_request(self, req, handler):
297        return handler
298
299    def post_process_request(self, req, template, data, content_type):
300        if data and req.path_info == '/timeline' and \
301                'TAGS_VIEW' in req.perm(Resource('tags')):
302
303            def realm_handler(_, node, context):
304                return query.match(node, [context.realm])
305
306            query_str = req.args.getfirst(self.key)
307            if query_str is None and req.args.get('format') != 'rss':
308                query_str = req.session.get('timeline.%s' % self.key)
309            else:
310                query_str = (query_str or '').strip()
311                # Record tag query expression between visits.
312                req.session['timeline.%s' % self.key] = query_str
313
314            if data.get('events') and query_str:
315                tag_system = TagSystem(self.env)
316                try:
317                    query = Query(query_str,
318                                  attribute_handlers={'realm': realm_handler})
319                except InvalidQuery as e:
320                    add_warning(req, _("Tag query syntax error: %s" % e))
321                else:
322                    all_realms = tag_system.get_taggable_realms(req.perm)
323                    query_realms = set()
324                    for m in REALM_RE.finditer(query.as_string()):
325                        query_realms.add(m.group(1))
326                    # Don't care about resources from non-taggable realms.
327                    realms = not query_realms and all_realms or \
328                             query_realms.intersection(all_realms)
329                    events = []
330                    self.log.info("Filtering timeline events by tags '%s'",
331                                   query_str)
332                    for event in data['events']:
333                        resource = resource_from_event(event)
334                        if resource and resource.realm in realms:
335                            # Shortcut view permission checks here.
336                            tags = tag_system.get_tags(None, resource)
337                            if query(tags, context=resource):
338                                events.append(event)
339                    # Overwrite with filtered list.
340                    data['events'] = events
341            if query_str:
342                # Add current value for next form rendering.
343                data[self.key] = query_str
344            elif self.key in req.session:
345                del req.session[self.key]
346
347            filter_lst = []
348            # xpath = '//form[@id="prefs"]/div[1]'
349            xform = JTransformer('form#prefs > div:nth-of-type(1)')
350            insert = builder(Markup('<br />'), tag_("matching tags "),
351                             builder.input(type='text', name=self.key,
352                                           value=data.get(self.key)))
353            filter_lst.append(xform.append(Markup(insert)))
354            add_script_data(req, {'tags_filter': filter_lst})
355            add_script(req, 'tags/js/tags_jtransform.js')
356
357        return template, data, content_type
358
359
360def resource_from_event(event):
361    resource = None
362    event_data = event['data']
363    if not isinstance(event_data, (tuple, list)):
364        event_data = [event_data]
365    for entry in event_data:
366        try:
367            entry.realm
368        except AttributeError:
369            pass
370        else:
371            resource = entry
372            break
373    return resource
374
375
376class TagTimelineEventProvider(TagTemplateProvider):
377    """[opt] Delivers recorded tag change events to timeline view."""
378
379    implements(ITimelineEventProvider)
380
381    # ITimelineEventProvider methods
382
383    def get_timeline_filters(self, req):
384        if 'TAGS_VIEW' in req.perm('tags'):
385            yield ('tags', _("Tag changes"))
386
387    def get_timeline_events(self, req, start, stop, filters):
388        if 'tags' in filters:
389            tags_realm = Resource('tags')
390            if 'TAGS_VIEW' not in req.perm(tags_realm):
391                return
392            add_stylesheet(req, 'tags/css/tractags.css')
393            for time, author, tagspace, name, old_tags, new_tags in \
394                    tag_changes(self.env, None, start, stop):
395                tagged_resource = Resource(tagspace, name)
396                if 'TAGS_VIEW' in req.perm(tagged_resource):
397                    yield ('tags', time, author,
398                           (tagged_resource, old_tags, new_tags), self)
399
400    def render_timeline_event(self, context, field, event):
401        resource = event[3][0]
402        if field == 'url':
403            return get_resource_url(self.env, resource, context.href)
404        elif field == 'title':
405            name = builder.em(get_resource_name(self.env, resource))
406            return tag_("Tag change on %(resource)s", resource=name)
407        elif field == 'description':
408            return render_tag_changes(event[3][1], event[3][2])
409
410
411def render_tag_changes(old_tags, new_tags):
412        old_tags = split_into_tags(old_tags or '')
413        new_tags = split_into_tags(new_tags or '')
414        added = sorted(new_tags - old_tags)
415        added = added and \
416                tagn_("%(tags)s added", "%(tags)s added",
417                      len(added), tags=builder.em(', '.join(added)))
418        removed = sorted(old_tags - new_tags)
419        removed = removed and \
420                  tagn_("%(tags)s removed", "%(tags)s removed",
421                        len(removed), tags=builder.em(', '.join(removed)))
422        # TRANSLATOR: How to delimit added and removed tags.
423        delim = added and removed and _("; ")
424        return builder(builder.strong(_("Tags")), ' ', added,
425                       delim, removed)
Note: See TracBrowser for help on using the repository browser.