| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2006 Alec Thomas <alec@swapoff.org> |
|---|
| 4 | # Copyright (C) 2014 Jun Omae <jun66j5@gmail.com> |
|---|
| 5 | # Copyright (C) 2011-2014 Steffen Hoffmann <hoff.st@web.de> |
|---|
| 6 | # Copyright (C) 2021 Cinc |
|---|
| 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 | |
|---|
| 12 | import collections |
|---|
| 13 | import re |
|---|
| 14 | |
|---|
| 15 | from trac.config import BoolOption |
|---|
| 16 | from trac.core import Component, implements |
|---|
| 17 | from trac.resource import Resource, render_resource_link, get_resource_url |
|---|
| 18 | from trac.util.datefmt import to_utimestamp |
|---|
| 19 | from trac.util.html import Fragment, Markup, tag |
|---|
| 20 | from trac.util.text import to_unicode |
|---|
| 21 | from trac.util.translation import tag_ |
|---|
| 22 | from trac.web.api import IRequestFilter |
|---|
| 23 | from trac.web.chrome import add_script, add_script_data, add_stylesheet, web_context |
|---|
| 24 | from trac.wiki.api import IWikiChangeListener, IWikiPageManipulator |
|---|
| 25 | from trac.wiki.api import IWikiSyntaxProvider |
|---|
| 26 | from trac.wiki.formatter import format_to_oneliner |
|---|
| 27 | from trac.wiki.model import WikiPage |
|---|
| 28 | from trac.wiki.parser import WikiParser |
|---|
| 29 | from trac.wiki.web_ui import WikiModule |
|---|
| 30 | |
|---|
| 31 | from tractags.api import DefaultTagProvider, TagSystem, _, requests |
|---|
| 32 | from tractags.macros import TagTemplateProvider |
|---|
| 33 | from tractags.model import delete_tags, tag_changes |
|---|
| 34 | from tractags.web_ui import render_tag_changes |
|---|
| 35 | from tractags.util import JTransformer, MockReq, query_realms, split_into_tags |
|---|
| 36 | |
|---|
| 37 | |
|---|
| 38 | try: |
|---|
| 39 | unicode |
|---|
| 40 | except NameError: |
|---|
| 41 | # Python 3 |
|---|
| 42 | def next_iter(i): |
|---|
| 43 | return next(i) |
|---|
| 44 | else: |
|---|
| 45 | def next_iter(i): |
|---|
| 46 | return i.next() |
|---|
| 47 | |
|---|
| 48 | class WikiTagProvider(DefaultTagProvider): |
|---|
| 49 | """[main] Tag provider for Trac wiki.""" |
|---|
| 50 | |
|---|
| 51 | realm = 'wiki' |
|---|
| 52 | |
|---|
| 53 | exclude_templates = BoolOption('tags', 'query_exclude_wiki_templates', |
|---|
| 54 | default=True, |
|---|
| 55 | doc="Whether tagged wiki page templates should be queried.") |
|---|
| 56 | |
|---|
| 57 | first_head = re.compile('=\s+([^=\n]*)={0,1}') |
|---|
| 58 | |
|---|
| 59 | def check_permission(self, perm, action): |
|---|
| 60 | map = {'view': 'WIKI_VIEW', 'modify': 'WIKI_MODIFY'} |
|---|
| 61 | return super(WikiTagProvider, self).check_permission(perm, action) \ |
|---|
| 62 | and map[action] in perm |
|---|
| 63 | |
|---|
| 64 | def get_tagged_resources(self, req, tags=None, filter=None): |
|---|
| 65 | if self.exclude_templates: |
|---|
| 66 | with self.env.db_query as db: |
|---|
| 67 | like_templates = ''.join( |
|---|
| 68 | ["'", db.like_escape(WikiModule.PAGE_TEMPLATES_PREFIX), |
|---|
| 69 | "%%'"]) |
|---|
| 70 | filter = (' '.join(['name NOT', db.like() % like_templates]),) |
|---|
| 71 | return super(WikiTagProvider, self).get_tagged_resources(req, tags, |
|---|
| 72 | filter) |
|---|
| 73 | |
|---|
| 74 | def get_all_tags(self, req, filter=None): |
|---|
| 75 | if not self.check_permission(req.perm, 'view'): |
|---|
| 76 | return collections.Counter() |
|---|
| 77 | if self.exclude_templates: |
|---|
| 78 | with self.env.db_transaction as db: |
|---|
| 79 | like_templates = ''.join( |
|---|
| 80 | ["'", db.like_escape(WikiModule.PAGE_TEMPLATES_PREFIX), |
|---|
| 81 | "%%'"]) |
|---|
| 82 | filter = (' '.join(['name NOT', db.like() % like_templates]),) |
|---|
| 83 | return super(WikiTagProvider, self).get_all_tags(req, filter) |
|---|
| 84 | |
|---|
| 85 | def describe_tagged_resource(self, req, resource): |
|---|
| 86 | if not self.check_permission(req.perm(resource), 'view'): |
|---|
| 87 | return '' |
|---|
| 88 | page = WikiPage(self.env, resource.id) |
|---|
| 89 | if page.exists: |
|---|
| 90 | ret = self.first_head.search(page.text) |
|---|
| 91 | return ret and ret.group(1) or '' |
|---|
| 92 | return '' |
|---|
| 93 | |
|---|
| 94 | |
|---|
| 95 | class WikiTagInterface(TagTemplateProvider): |
|---|
| 96 | """[main] Implements the user interface for tagging Wiki pages.""" |
|---|
| 97 | |
|---|
| 98 | implements(IRequestFilter, IWikiChangeListener, IWikiPageManipulator) |
|---|
| 99 | |
|---|
| 100 | def __init__(self): |
|---|
| 101 | self.tag_system = TagSystem(self.env) |
|---|
| 102 | |
|---|
| 103 | # IRequestFilter methods |
|---|
| 104 | |
|---|
| 105 | def pre_process_request(self, req, handler): |
|---|
| 106 | return handler |
|---|
| 107 | |
|---|
| 108 | def post_process_request(self, req, template, data, content_type): |
|---|
| 109 | if template is not None: |
|---|
| 110 | if req.method == 'GET' and req.path_info.startswith('/wiki/'): |
|---|
| 111 | if req.args.get('action') == 'edit' and \ |
|---|
| 112 | req.args.get('template') and 'tags' not in req.args: |
|---|
| 113 | self._post_process_request_edit(req) |
|---|
| 114 | if req.args.get('action') == 'history' and \ |
|---|
| 115 | data and 'history' in data: |
|---|
| 116 | self._post_process_request_history(req, data) |
|---|
| 117 | elif req.method == 'POST' and \ |
|---|
| 118 | req.path_info.startswith('/wiki/') and \ |
|---|
| 119 | 'save' in req.args: |
|---|
| 120 | requests.reset() |
|---|
| 121 | |
|---|
| 122 | # Insert the tags information and controls into |
|---|
| 123 | # the wiki page |
|---|
| 124 | if data and 'page' in data: |
|---|
| 125 | if template == 'wiki_view.html' and \ |
|---|
| 126 | 'TAGS_VIEW' in req.perm(data['page'].resource): |
|---|
| 127 | self._post_process_request_wiki_view(req) |
|---|
| 128 | elif template == 'wiki_edit.html' and \ |
|---|
| 129 | 'TAGS_MODIFY' in req.perm(data['page'].resource): |
|---|
| 130 | self._post_process_request_wiki_edit(req) |
|---|
| 131 | elif template == 'history_view.html' and \ |
|---|
| 132 | 'TAGS_VIEW' in req.perm(data['page'].resource): |
|---|
| 133 | self._post_process_request_wiki_history(req) |
|---|
| 134 | |
|---|
| 135 | return template, data, content_type |
|---|
| 136 | |
|---|
| 137 | # IWikiPageManipulator methods |
|---|
| 138 | def prepare_wiki_page(self, req, page, fields): |
|---|
| 139 | pass |
|---|
| 140 | |
|---|
| 141 | def validate_wiki_page(self, req, page): |
|---|
| 142 | # If we're saving the wiki page, and can modify tags, do so. |
|---|
| 143 | if req and 'TAGS_MODIFY' in req.perm(page.resource) \ |
|---|
| 144 | and req.path_info.startswith('/wiki') and 'save' in req.args: |
|---|
| 145 | page_modified = req.args.get('text') != page.old_text or \ |
|---|
| 146 | page.readonly != int('readonly' in req.args) |
|---|
| 147 | if page_modified: |
|---|
| 148 | requests.set(req) |
|---|
| 149 | req.add_redirect_listener(self._redirect_listener) |
|---|
| 150 | elif page.version > 0: |
|---|
| 151 | # If the page hasn't been otherwise modified, save tags and |
|---|
| 152 | # redirect to avoid the "page has not been modified" warning. |
|---|
| 153 | if self._update_tags(req, page): |
|---|
| 154 | req.redirect(get_resource_url(self.env, page.resource, |
|---|
| 155 | req.href, version=None)) |
|---|
| 156 | return [] |
|---|
| 157 | |
|---|
| 158 | # IWikiChangeListener methods |
|---|
| 159 | def wiki_page_added(self, page): |
|---|
| 160 | req = requests.get() |
|---|
| 161 | if req: |
|---|
| 162 | self._update_tags(req, page, page.time) |
|---|
| 163 | |
|---|
| 164 | def wiki_page_changed(self, page, version, t, comment, author): |
|---|
| 165 | req = requests.get() |
|---|
| 166 | if req: |
|---|
| 167 | self._update_tags(req, page, page.time) |
|---|
| 168 | |
|---|
| 169 | def wiki_page_renamed(self, page, old_name): |
|---|
| 170 | """Called when a page has been renamed (since Trac 0.12).""" |
|---|
| 171 | self.log.debug("Moving wiki page tags from %s to %s", |
|---|
| 172 | old_name, page.name) |
|---|
| 173 | req = MockReq() |
|---|
| 174 | self.tag_system.reparent_tags(req, Resource('wiki', page.name), |
|---|
| 175 | old_name) |
|---|
| 176 | |
|---|
| 177 | def wiki_page_deleted(self, page): |
|---|
| 178 | # Page gone, so remove all records on it. |
|---|
| 179 | delete_tags(self.env, page.resource, purge=True) |
|---|
| 180 | |
|---|
| 181 | def wiki_page_version_deleted(self, page): |
|---|
| 182 | pass |
|---|
| 183 | |
|---|
| 184 | # Internal methods |
|---|
| 185 | def _page_tags(self, req): |
|---|
| 186 | pagename = req.args.get('page', 'WikiStart') |
|---|
| 187 | # 'version' and 'tags_version' are part of the url |
|---|
| 188 | # whrn coming from the history |
|---|
| 189 | version = req.args.get('version') |
|---|
| 190 | tags_version = req.args.get('tags_version') |
|---|
| 191 | |
|---|
| 192 | page = WikiPage(self.env, pagename, version=version) |
|---|
| 193 | resource = page.resource |
|---|
| 194 | if version and not tags_version: |
|---|
| 195 | tags_version = page.time |
|---|
| 196 | tags = sorted(self.tag_system.get_tags(req, resource, |
|---|
| 197 | when=tags_version)) |
|---|
| 198 | return tags |
|---|
| 199 | |
|---|
| 200 | def _redirect_listener(self, req, url, permanent): |
|---|
| 201 | requests.reset() |
|---|
| 202 | |
|---|
| 203 | def _post_process_request_edit(self, req): |
|---|
| 204 | # Retrieve template resource to be queried for tags. |
|---|
| 205 | template_pagename = ''.join([WikiModule.PAGE_TEMPLATES_PREFIX, |
|---|
| 206 | req.args.get('template')]) |
|---|
| 207 | template_page = WikiPage(self.env, template_pagename) |
|---|
| 208 | if template_page.exists and \ |
|---|
| 209 | 'TAGS_VIEW' in req.perm(template_page.resource): |
|---|
| 210 | tags = sorted(self.tag_system.get_tags(req, |
|---|
| 211 | template_page.resource)) |
|---|
| 212 | # Prepare tags as content for the editor field. |
|---|
| 213 | tags_str = ' '.join(tags) |
|---|
| 214 | self.env.log.debug("Tags retrieved from template: '%s'", |
|---|
| 215 | to_unicode(tags_str).encode('utf-8')) |
|---|
| 216 | # DEVEL: More arguments need to be propagated here? |
|---|
| 217 | req.redirect(req.href(req.path_info, |
|---|
| 218 | action='edit', tags=tags_str, |
|---|
| 219 | template=req.args.get('template'))) |
|---|
| 220 | |
|---|
| 221 | def _post_process_request_history(self, req, data): |
|---|
| 222 | history = [] |
|---|
| 223 | page_histories = data.get('history', []) |
|---|
| 224 | resource = data['resource'] |
|---|
| 225 | tags_histories = tag_changes(self.env, resource) |
|---|
| 226 | |
|---|
| 227 | for page_history in page_histories: |
|---|
| 228 | while tags_histories and \ |
|---|
| 229 | tags_histories[0][0] >= page_history['date']: |
|---|
| 230 | tags_history = tags_histories.pop(0) |
|---|
| 231 | date = tags_history[0] |
|---|
| 232 | author = tags_history[1] |
|---|
| 233 | comment = render_tag_changes(tags_history[2], tags_history[3]) |
|---|
| 234 | url = req.href(resource.realm, resource.id, |
|---|
| 235 | version=page_history['version'], |
|---|
| 236 | tags_version=to_utimestamp(date)) |
|---|
| 237 | history.append({'version': '*', 'url': url, 'date': date, |
|---|
| 238 | 'author': author, 'comment': comment}) |
|---|
| 239 | history.append(page_history) |
|---|
| 240 | |
|---|
| 241 | data.update(dict(history=history, |
|---|
| 242 | wiki_to_oneliner=self._wiki_to_oneliner)) |
|---|
| 243 | |
|---|
| 244 | def _post_process_request_wiki_view(self, req): |
|---|
| 245 | add_stylesheet(req, 'tags/css/tractags.css') |
|---|
| 246 | tags = self._page_tags(req) |
|---|
| 247 | if not tags: |
|---|
| 248 | return |
|---|
| 249 | li = [] |
|---|
| 250 | for t in tags: |
|---|
| 251 | resource = Resource('tag', t) |
|---|
| 252 | anchor = render_resource_link(self.env, |
|---|
| 253 | web_context(req, resource), |
|---|
| 254 | resource) |
|---|
| 255 | anchor = anchor(rel='tag') |
|---|
| 256 | li.append(tag.li(anchor, ' ')) |
|---|
| 257 | |
|---|
| 258 | # TRANSLATOR: Header label text for tag list at wiki page bottom. |
|---|
| 259 | insert = tag.ul(class_='tags')(tag.li(_("Tags"), class_='header'), li) |
|---|
| 260 | |
|---|
| 261 | filter_lst = [] |
|---|
| 262 | # xpath = //div[contains(@class,"wikipage")] |
|---|
| 263 | xform = JTransformer('div[class*=wikipage]') |
|---|
| 264 | filter_lst.append(xform.after(Markup(insert))) |
|---|
| 265 | |
|---|
| 266 | self._add_jtransform(req, filter_lst) |
|---|
| 267 | return |
|---|
| 268 | |
|---|
| 269 | def _update_tags(self, req, page, when=None): |
|---|
| 270 | newtags = split_into_tags(req.args.get('tags', '')) |
|---|
| 271 | oldtags = self.tag_system.get_tags(req, page.resource) |
|---|
| 272 | |
|---|
| 273 | if oldtags != newtags: |
|---|
| 274 | self.tag_system.set_tags(req, page.resource, newtags, when=when) |
|---|
| 275 | return True |
|---|
| 276 | return False |
|---|
| 277 | |
|---|
| 278 | def _add_jtransform(self, req, filter_lst): |
|---|
| 279 | add_script_data(req, {'tags_filter': filter_lst}) |
|---|
| 280 | add_script(req, 'tags/js/tags_jtransform.js') |
|---|
| 281 | |
|---|
| 282 | def _post_process_request_wiki_edit(self, req): |
|---|
| 283 | tags = ' '.join(self._page_tags(req)) |
|---|
| 284 | # TRANSLATOR: Label text for link to '/tags'. |
|---|
| 285 | link = tag.a(_("view all tags"), href=req.href.tags()) |
|---|
| 286 | # TRANSLATOR: ... (view all tags) |
|---|
| 287 | insert = tag( |
|---|
| 288 | tag_("Tag under: (%(tags_link)s)", tags_link=link), |
|---|
| 289 | tag.br(), |
|---|
| 290 | tag.input(id='tags', type='text', name='tags', size='50', |
|---|
| 291 | value=req.args.get('tags', tags)) |
|---|
| 292 | ) |
|---|
| 293 | insert = tag.div(tag.label(insert), class_='field') |
|---|
| 294 | filter_lst = [] |
|---|
| 295 | # xpath = //div[@id="changeinfo1"] |
|---|
| 296 | xform = JTransformer('div#changeinfo1') |
|---|
| 297 | filter_lst.append(xform.append(Markup(insert))) |
|---|
| 298 | self._add_jtransform(req, filter_lst) |
|---|
| 299 | |
|---|
| 300 | def _post_process_request_wiki_history(self, req): |
|---|
| 301 | filter_lst = [] |
|---|
| 302 | # xpath = '//input[@type="radio" and @value="*"]' |
|---|
| 303 | xform = JTransformer('input[type="radio"][value="*"]') |
|---|
| 304 | filter_lst.append(xform.remove()) |
|---|
| 305 | self._add_jtransform(req, filter_lst) |
|---|
| 306 | |
|---|
| 307 | def _wiki_to_oneliner(self, context, wiki, shorten=None): |
|---|
| 308 | if isinstance(wiki, Fragment): |
|---|
| 309 | return wiki |
|---|
| 310 | return format_to_oneliner(self.env, context, wiki, shorten=shorten) |
|---|
| 311 | |
|---|
| 312 | |
|---|
| 313 | class TagWikiSyntaxProvider(Component): |
|---|
| 314 | """[opt] Provides tag:<expr> links. |
|---|
| 315 | |
|---|
| 316 | This extends TracLinks via WikiFormatting to point at tag queries or |
|---|
| 317 | at specific tags. |
|---|
| 318 | """ |
|---|
| 319 | |
|---|
| 320 | implements(IWikiSyntaxProvider) |
|---|
| 321 | |
|---|
| 322 | def __init__(self): |
|---|
| 323 | self.tag_system = TagSystem(self.env) |
|---|
| 324 | |
|---|
| 325 | # IWikiSyntaxProvider methods |
|---|
| 326 | |
|---|
| 327 | def get_wiki_syntax(self): |
|---|
| 328 | """Additional syntax for quoted tags or tag expression.""" |
|---|
| 329 | tag_expr = ( |
|---|
| 330 | r"(%s)" % (WikiParser.QUOTED_STRING) |
|---|
| 331 | ) |
|---|
| 332 | |
|---|
| 333 | # Simple (tag|tagged):link syntax |
|---|
| 334 | yield (r'''(?P<qualifier>tag(?:ged)?):(?P<tag_expr>%s)''' % tag_expr, |
|---|
| 335 | lambda f, ns, match: self._format_tagged( |
|---|
| 336 | f, match.group('qualifier'), match.group('tag_expr'), |
|---|
| 337 | '%s:%s' % (match.group('qualifier'), |
|---|
| 338 | match.group('tag_expr')))) |
|---|
| 339 | |
|---|
| 340 | # [(tag|tagged):link with label] |
|---|
| 341 | yield (r'''\[tag(?:ged)?:''' |
|---|
| 342 | r'''(?P<ltag_expr>%s)\s*(?P<tag_title>[^\]]+)?\]''' % tag_expr, |
|---|
| 343 | lambda f, ns, match: self._format_tagged(f, 'tag', |
|---|
| 344 | match.group('ltag_expr'), |
|---|
| 345 | match.group('tag_title'))) |
|---|
| 346 | |
|---|
| 347 | def get_link_resolvers(self): |
|---|
| 348 | return [('tag', self._format_tagged), |
|---|
| 349 | ('tagged', self._format_tagged)] |
|---|
| 350 | |
|---|
| 351 | def _format_tagged(self, formatter, ns, target, label, fullmatch=None): |
|---|
| 352 | """Tag and tag query expression link formatter.""" |
|---|
| 353 | |
|---|
| 354 | def unquote(text): |
|---|
| 355 | """Strip all matching pairs of outer quotes from string.""" |
|---|
| 356 | while re.match(WikiParser.QUOTED_STRING, text): |
|---|
| 357 | # Remove outer whitespace after stripped quotation too. |
|---|
| 358 | text = text[1:-1].strip() |
|---|
| 359 | return text |
|---|
| 360 | |
|---|
| 361 | label = label and unquote(label.strip()) or '' |
|---|
| 362 | target = unquote(target.strip()) |
|---|
| 363 | |
|---|
| 364 | query = target |
|---|
| 365 | # Pop realms from query expression. |
|---|
| 366 | all_realms = self.tag_system.get_taggable_realms(formatter.perm) |
|---|
| 367 | realms = query_realms(target, all_realms) |
|---|
| 368 | if realms: |
|---|
| 369 | kwargs = dict((realm, 'on') for realm in realms) |
|---|
| 370 | target = re.sub('(^|\W)realm:\S+(\W|$)', ' ', target).strip() |
|---|
| 371 | else: |
|---|
| 372 | kwargs = {} |
|---|
| 373 | |
|---|
| 374 | tag_res = Resource('tag', target) |
|---|
| 375 | if 'TAGS_VIEW' not in formatter.perm(tag_res): |
|---|
| 376 | return tag.span(label, class_='forbidden tags', |
|---|
| 377 | title=_("no permission to view tags")) |
|---|
| 378 | |
|---|
| 379 | context = formatter.context |
|---|
| 380 | href = self.tag_system.get_resource_url(tag_res, context.href, kwargs) |
|---|
| 381 | if all_realms and ( |
|---|
| 382 | target in self.tag_system.get_all_tags(formatter.req) or |
|---|
| 383 | not iter_is_empty(self.tag_system.query(formatter.req, |
|---|
| 384 | query))): |
|---|
| 385 | # At least one tag provider is available and tag exists or |
|---|
| 386 | # tags query yields at least one match. |
|---|
| 387 | if label: |
|---|
| 388 | return tag.a(label, href=href) |
|---|
| 389 | return render_resource_link(self.env, context, tag_res) |
|---|
| 390 | |
|---|
| 391 | return tag.a(label+'?', href=href, class_='missing tags', |
|---|
| 392 | rel='nofollow') |
|---|
| 393 | |
|---|
| 394 | |
|---|
| 395 | def iter_is_empty(i): |
|---|
| 396 | """Test for empty iterator without consuming more than first element.""" |
|---|
| 397 | try: |
|---|
| 398 | next_iter(i) |
|---|
| 399 | except StopIteration: |
|---|
| 400 | return True |
|---|
| 401 | return False |
|---|