Edgewall Software

source: tags/trac-0.12/trac/wiki/api.py

Last change on this file was 9720, checked in by Christian Boos, 13 years ago

Attachment module checks that the parent resource exists before allowing attachments.

Add IResourceManager.resource_exists(resource) and an implementation for the WikiModule, with doctests.

  • Property svn:eol-style set to native
File size: 17.5 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2009 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
19import re
20
21from genshi.builder import tag
22
23from trac.cache import cached
24from trac.config import BoolOption
25from trac.core import *
26from trac.resource import IResourceManager
27from trac.util.translation import _
28from trac.wiki.parser import WikiParser
29
30
31class IWikiChangeListener(Interface):
32 """Extension point interface for components that should get notified about
33 the creation, deletion and modification of wiki pages.
34 """
35
36 def wiki_page_added(page):
37 """Called whenever a new Wiki page is added."""
38
39 def wiki_page_changed(page, version, t, comment, author, ipnr):
40 """Called when a page has been modified."""
41
42 def wiki_page_deleted(page):
43 """Called when a page has been deleted."""
44
45 def wiki_page_version_deleted(page):
46 """Called when a version of a page has been deleted."""
47
48 def wiki_page_renamed(page, old_name):
49 """Called when a page has been renamed."""
50
51
52class IWikiPageManipulator(Interface):
53 """Extension point interface for components that need to do specific
54 pre and post processing of wiki page changes.
55
56 Unlike change listeners, a manipulator can reject changes being committed
57 to the database.
58 """
59
60 def prepare_wiki_page(req, page, fields):
61 """Validate a wiki page before rendering it.
62
63 `page` is the `WikiPage` being viewed.
64 `fields` is a dictionary which contains the wiki `text` of the page,
65 initially identical to `page.text` but it can eventually be
66 transformed in place before being used as input to the formatter.
67 """
68
69 def validate_wiki_page(req, page):
70 """Validate a wiki page after it's been populated from user input.
71
72 `page` is the `WikiPage` being edited.
73
74 Must return a list of `(field, message)` tuples, one for each problem
75 detected. `field` can be `None` to indicate an overall problem with the
76 page. Therefore, a return value of `[]` means everything is OK.
77 """
78
79
80class IWikiMacroProvider(Interface):
81 """Extension point interface for components that provide Wiki macros
82
83 New Wiki processors can also be added that way. (''since 0.12'')
84 """
85
86 def get_macros():
87 """Return an iterable that provides the names of the provided macros.
88 """
89
90 def get_macro_description(name):
91 """Return a plain text description of the macro with the specified
92 name."""
93
94 def render_macro(req, name, content):
95 """Return the HTML output of the macro (deprecated)"""
96
97 def expand_macro(formatter, name, content, args=None):
98 """Called by the formatter when rendering the parsed wiki text.
99
100 This form is preferred over `render_macro`, as you get the
101 formatter, which knows the current `.context` (and the `.req`,
102 but ideally you shouldn't use it in your macros). (''since 0.11'')
103
104 `name` is the name by which the macro has been called; remember
105 that via `get_macros`, multiple names could be associated to this
106 macros. Note that the macro names are case sensitive.
107
108 `content` is the content of the macro call. When called using macro
109 syntax (`[[Macro(content)]]`), this is the string contained between
110 parentheses, usually containing macro arguments. When called using wiki
111 processor syntax (`{{{!#Macro ...}}}`), it is the content of the
112 processor block, that is, the text starting on the line following the
113 macro name.
114
115 `args` will be a dictionary containing the named parameters which you
116 can specify when calling the macro using the wiki processor syntax:
117 `{{{#!Macro arg1=value1 arg2="value 2"`. In this example, `args` will
118 be `{'arg1': 'value1', 'arg2': 'value 2'}`).
119 If no named parameters are given, `args` will be `{}`. That makes it
120 possible to differentiate with a call using the macro syntax, in which
121 case `args` will be `None` (see `parse_args` for a convenient way to
122 extract arguments and name parameters from the `content` inside the
123 parentheses, in the latter situation). (''since 0.12'')
124 """
125
126
127class IWikiSyntaxProvider(Interface):
128
129 def get_wiki_syntax():
130 """Return an iterable that provides additional wiki syntax.
131
132 Additional wiki syntax correspond to a pair of (regexp, cb),
133 the `regexp` for the additional syntax and the callback `cb`
134 which will be called if there's a match.
135 That function is of the form cb(formatter, ns, match).
136 """
137
138 def get_link_resolvers():
139 """Return an iterable over (namespace, formatter) tuples.
140
141 Each formatter should be a function of the form:
142 {{{#!python
143 def format(formatter, ns, target, label, fullmatch=None):
144 pass
145 }}}
146 and should return some HTML fragment. The `label` is already
147 HTML escaped, whereas the `target` is not. The `fullmatch` argument
148 is optional, and is bound to the regexp match object for the link.
149 """
150
151
152def parse_args(args, strict=True):
153 """Utility for parsing macro "content" and splitting them into arguments.
154
155 The content is split along commas, unless they are escaped with a
156 backquote (like this: \,).
157
158 :param args: macros arguments, as plain text
159 :param strict: if `True`, only Python-like identifiers will be
160 recognized as keyword arguments
161
162 Example usage:
163
164 >>> parse_args('')
165 ([], {})
166 >>> parse_args('Some text')
167 (['Some text'], {})
168 >>> parse_args('Some text, mode= 3, some other arg\, with a comma.')
169 (['Some text', ' some other arg, with a comma.'], {'mode': ' 3'})
170 >>> parse_args('milestone=milestone1,status!=closed', strict=False)
171 ([], {'status!': 'closed', 'milestone': 'milestone1'})
172
173 """
174 largs, kwargs = [], {}
175 if args:
176 for arg in re.split(r'(?<!\\),', args):
177 arg = arg.replace(r'\,', ',')
178 if strict:
179 m = re.match(r'\s*[a-zA-Z_]\w+=', arg)
180 else:
181 m = re.match(r'\s*[^=]+=', arg)
182 if m:
183 kw = arg[:m.end()-1].strip()
184 if strict:
185 kw = unicode(kw).encode('utf-8')
186 kwargs[kw] = arg[m.end():]
187 else:
188 largs.append(arg)
189 return largs, kwargs
190
191
192class WikiSystem(Component):
193 """Wiki system manager."""
194
195 implements(IWikiSyntaxProvider, IResourceManager)
196
197 change_listeners = ExtensionPoint(IWikiChangeListener)
198 macro_providers = ExtensionPoint(IWikiMacroProvider)
199 syntax_providers = ExtensionPoint(IWikiSyntaxProvider)
200
201 ignore_missing_pages = BoolOption('wiki', 'ignore_missing_pages', 'false',
202 """Enable/disable highlighting CamelCase links to missing pages
203 (''since 0.9'').""")
204
205 split_page_names = BoolOption('wiki', 'split_page_names', 'false',
206 """Enable/disable splitting the WikiPageNames with space characters
207 (''since 0.10'').""")
208
209 render_unsafe_content = BoolOption('wiki', 'render_unsafe_content', 'false',
210 """Enable/disable the use of unsafe HTML tags such as `<script>` or
211 `<embed>` with the HTML [wiki:WikiProcessors WikiProcessor]
212 (''since 0.10.4'').
213
214 For public sites where anonymous users can edit the wiki it is
215 recommended to leave this option disabled (which is the default).""")
216
217 @cached
218 def pages(self, db):
219 """Return the names of all existing wiki pages."""
220 cursor = db.cursor()
221 cursor.execute("SELECT DISTINCT name FROM wiki")
222 return set(row[0] for row in cursor)
223
224 # Public API
225
226 def get_pages(self, prefix=None):
227 """Iterate over the names of existing Wiki pages.
228
229 If the `prefix` parameter is given, only names that start with that
230 prefix are included.
231 """
232 for page in self.pages:
233 if not prefix or page.startswith(prefix):
234 yield page
235
236 def has_page(self, pagename):
237 """Whether a page with the specified name exists."""
238 return pagename.rstrip('/') in self.pages
239
240 # IWikiSyntaxProvider methods
241
242 XML_NAME = r"[\w:](?<!\d)(?:[\w:.-]*[\w-])?"
243 # See http://www.w3.org/TR/REC-xml/#id,
244 # here adapted to exclude terminal "." and ":" characters
245
246 PAGE_SPLIT_RE = re.compile(r"([a-z])([A-Z])(?=[a-z])")
247
248 Lu = ''.join(unichr(c) for c in range(0, 0x10000) if unichr(c).isupper())
249 Ll = ''.join(unichr(c) for c in range(0, 0x10000) if unichr(c).islower())
250
251 def format_page_name(self, page, split=False):
252 if split or self.split_page_names:
253 return self.PAGE_SPLIT_RE.sub(r"\1 \2", page)
254 return page
255
256 def make_label_from_target(self, target):
257 """Create a label from a wiki target.
258
259 A trailing fragment and query string is stripped. Then, leading `./`,
260 `../` and '/' elements are stripped, except when this would lead to an
261 empty label. Finally, if `[wiki] split_page_names` is true, the label
262 is split accordingly.
263 """
264 label = target.split('#', 1)[0].split('?', 1)[0]
265 if not label:
266 return target
267 components = label.split('/')
268 for i, comp in enumerate(components):
269 if comp not in ('', '.', '..'):
270 label = '/'.join(components[i:])
271 break
272 return self.format_page_name(label)
273
274 def get_wiki_syntax(self):
275 wiki_page_name = (
276 r"(?:[%(upper)s](?:[%(lower)s])+/?){2,}" # wiki words
277 r"(?:@\d+)?" # optional version
278 r"(?:#%(xml)s)?" # optional fragment id
279 r"(?=:(?:\Z|\s)|[^:%(upper)s%(lower)s]|\s|\Z)"
280 # what should follow it
281 % {'upper': self.Lu, 'lower': self.Ll, 'xml': self.XML_NAME})
282
283 # Regular WikiPageNames
284 def wikipagename_link(formatter, match, fullmatch):
285 return self._format_link(formatter, 'wiki', match,
286 self.format_page_name(match),
287 self.ignore_missing_pages, match)
288
289 yield (r"!?(?<!/)\b" + # start at a word boundary but not after '/'
290 wiki_page_name, wikipagename_link)
291
292 # [WikiPageNames with label]
293 def wikipagename_with_label_link(formatter, match, fullmatch):
294 page = fullmatch.group('wiki_page')
295 label = fullmatch.group('wiki_label')
296 return self._format_link(formatter, 'wiki', page, label.strip(),
297 self.ignore_missing_pages, match)
298 yield (r"!?\[(?P<wiki_page>%s)\s+(?P<wiki_label>%s|[^\]]+)\]"
299 % (wiki_page_name, WikiParser.QUOTED_STRING),
300 wikipagename_with_label_link)
301
302 # MoinMoin's ["internal free link"] and ["free link" with label]
303 def internal_free_link(fmt, m, fullmatch):
304 page = fullmatch.group('ifl_page')[1:-1]
305 label = fullmatch.group('ifl_label')
306 if label is None:
307 label = self.make_label_from_target(page)
308 return self._format_link(fmt, 'wiki', page, label.strip(), False)
309 yield (r"!?\[(?P<ifl_page>%s)(?:\s+(?P<ifl_label>%s|[^\]]+))?\]"
310 % (WikiParser.QUOTED_STRING, WikiParser.QUOTED_STRING),
311 internal_free_link)
312
313 def get_link_resolvers(self):
314 def link_resolver(formatter, ns, target, label, fullmatch=None):
315 if fullmatch is not None:
316 # If no explicit label was specified for a [wiki:...] link,
317 # generate a "nice" label instead of keeping the label
318 # generated by the Formatter (usually the target itself).
319 groups = fullmatch.groupdict()
320 if groups.get('lns') and not groups.get('label'):
321 label = self.make_label_from_target(target)
322 return self._format_link(formatter, ns, target, label, False)
323 yield ('wiki', link_resolver)
324
325 def _format_link(self, formatter, ns, pagename, label, ignore_missing,
326 original_label=None):
327 pagename, query, fragment = formatter.split_link(pagename)
328 version = None
329 if '@' in pagename:
330 pagename, version = pagename.split('@', 1)
331 if version and query:
332 query = '&' + query[1:]
333 pagename = pagename.rstrip('/') or 'WikiStart'
334 referrer = ''
335 if formatter.resource and formatter.resource.realm == 'wiki':
336 referrer = formatter.resource.id
337 if pagename.startswith('/'):
338 pagename = pagename.lstrip('/')
339 elif pagename.startswith('./') or pagename.startswith('../') \
340 or pagename in ('.', '..'):
341 pagename = self._resolve_relative_name(pagename, referrer)
342 else:
343 pagename = self._resolve_scoped_name(pagename, referrer)
344 if 'WIKI_VIEW' in formatter.perm('wiki', pagename, version):
345 href = formatter.href.wiki(pagename, version=version) + query \
346 + fragment
347 if self.has_page(pagename):
348 return tag.a(label, href=href, class_='wiki')
349 else:
350 if ignore_missing:
351 return original_label or label
352 if 'WIKI_CREATE' in formatter.perm('wiki', pagename, version):
353 return tag.a(label + '?', class_='missing wiki',
354 href=href, rel='nofollow')
355 else:
356 return tag.a(label + '?', class_='missing wiki')
357 elif ignore_missing and not self.has_page(pagename):
358 return original_label or label
359 else:
360 return tag.a(label, class_='forbidden wiki',
361 title=_("no permission to view this wiki page"))
362
363 def _resolve_relative_name(self, pagename, referrer):
364 base = referrer.split('/')
365 components = pagename.split('/')
366 for i, comp in enumerate(components):
367 if comp == '..':
368 if base:
369 base.pop()
370 elif comp and comp != '.':
371 base.extend(components[i:])
372 break
373 return '/'.join(base)
374
375 def _resolve_scoped_name(self, pagename, referrer):
376 referrer = referrer.split('/')
377 if len(referrer) == 1: # Non-hierarchical referrer
378 return pagename
379 # Test for pages with same name, higher in the hierarchy
380 for i in range(len(referrer) - 1, 0, -1):
381 name = '/'.join(referrer[:i]) + '/' + pagename
382 if self.has_page(name):
383 return name
384 if self.has_page(pagename):
385 return pagename
386 # If we are on First/Second/Third, and pagename is Second/Other,
387 # resolve to First/Second/Other instead of First/Second/Second/Other
388 # See http://trac.edgewall.org/ticket/4507#comment:12
389 if '/' in pagename:
390 (first, rest) = pagename.split('/', 1)
391 for (i, part) in enumerate(referrer):
392 if first == part:
393 anchor = '/'.join(referrer[:i + 1])
394 if self.has_page(anchor):
395 return anchor + '/' + rest
396 # Assume the user wants a sibling of referrer
397 return '/'.join(referrer[:-1]) + '/' + pagename
398
399 # IResourceManager methods
400
401 def get_resource_realms(self):
402 yield 'wiki'
403
404 def get_resource_description(self, resource, format, **kwargs):
405 """
406 >>> from trac.test import EnvironmentStub
407 >>> from trac.resource import Resource, get_resource_description
408 >>> env = EnvironmentStub()
409 >>> main = Resource('wiki', 'WikiStart')
410 >>> get_resource_description(env, main)
411 'WikiStart'
412
413 >>> get_resource_description(env, main(version=3))
414 'WikiStart'
415
416 >>> get_resource_description(env, main(version=3), format='summary')
417 'WikiStart'
418
419 >>> env.config['wiki'].set('split_page_names', 'true')
420 >>> get_resource_description(env, main(version=3))
421 'Wiki Start'
422 """
423 return self.format_page_name(resource.id)
424
425 def resource_exists(self, resource):
426 """
427 >>> from trac.test import EnvironmentStub
428 >>> from trac.resource import Resource, resource_exists
429 >>> env = EnvironmentStub()
430
431 >>> resource_exists(env, Resource('wiki', 'WikiStart'))
432 False
433
434 >>> from trac.wiki.model import WikiPage
435 >>> main = WikiPage(env, 'WikiStart')
436 >>> main.text = 'some content'
437 >>> main.save('author', 'no comment', '::1')
438 >>> resource_exists(env, main.resource)
439 True
440 """
441 if resource.version is None:
442 return resource.id in self.pages
443 db = self.env.get_read_db()
444 cursor = db.cursor()
445 cursor.execute("SELECT name FROM wiki WHERE name=%s AND version=%s",
446 (resource.id, resource.version))
447 return bool(cursor.fetchall())
Note: See TracBrowser for help on using the repository browser.