Edgewall Software

root/trunk/trac/wiki/api.py

Revision 7685, 14.0 KB (checked in by rblank, 8 days ago)

0.12dev: Make wiki links in wiki pages scoped. For example, a link to MyPage placed on MyProject/People/Joe will now resolve to MyProject/People/MyPage if it exists, otherwise to MyProject/MyPage if it exists, otherwise to MyPage if it exists.

If none of the pages higher in the hierarchy exists, it will link to MyProject/People/MyPage, where links to pages with the same name higher in the hierarchy will also be provided.

Closes #4507.

  • Property svn:eol-style set to native
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2008 Edgewall Software
4# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
5# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
6# All rights reserved.
7#
8# This software is licensed as described in the file COPYING, which
9# you should have received as part of this distribution. The terms
10# are also available at http://trac.edgewall.org/wiki/TracLicense.
11#
12# This software consists of voluntary contributions made by many
13# individuals. For the exact contribution history, see the revision
14# history and logs, available at http://trac.edgewall.org/log/.
15#
16# Author: Jonas Borgström <jonas@edgewall.com>
17#         Christopher Lenz <cmlenz@gmx.de>
18
19try:
20    import threading
21except ImportError:
22    import dummy_threading as threading
23import time
24import urllib
25import re
26from StringIO import StringIO
27
28from genshi.builder import tag
29
30from trac.config import BoolOption
31from trac.core import *
32from trac.resource import IResourceManager
33from trac.util.html import html
34from trac.util.translation import _
35from trac.wiki.parser import WikiParser
36
37
38class IWikiChangeListener(Interface):
39    """Extension point interface for components that should get notified about
40    the creation, deletion and modification of wiki pages.
41    """
42
43    def wiki_page_added(page):
44        """Called whenever a new Wiki page is added."""
45
46    def wiki_page_changed(page, version, t, comment, author, ipnr):
47        """Called when a page has been modified."""
48
49    def wiki_page_deleted(page):
50        """Called when a page has been deleted."""
51
52    def wiki_page_version_deleted(page):
53        """Called when a version of a page has been deleted."""
54
55
56class IWikiPageManipulator(Interface):
57    """Extension point interface for components that need to do specific
58    pre and post processing of wiki page changes.
59   
60    Unlike change listeners, a manipulator can reject changes being committed
61    to the database.
62    """
63
64    def prepare_wiki_page(req, page, fields):
65        """Not currently called, but should be provided for future
66        compatibility."""
67
68    def validate_wiki_page(req, page):
69        """Validate a wiki page after it's been populated from user input.
70       
71        Must return a list of `(field, message)` tuples, one for each problem
72        detected. `field` can be `None` to indicate an overall problem with the
73        page. Therefore, a return value of `[]` means everything is OK."""
74
75
76class IWikiMacroProvider(Interface):
77    """Extension point interface for components that provide Wiki macros."""
78
79    def get_macros():
80        """Return an iterable that provides the names of the provided macros."""
81
82    def get_macro_description(name):
83        """Return a plain text description of the macro with the specified name.
84        """
85
86    def render_macro(req, name, content):
87        """Return the HTML output of the macro (deprecated)"""
88
89    def expand_macro(formatter, name, content):
90        """Called by the formatter when rendering the parsed wiki text.
91
92        (since 0.11)
93        """
94
95
96class IWikiSyntaxProvider(Interface):
97 
98    def get_wiki_syntax():
99        """Return an iterable that provides additional wiki syntax.
100
101        Additional wiki syntax correspond to a pair of (regexp, cb),
102        the `regexp` for the additional syntax and the callback `cb`
103        which will be called if there's a match.
104        That function is of the form cb(formatter, ns, match).
105        """
106 
107    def get_link_resolvers():
108        """Return an iterable over (namespace, formatter) tuples.
109
110        Each formatter should be a function of the form
111        fmt(formatter, ns, target, label), and should
112        return some HTML fragment.
113        The `label` is already HTML escaped, whereas the `target` is not.
114        """
115
116
117def parse_args(args, strict=True):
118    """Utility for parsing macro "content" and splitting them into arguments.
119
120    The content is split along commas, unless they are escaped with a
121    backquote (like this: \,).
122   
123    :param args: macros arguments, as plain text
124    :param strict: if `True`, only Python-like identifiers will be
125                   recognized as keyword arguments
126
127    Example usage:
128
129    >>> parse_args('')
130    ([], {})
131    >>> parse_args('Some text')
132    (['Some text'], {})
133    >>> parse_args('Some text, mode= 3, some other arg\, with a comma.')
134    (['Some text', ' some other arg, with a comma.'], {'mode': ' 3'})
135    >>> parse_args('milestone=milestone1,status!=closed', strict=False)
136    ([], {'status!': 'closed', 'milestone': 'milestone1'})
137   
138    """   
139    largs, kwargs = [], {}
140    if args:
141        for arg in re.split(r'(?<!\\),', args):
142            arg = arg.replace(r'\,', ',')
143            if strict:
144                m = re.match(r'\s*[a-zA-Z_]\w+=', arg)
145            else:
146                m = re.match(r'\s*[^=]+=', arg)
147            if m:
148                kw = arg[:m.end()-1].strip()
149                if strict:
150                    kw = unicode(kw).encode('utf-8')
151                kwargs[kw] = arg[m.end():]
152            else:
153                largs.append(arg)
154    return largs, kwargs
155
156
157
158class WikiSystem(Component):
159    """Represents the wiki system."""
160
161    implements(IWikiChangeListener, IWikiSyntaxProvider, IResourceManager)
162
163    change_listeners = ExtensionPoint(IWikiChangeListener)
164    macro_providers = ExtensionPoint(IWikiMacroProvider)
165    syntax_providers = ExtensionPoint(IWikiSyntaxProvider)
166
167    INDEX_UPDATE_INTERVAL = 5 # seconds
168
169    ignore_missing_pages = BoolOption('wiki', 'ignore_missing_pages', 'false',
170        """Enable/disable highlighting CamelCase links to missing pages
171        (''since 0.9'').""")
172
173    split_page_names = BoolOption('wiki', 'split_page_names', 'false',
174        """Enable/disable splitting the WikiPageNames with space characters
175        (''since 0.10'').""")
176
177    render_unsafe_content = BoolOption('wiki', 'render_unsafe_content', 'false',
178        """Enable/disable the use of unsafe HTML tags such as `<script>` or
179        `<embed>` with the HTML [wiki:WikiProcessors WikiProcessor]
180        (''since 0.10.4'').
181
182        For public sites where anonymous users can edit the wiki it is
183        recommended to leave this option disabled (which is the default).""")
184
185    def __init__(self):
186        self._index = None
187        self._last_index_update = 0
188        self._index_lock = threading.RLock()
189
190    def _update_index(self):
191        self._index_lock.acquire()
192        try:
193            now = time.time()
194            if now > self._last_index_update + WikiSystem.INDEX_UPDATE_INTERVAL:
195                self.log.debug('Updating wiki page index')
196                db = self.env.get_db_cnx()
197                cursor = db.cursor()
198                cursor.execute("SELECT DISTINCT name FROM wiki")
199                self._index = {}
200                for (name,) in cursor:
201                    self._index[name] = True
202                self._last_index_update = now
203        finally:
204            self._index_lock.release()
205
206    # Public API
207
208    def get_pages(self, prefix=None):
209        """Iterate over the names of existing Wiki pages.
210
211        If the `prefix` parameter is given, only names that start with that
212        prefix are included.
213        """
214        self._update_index()
215        # Note: use of keys() is intentional since iterkeys() is prone to
216        # errors with concurrent modification
217        for page in self._index.keys():
218            if not prefix or page.startswith(prefix):
219                yield page
220
221    def has_page(self, pagename):
222        """Whether a page with the specified name exists."""
223        self._update_index()
224        return self._index.has_key(pagename.rstrip('/'))
225
226    # IWikiChangeListener methods
227
228    def wiki_page_added(self, page):
229        if not self.has_page(page.name):
230            self.log.debug('Adding page %s to index' % page.name)
231            self._index[page.name] = True
232
233    def wiki_page_changed(self, page, version, t, comment, author, ipnr):
234        pass
235
236    def wiki_page_deleted(self, page):
237        if self.has_page(page.name):
238            self.log.debug('Removing page %s from index' % page.name)
239            del self._index[page.name]
240
241    def wiki_page_version_deleted(self, page):
242        pass
243
244    # IWikiSyntaxProvider methods
245
246    XML_NAME = r"[\w:](?<!\d)(?:[\w:.-]*[\w-])?"
247    # See http://www.w3.org/TR/REC-xml/#id,
248    # here adapted to exclude terminal "." and ":" characters
249
250    PAGE_SPLIT_RE = re.compile(r"([a-z])([A-Z])(?=[a-z])")
251   
252    def format_page_name(self, page, split=False):
253        if split or self.split_page_names:
254            return self.PAGE_SPLIT_RE.sub(r"\1 \2", page)
255        return page
256
257    def get_wiki_syntax(self):
258        from trac.wiki.formatter import Formatter
259        lower = r'(?<![A-Z0-9_])' # No Upper case when looking behind
260        upper = r'(?<![a-z0-9_])' # No Lower case when looking behind
261        wiki_page_name = (
262            r"\w%s(?:\w%s)+(?:\w%s(?:\w%s)*[\w/]%s)+" % # wiki words
263            (upper, lower, upper, lower, lower) +
264            r"(?:@\d+)?" # optional version
265            r"(?:#%s)?" % self.XML_NAME + # optional fragment id
266            r"(?=:(?:\Z|\s)|[^:a-zA-Z]|\s|\Z)" # what should follow it
267            )
268
269       
270        # Regular WikiPageNames
271        def wikipagename_link(formatter, match, fullmatch):
272            if not _check_unicode_camelcase(match):
273                return match
274            return self._format_link(formatter, 'wiki', match,
275                                     self.format_page_name(match),
276                                     self.ignore_missing_pages)
277       
278        yield (r"!?(?<!/)\b" + # start at a word boundary but not after '/'
279               wiki_page_name, wikipagename_link)
280
281        # [WikiPageNames with label]
282        def wikipagename_with_label_link(formatter, match, fullmatch):
283            page, label = match[1:-1].split(' ', 1)
284            if not _check_unicode_camelcase(page):
285                return label
286            return self._format_link(formatter, 'wiki', page, label.strip(),
287                                     self.ignore_missing_pages)
288        yield (r"!?\[%s\s+(?:%s|[^\]]+)\]" % (wiki_page_name,
289                                              WikiParser.QUOTED_STRING),
290               wikipagename_with_label_link)
291
292        # MoinMoin's ["internal free link"]
293        def internal_free_link(fmt, m, fullmatch): 
294            return self._format_link(fmt, 'wiki', m[2:-2], m[2:-2], False) 
295        yield (r"!?\[(?:%s)\]" % WikiParser.QUOTED_STRING, internal_free_link) 
296
297    def get_link_resolvers(self):
298        def link_resolver(formatter, ns, target, label):
299            return self._format_link(formatter, ns, target, label, False)
300        yield ('wiki', link_resolver)
301
302    def _format_link(self, formatter, ns, pagename, label, ignore_missing):
303        pagename, query, fragment = formatter.split_link(pagename)
304        version = None
305        if '@' in pagename:
306            pagename, version = pagename.split('@', 1)
307        if version and query:
308            query = '&' + query[1:]
309        pagename = pagename.rstrip('/')
310        if formatter.resource and formatter.resource.realm == 'wiki' \
311                              and not pagename.startswith('/'):
312            prefix = formatter.resource.id
313            if '/' in prefix:
314                while '/' in prefix:
315                    prefix = prefix.rsplit('/', 1)[0]
316                    name = prefix + '/' + pagename
317                    if self.has_page(name):
318                        pagename = name
319                        break
320                else:
321                    if not self.has_page(pagename):
322                        pagename = formatter.resource.id.rsplit('/', 1)[0] \
323                                   + '/' + pagename
324        pagename = pagename.lstrip('/')
325        if 'WIKI_VIEW' in formatter.perm('wiki', pagename, version):
326            href = formatter.href.wiki(pagename, version=version) + query \
327                   + fragment
328            if self.has_page(pagename):
329                return tag.a(label, href=href, class_='wiki')
330            else:
331                if ignore_missing:
332                    return label
333                if 'WIKI_CREATE' in formatter.perm('wiki', pagename, version):
334                    return tag.a(label + '?', class_='missing wiki',
335                                 href=href, rel='nofollow')
336                else:
337                    return tag.a(label + '?', class_='missing wiki')
338        elif ignore_missing and not self.has_page(pagename):
339            return label
340        else:
341            return tag.a(label, class_='forbidden wiki',
342                         title=_("no permission to view this wiki page"))
343
344    # IResourceManager methods
345
346    def get_resource_realms(self):
347        yield 'wiki'
348
349    def get_resource_description(self, resource, format, **kwargs):
350        """
351        >>> from trac.test import EnvironmentStub
352        >>> from trac.resource import Resource, get_resource_description
353        >>> env = EnvironmentStub()
354        >>> main = Resource('wiki', 'WikiStart')
355        >>> get_resource_description(env, main)
356        'WikiStart'
357
358        >>> get_resource_description(env, main(version=3))
359        'WikiStart'
360
361        >>> get_resource_description(env, main(version=3), format='summary')
362        'WikiStart'
363
364        >>> env.config['wiki'].set('split_page_names', 'true')
365        >>> get_resource_description(env, main(version=3))
366        'Wiki Start'
367        """
368        return self.format_page_name(resource.id)
369
370
371def _check_unicode_camelcase(pagename):
372    """A camelcase word must have at least 2 humps (well...)
373
374    >>> _check_unicode_camelcase(u"\xc9l\xe9phant")
375    False
376    >>> _check_unicode_camelcase(u"\xc9l\xe9Phant")
377    True
378    >>> _check_unicode_camelcase(u"\xe9l\xe9Phant")
379    False
380    >>> _check_unicode_camelcase(u"\xc9l\xe9PhanT")
381    False
382    """
383    if not pagename[0].isupper():
384        return False
385    pagename = pagename.split('@', 1)[0].split('#', 1)[0]
386    if not pagename[-1].islower():
387        return False
388    humps = 0
389    for i in xrange(1, len(pagename)):
390        if pagename[i-1].isupper():
391            if pagename[i].islower():
392                humps += 1
393            else:
394                return False
395    return humps > 1
Note: See TracBrowser for help on using the browser.