Edgewall Software

source: trunk/trac/wiki/api.py

Last change on this file was 17657, checked in by Jun Omae, 8 months ago

1.5.4dev: update copyright year to 2023 (refs #13402)

[skip ci]

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