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