Merge lp:~thumper/launchpad/bug-linkification into lp:launchpad
- bug-linkification
- Merge into devel
Proposed by
Tim Penhey
on 2010-05-18
| Status: | Merged |
|---|---|
| Approved by: | Tim Penhey on 2010-05-21 |
| Approved revision: | no longer in the source branch. |
| Merged at revision: | 10909 |
| Proposed branch: | lp:~thumper/launchpad/bug-linkification |
| Merge into: | lp:launchpad |
| Diff against target: |
2167 lines (+905/-920) 17 files modified
lib/canonical/launchpad/scripts/oops.py (+1/-1) lib/canonical/launchpad/vocabularies/dbobjects.py (+1/-1) lib/canonical/launchpad/webapp/configure.zcml (+0/-7) lib/canonical/launchpad/webapp/tales.py (+4/-814) lib/canonical/launchpad/webapp/tests/test_tales.py (+5/-5) lib/lp/app/browser/configure.zcml (+16/-7) lib/lp/app/browser/stringformatter.py (+830/-0) lib/lp/app/doc/displaying-paragraphs-of-text.txt (+32/-48) lib/lp/bugs/browser/bug.py (+1/-1) lib/lp/bugs/tests/test_bugs_webservice.py (+2/-2) lib/lp/code/browser/branchmergeproposal.py (+2/-2) lib/lp/registry/browser/person.py (+2/-1) lib/lp/registry/doc/sourcepackage.txt (+4/-26) lib/lp/registry/feed/announcement.py (+1/-1) lib/lp/soyuz/browser/archive.py (+1/-1) lib/lp/soyuz/browser/sourcepackagerelease.py (+1/-1) lib/lp/soyuz/stories/soyuz/xx-sourcepackage-changelog.txt (+2/-2) |
| To merge this branch: | bzr merge lp:~thumper/launchpad/bug-linkification |
| Related bugs: |
| Reviewer | Review Type | Date Requested | Status |
|---|---|---|---|
| Francis J. Lacoste (community) | 2010-05-18 | Approve on 2010-05-21 | |
|
Review via email:
|
|||
Commit Message
Don't add titles to linkified bugs in the string formatting.
Description of the Change
This branch removes the title of linked bugs.
Links are still created for bugs that don't exist or a private, and no privacy checking is done on the linkified bugs. This will allow us to cache certain text blocks as they don't check on the privacy needed.
I also took the opportunity to move the TAL formatter out of the canonical.launchpad package. There was already a TAL formatter in lp.app.browser, so that seemed like a good place for it.
Pre-impl call: flacoste
tests:
displaying-
To post a comment you must log in.
| Gary Poster (gary) wrote : | # |
| Tim Penhey (thumper) wrote : | # |
Gary, I don't know how to insert the memcache tags. Personally I'd suggest a different branch for that.
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
| 1 | === modified file 'lib/canonical/launchpad/scripts/oops.py' |
| 2 | --- lib/canonical/launchpad/scripts/oops.py 2010-03-17 08:11:33 +0000 |
| 3 | +++ lib/canonical/launchpad/scripts/oops.py 2010-05-21 02:35:37 +0000 |
| 4 | @@ -21,7 +21,7 @@ |
| 5 | from canonical.database.sqlbase import cursor |
| 6 | from canonical.launchpad.webapp import errorlog |
| 7 | from canonical.launchpad.webapp.dbpolicy import SlaveOnlyDatabasePolicy |
| 8 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 9 | +from lp.app.browser.stringformatter import FormattersAPI |
| 10 | |
| 11 | def referenced_oops(): |
| 12 | '''Return a set of OOPS codes that are referenced somewhere in the |
| 13 | |
| 14 | === modified file 'lib/canonical/launchpad/vocabularies/dbobjects.py' |
| 15 | --- lib/canonical/launchpad/vocabularies/dbobjects.py 2010-02-17 11:19:42 +0000 |
| 16 | +++ lib/canonical/launchpad/vocabularies/dbobjects.py 2010-05-21 02:35:37 +0000 |
| 17 | @@ -83,11 +83,11 @@ |
| 18 | from lp.translations.interfaces.languagepack import LanguagePackType |
| 19 | from lp.blueprints.interfaces.specification import SpecificationFilter |
| 20 | from canonical.launchpad.webapp.interfaces import ILaunchBag |
| 21 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 22 | from canonical.launchpad.webapp.vocabulary import ( |
| 23 | CountableIterator, IHugeVocabulary, |
| 24 | NamedSQLObjectVocabulary, SQLObjectVocabularyBase) |
| 25 | |
| 26 | +from lp.app.browser.stringformatter import FormattersAPI |
| 27 | from lp.code.enums import BranchType |
| 28 | from lp.code.interfaces.branch import IBranch |
| 29 | from lp.code.interfaces.branchcollection import IAllBranches |
| 30 | |
| 31 | === modified file 'lib/canonical/launchpad/webapp/configure.zcml' |
| 32 | --- lib/canonical/launchpad/webapp/configure.zcml 2010-04-30 18:37:39 +0000 |
| 33 | +++ lib/canonical/launchpad/webapp/configure.zcml 2010-05-21 02:35:37 +0000 |
| 34 | @@ -286,13 +286,6 @@ |
| 35 | /> |
| 36 | |
| 37 | <adapter |
| 38 | - for="basestring" |
| 39 | - provides="zope.traversing.interfaces.IPathAdapter" |
| 40 | - factory="canonical.launchpad.webapp.tales.FormattersAPI" |
| 41 | - name="fmt" |
| 42 | - /> |
| 43 | - |
| 44 | - <adapter |
| 45 | for="int" |
| 46 | provides="zope.traversing.interfaces.IPathAdapter" |
| 47 | factory="canonical.launchpad.webapp.tales.NumberFormatterAPI" |
| 48 | |
| 49 | === modified file 'lib/canonical/launchpad/webapp/tales.py' |
| 50 | --- lib/canonical/launchpad/webapp/tales.py 2010-05-17 15:54:18 +0000 |
| 51 | +++ lib/canonical/launchpad/webapp/tales.py 2010-05-21 02:35:37 +0000 |
| 52 | @@ -12,13 +12,11 @@ |
| 53 | from email.Utils import formatdate |
| 54 | import math |
| 55 | import os.path |
| 56 | -import re |
| 57 | import rfc822 |
| 58 | import sys |
| 59 | import urllib |
| 60 | ##import warnings |
| 61 | |
| 62 | -from xml.sax.saxutils import unescape as xml_unescape |
| 63 | from datetime import datetime, timedelta |
| 64 | from lazr.enum import enumerated_type_registry |
| 65 | from lazr.uri import URI |
| 66 | @@ -37,12 +35,10 @@ |
| 67 | import pytz |
| 68 | from z3c.ptcompat import ViewPageTemplateFile |
| 69 | |
| 70 | -from canonical.config import config |
| 71 | from canonical.launchpad import _ |
| 72 | from canonical.launchpad.interfaces import ( |
| 73 | - IBug, IBugSet, IDistribution, IFAQSet, |
| 74 | - IProduct, IProjectGroup, IDistributionSourcePackage, ISprint, |
| 75 | - LicenseStatus, NotFoundError) |
| 76 | + IBug, IDistribution, IProduct, IProjectGroup, IDistributionSourcePackage, |
| 77 | + ISprint, LicenseStatus) |
| 78 | from canonical.launchpad.interfaces.launchpad import ( |
| 79 | IHasIcon, IHasLogo, IHasMugshot, IPrivacy) |
| 80 | import canonical.launchpad.pagetitles |
| 81 | @@ -57,25 +53,18 @@ |
| 82 | get_current_browser_request, LaunchpadView, nearest) |
| 83 | from canonical.launchpad.webapp.session import get_cookie_domain |
| 84 | from canonical.lazr.canonicalurl import nearest_adapter |
| 85 | +from lp.app.browser.stringformatter import escape, FormattersAPI |
| 86 | from lp.blueprints.interfaces.specification import ISpecification |
| 87 | from lp.buildmaster.interfaces.buildbase import BuildStatus |
| 88 | from lp.code.interfaces.branch import IBranch |
| 89 | from lp.soyuz.interfaces.archive import ArchivePurpose, IPPA |
| 90 | from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet |
| 91 | -from lp.registry.interfaces.person import IPerson, IPersonSet |
| 92 | +from lp.registry.interfaces.person import IPerson |
| 93 | |
| 94 | |
| 95 | SEPARATOR = ' : ' |
| 96 | |
| 97 | |
| 98 | -def escape(text, quote=True): |
| 99 | - """Escape text for insertion into HTML. |
| 100 | - |
| 101 | - Wraps `cgi.escape` to make the default to escape double-quotes. |
| 102 | - """ |
| 103 | - return cgi.escape(text, quote) |
| 104 | - |
| 105 | - |
| 106 | class MenuAPI: |
| 107 | """Namespace to give access to the facet menus. |
| 108 | |
| 109 | @@ -2280,805 +2269,6 @@ |
| 110 | return title |
| 111 | |
| 112 | |
| 113 | -def split_paragraphs(text): |
| 114 | - """Split text into paragraphs. |
| 115 | - |
| 116 | - This function yields lists of strings that represent lines of text |
| 117 | - in each paragraph. |
| 118 | - |
| 119 | - Paragraphs are split by one or more blank lines. |
| 120 | - """ |
| 121 | - paragraph = [] |
| 122 | - for line in text.splitlines(): |
| 123 | - line = line.rstrip() |
| 124 | - |
| 125 | - # blank lines split paragraphs |
| 126 | - if not line: |
| 127 | - if paragraph: |
| 128 | - yield paragraph |
| 129 | - paragraph = [] |
| 130 | - continue |
| 131 | - |
| 132 | - paragraph.append(line) |
| 133 | - |
| 134 | - if paragraph: |
| 135 | - yield paragraph |
| 136 | - |
| 137 | - |
| 138 | -def re_substitute(pattern, replace_match, replace_nomatch, string): |
| 139 | - """Transform a string, replacing matched and non-matched sections. |
| 140 | - |
| 141 | - :param patter: a regular expression |
| 142 | - :param replace_match: a function used to transform matches |
| 143 | - :param replace_nomatch: a function used to transform non-matched text |
| 144 | - :param string: the string to transform |
| 145 | - |
| 146 | - This function behaves similarly to re.sub() when a function is |
| 147 | - passed as the second argument, except that the non-matching |
| 148 | - portions of the string can be transformed by a second function. |
| 149 | - """ |
| 150 | - if replace_match is None: |
| 151 | - replace_match = lambda match: match.group() |
| 152 | - if replace_nomatch is None: |
| 153 | - replace_nomatch = lambda text: text |
| 154 | - parts = [] |
| 155 | - position = 0 |
| 156 | - for match in re.finditer(pattern, string): |
| 157 | - if match.start() != position: |
| 158 | - parts.append(replace_nomatch(string[position:match.start()])) |
| 159 | - parts.append(replace_match(match)) |
| 160 | - position = match.end() |
| 161 | - remainder = string[position:] |
| 162 | - if remainder: |
| 163 | - parts.append(replace_nomatch(remainder)) |
| 164 | - return ''.join(parts) |
| 165 | - |
| 166 | - |
| 167 | -def next_word_chunk(word, pos, minlen, maxlen): |
| 168 | - """Return the next chunk of the word of length between minlen and maxlen. |
| 169 | - |
| 170 | - Shorter word chunks are preferred, preferably ending in a non |
| 171 | - alphanumeric character. The index of the end of the chunk is also |
| 172 | - returned. |
| 173 | - |
| 174 | - This function treats HTML entities in the string as single |
| 175 | - characters. The string should not include HTML tags. |
| 176 | - """ |
| 177 | - nchars = 0 |
| 178 | - endpos = pos |
| 179 | - while endpos < len(word): |
| 180 | - # advance by one character |
| 181 | - if word[endpos] == '&': |
| 182 | - # make sure we grab the entity as a whole |
| 183 | - semicolon = word.find(';', endpos) |
| 184 | - assert semicolon >= 0, 'badly formed entity: %r' % word[endpos:] |
| 185 | - endpos = semicolon + 1 |
| 186 | - else: |
| 187 | - endpos += 1 |
| 188 | - nchars += 1 |
| 189 | - if nchars >= maxlen: |
| 190 | - # stop if we've reached the maximum chunk size |
| 191 | - break |
| 192 | - if nchars >= minlen and not word[endpos-1].isalnum(): |
| 193 | - # stop if we've reached the minimum chunk size and the last |
| 194 | - # character wasn't alphanumeric. |
| 195 | - break |
| 196 | - return word[pos:endpos], endpos |
| 197 | - |
| 198 | - |
| 199 | -def add_word_breaks(word): |
| 200 | - """Insert manual word breaks into a string. |
| 201 | - |
| 202 | - The word may be entity escaped, but is not expected to contain |
| 203 | - any HTML tags. |
| 204 | - |
| 205 | - Breaks are inserted at least every 7 to 15 characters, |
| 206 | - preferably after puctuation. |
| 207 | - """ |
| 208 | - broken = [] |
| 209 | - pos = 0 |
| 210 | - while pos < len(word): |
| 211 | - chunk, pos = next_word_chunk(word, pos, 7, 15) |
| 212 | - broken.append(chunk) |
| 213 | - return '<wbr></wbr>'.join(broken) |
| 214 | - |
| 215 | - |
| 216 | -break_text_pat = re.compile(r''' |
| 217 | - (?P<tag> |
| 218 | - <[^>]*> |
| 219 | - ) | |
| 220 | - (?P<longword> |
| 221 | - (?<![^\s<>])(?:[^\s<>&]|&[^;]*;){20,} |
| 222 | - ) |
| 223 | -''', re.VERBOSE) |
| 224 | - |
| 225 | -def break_long_words(text): |
| 226 | - """Add word breaks to long words in a run of text. |
| 227 | - |
| 228 | - The text may contain entity references or HTML tags. |
| 229 | - """ |
| 230 | - def replace(match): |
| 231 | - if match.group('tag'): |
| 232 | - return match.group() |
| 233 | - elif match.group('longword'): |
| 234 | - return add_word_breaks(match.group()) |
| 235 | - else: |
| 236 | - raise AssertionError('text matched but neither named group found') |
| 237 | - return break_text_pat.sub(replace, text) |
| 238 | - |
| 239 | - |
| 240 | -class FormattersAPI: |
| 241 | - """Adapter from strings to HTML formatted text.""" |
| 242 | - |
| 243 | - implements(ITraversable) |
| 244 | - |
| 245 | - def __init__(self, stringtoformat): |
| 246 | - self._stringtoformat = stringtoformat |
| 247 | - |
| 248 | - def nl_to_br(self): |
| 249 | - """Quote HTML characters, then replace newlines with <br /> tags.""" |
| 250 | - return cgi.escape(self._stringtoformat).replace('\n','<br />\n') |
| 251 | - |
| 252 | - def escape(self): |
| 253 | - return escape(self._stringtoformat) |
| 254 | - |
| 255 | - def break_long_words(self): |
| 256 | - """Add manual word breaks to long words.""" |
| 257 | - return break_long_words(cgi.escape(self._stringtoformat)) |
| 258 | - |
| 259 | - @staticmethod |
| 260 | - def _substitute_matchgroup_for_spaces(match): |
| 261 | - """Return a string made up of ' ' for each character in the |
| 262 | - first match group. |
| 263 | - |
| 264 | - Used when replacing leading spaces with nbsps. |
| 265 | - |
| 266 | - There must be only one match group. |
| 267 | - """ |
| 268 | - groups = match.groups() |
| 269 | - assert len(groups) == 1 |
| 270 | - return ' ' * len(groups[0]) |
| 271 | - |
| 272 | - @staticmethod |
| 273 | - def _split_url_and_trailers(url): |
| 274 | - """Given a URL return a tuple of the URL and punctuation trailers. |
| 275 | - |
| 276 | - :return: an unescaped url, an unescaped trailer. |
| 277 | - """ |
| 278 | - # The text will already have been cgi escaped. We temporarily |
| 279 | - # unescape it so that we can strip common trailing characters |
| 280 | - # that aren't part of the URL. |
| 281 | - url = xml_unescape(url) |
| 282 | - match = FormattersAPI._re_url_trailers.search(url) |
| 283 | - if match: |
| 284 | - trailers = match.group(1) |
| 285 | - url = url[:-len(trailers)] |
| 286 | - else: |
| 287 | - trailers = '' |
| 288 | - return url, trailers |
| 289 | - |
| 290 | - @staticmethod |
| 291 | - def _linkify_bug_number(text, bugnum, trailers=''): |
| 292 | - # XXX Brad Bollenbach 2006-04-10: Use a hardcoded url so |
| 293 | - # we still have a link for bugs that don't exist. |
| 294 | - url = '/bugs/%s' % bugnum |
| 295 | - |
| 296 | - bugset = getUtility(IBugSet) |
| 297 | - try: |
| 298 | - bug = bugset.get(bugnum) |
| 299 | - except NotFoundError: |
| 300 | - title = "No such bug" |
| 301 | - else: |
| 302 | - try: |
| 303 | - title = bug.title |
| 304 | - except Unauthorized: |
| 305 | - title = "private bug" |
| 306 | - title = cgi.escape(title, quote=True) |
| 307 | - # The text will have already been cgi escaped. |
| 308 | - return '<a href="%s" title="%s">%s</a>%s' % ( |
| 309 | - url, title, text, trailers) |
| 310 | - |
| 311 | - @staticmethod |
| 312 | - def _linkify_substitution(match): |
| 313 | - if match.group('bug') is not None: |
| 314 | - return FormattersAPI._linkify_bug_number( |
| 315 | - match.group('bug'), match.group('bugnum')) |
| 316 | - elif match.group('url') is not None: |
| 317 | - # The text will already have been cgi escaped. We temporarily |
| 318 | - # unescape it so that we can strip common trailing characters |
| 319 | - # that aren't part of the URL. |
| 320 | - url = match.group('url') |
| 321 | - url, trailers = FormattersAPI._split_url_and_trailers(url) |
| 322 | - # We use nofollow for these links to reduce the value of |
| 323 | - # adding spam URLs to our comments; it's a way of moderately |
| 324 | - # devaluing the return on effort for spammers that consider |
| 325 | - # using Launchpad. |
| 326 | - return '<a rel="nofollow" href="%s">%s</a>%s' % ( |
| 327 | - cgi.escape(url, quote=True), |
| 328 | - add_word_breaks(cgi.escape(url)), |
| 329 | - cgi.escape(trailers)) |
| 330 | - elif match.group('faq') is not None: |
| 331 | - text = match.group('faq') |
| 332 | - faqnum = match.group('faqnum') |
| 333 | - faqset = getUtility(IFAQSet) |
| 334 | - faq = faqset.getFAQ(faqnum) |
| 335 | - if not faq: |
| 336 | - return text |
| 337 | - url = canonical_url(faq) |
| 338 | - return '<a href="%s">%s</a>' % (url, text) |
| 339 | - elif match.group('oops') is not None: |
| 340 | - text = match.group('oops') |
| 341 | - |
| 342 | - if not getUtility(ILaunchBag).developer: |
| 343 | - return text |
| 344 | - |
| 345 | - root_url = config.launchpad.oops_root_url |
| 346 | - url = root_url + match.group('oopscode') |
| 347 | - return '<a href="%s">%s</a>' % (url, text) |
| 348 | - elif match.group('lpbranchurl') is not None: |
| 349 | - lp_url = match.group('lpbranchurl') |
| 350 | - path = match.group('branch') |
| 351 | - lp_url, trailers = FormattersAPI._split_url_and_trailers(lp_url) |
| 352 | - path, trailers = FormattersAPI._split_url_and_trailers(path) |
| 353 | - if path.isdigit(): |
| 354 | - return FormattersAPI._linkify_bug_number( |
| 355 | - lp_url, path, trailers) |
| 356 | - url = '/+branch/%s' % path |
| 357 | - return '<a href="%s">%s</a>%s' % ( |
| 358 | - cgi.escape(url, quote=True), |
| 359 | - cgi.escape(lp_url), |
| 360 | - cgi.escape(trailers)) |
| 361 | - elif match.group("clbug") is not None: |
| 362 | - # 'clbug' matches Ubuntu changelog format bugs. 'bugnumbers' is |
| 363 | - # all of the bug numbers, that look something like "#1234, #434". |
| 364 | - # 'leader' is the 'LP: ' bit at the beginning. |
| 365 | - bug_parts = [] |
| 366 | - # Split the bug numbers into multiple bugs. |
| 367 | - splitted = re.split("(,(?:\s|<br\s*/>)+)", |
| 368 | - match.group("bugnumbers")) + [""] |
| 369 | - for bug_id, spacer in zip(splitted[::2], splitted[1::2]): |
| 370 | - bug_parts.append(FormattersAPI._linkify_bug_number( |
| 371 | - bug_id, bug_id.lstrip("#"))) |
| 372 | - bug_parts.append(spacer) |
| 373 | - return match.group("leader") + "".join(bug_parts) |
| 374 | - else: |
| 375 | - raise AssertionError("Unknown pattern matched.") |
| 376 | - |
| 377 | - # match whitespace at the beginning of a line |
| 378 | - _re_leadingspace = re.compile(r'^(\s+)') |
| 379 | - |
| 380 | - # From RFC 3986 ABNF for URIs: |
| 381 | - # |
| 382 | - # URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] |
| 383 | - # hier-part = "//" authority path-abempty |
| 384 | - # / path-absolute |
| 385 | - # / path-rootless |
| 386 | - # / path-empty |
| 387 | - # |
| 388 | - # authority = [ userinfo "@" ] host [ ":" port ] |
| 389 | - # userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) |
| 390 | - # host = IP-literal / IPv4address / reg-name |
| 391 | - # reg-name = *( unreserved / pct-encoded / sub-delims ) |
| 392 | - # port = *DIGIT |
| 393 | - # |
| 394 | - # path-abempty = *( "/" segment ) |
| 395 | - # path-absolute = "/" [ segment-nz *( "/" segment ) ] |
| 396 | - # path-rootless = segment-nz *( "/" segment ) |
| 397 | - # path-empty = 0<pchar> |
| 398 | - # |
| 399 | - # segment = *pchar |
| 400 | - # segment-nz = 1*pchar |
| 401 | - # pchar = unreserved / pct-encoded / sub-delims / ":" / "@" |
| 402 | - # |
| 403 | - # query = *( pchar / "/" / "?" ) |
| 404 | - # fragment = *( pchar / "/" / "?" ) |
| 405 | - # |
| 406 | - # unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" |
| 407 | - # pct-encoded = "%" HEXDIG HEXDIG |
| 408 | - # sub-delims = "!" / "$" / "&" / "'" / "(" / ")" |
| 409 | - # / "*" / "+" / "," / ";" / "=" |
| 410 | - # |
| 411 | - # We only match a set of known scheme names too. We don't handle |
| 412 | - # IP-literal either. |
| 413 | - # |
| 414 | - # We will simplify "unreserved / pct-encoded / sub-delims" as the |
| 415 | - # following regular expression: |
| 416 | - # [-a-zA-Z0-9._~%!$&'()*+,;=] |
| 417 | - # |
| 418 | - # We also require that the path-rootless form not begin with a |
| 419 | - # colon to avoid matching strings like "http::foo" (to avoid bug |
| 420 | - # #40255). |
| 421 | - # |
| 422 | - # The path-empty pattern is not matched either, due to false |
| 423 | - # positives. |
| 424 | - # |
| 425 | - # Some allowed URI punctuation characters will be trimmed if they |
| 426 | - # appear at the end of the URI since they may be incidental in the |
| 427 | - # flow of the text. |
| 428 | - # |
| 429 | - # apport has at one time produced query strings containing sqaure |
| 430 | - # braces (that are not percent-encoded). In RFC 2986 they seem to be |
| 431 | - # allowed by section 2.2 "Reserved Characters", yet section 3.4 |
| 432 | - # "Query" appears to provide a strict definition of the query string |
| 433 | - # that would forbid square braces. Either way, links with |
| 434 | - # non-percent-encoded square braces are being used on Launchpad so |
| 435 | - # it's probably best to accomodate them. |
| 436 | - |
| 437 | - # Match urls or bugs or oopses. |
| 438 | - _re_linkify = re.compile(r''' |
| 439 | - (?P<url> |
| 440 | - \b |
| 441 | - (?:about|gopher|http|https|sftp|news|ftp|mailto|file|irc|jabber) |
| 442 | - : |
| 443 | - (?: |
| 444 | - (?: |
| 445 | - # "//" authority path-abempty |
| 446 | - // |
| 447 | - (?: # userinfo |
| 448 | - [%(unreserved)s:]* |
| 449 | - @ |
| 450 | - )? |
| 451 | - (?: # host |
| 452 | - \d+\.\d+\.\d+\.\d+ | |
| 453 | - [%(unreserved)s]* |
| 454 | - ) |
| 455 | - (?: # port |
| 456 | - : \d* |
| 457 | - )? |
| 458 | - (?: / [%(unreserved)s:@]* )* |
| 459 | - ) | (?: |
| 460 | - # path-absolute |
| 461 | - / |
| 462 | - (?: [%(unreserved)s:@]+ |
| 463 | - (?: / [%(unreserved)s:@]* )* )? |
| 464 | - ) | (?: |
| 465 | - # path-rootless |
| 466 | - [%(unreserved)s@] |
| 467 | - [%(unreserved)s:@]* |
| 468 | - (?: / [%(unreserved)s:@]* )* |
| 469 | - ) |
| 470 | - ) |
| 471 | - (?: # query |
| 472 | - \? |
| 473 | - [%(unreserved)s:@/\?\[\]]* |
| 474 | - )? |
| 475 | - (?: # fragment |
| 476 | - \# |
| 477 | - [%(unreserved)s:@/\?]* |
| 478 | - )? |
| 479 | - ) | |
| 480 | - (?P<clbug> |
| 481 | - \b(?P<leader>lp:(\s|<br\s*/>)+) |
| 482 | - (?P<bugnumbers>\#\d+(,(\s|<br\s*/>)+\#\d+)* |
| 483 | - ) |
| 484 | - ) | |
| 485 | - (?P<bug> |
| 486 | - \bbug(?:[\s=-]|<br\s*/>)*(?:\#|report|number\.?|num\.?|no\.?)?(?:[\s=-]|<br\s*/>)* |
| 487 | - 0*(?P<bugnum>\d+) |
| 488 | - ) | |
| 489 | - (?P<faq> |
| 490 | - \bfaq(?:[\s=-]|<br\s*/>)*(?:\#|item|number\.?|num\.?|no\.?)?(?:[\s=-]|<br\s*/>)* |
| 491 | - 0*(?P<faqnum>\d+) |
| 492 | - ) | |
| 493 | - (?P<oops> |
| 494 | - \boops\s*-?\s* |
| 495 | - (?P<oopscode> \d* [a-z]+ \d+) |
| 496 | - ) | |
| 497 | - (?P<lpbranchurl> |
| 498 | - \blp:(?:///|/)? |
| 499 | - (?P<branch>[%(unreserved)s][%(unreserved)s/]*) |
| 500 | - ) |
| 501 | - ''' % {'unreserved': "-a-zA-Z0-9._~%!$&'()*+,;="}, |
| 502 | - re.IGNORECASE | re.VERBOSE) |
| 503 | - |
| 504 | - # a pattern to match common trailing punctuation for URLs that we |
| 505 | - # don't want to include in the link. |
| 506 | - _re_url_trailers = re.compile(r'([,.?:);>]+)$') |
| 507 | - |
| 508 | - def text_to_html(self): |
| 509 | - """Quote text according to DisplayingParagraphsOfText.""" |
| 510 | - # This is based on the algorithm in the |
| 511 | - # DisplayingParagraphsOfText spec, but is a little more |
| 512 | - # complicated. |
| 513 | - |
| 514 | - # 1. Blank lines are used to detect paragraph boundaries. |
| 515 | - # 2. Two lines are considered to be part of the same logical line |
| 516 | - # only if the first is between 60 and 80 characters and the |
| 517 | - # second does not begin with white space. |
| 518 | - # 3. Use <br /> to split logical lines within a paragraph. |
| 519 | - output = [] |
| 520 | - first_para = True |
| 521 | - for para in split_paragraphs(self._stringtoformat): |
| 522 | - if not first_para: |
| 523 | - output.append('\n') |
| 524 | - first_para = False |
| 525 | - output.append('<p>') |
| 526 | - first_line = True |
| 527 | - for line in para: |
| 528 | - if not first_line: |
| 529 | - output.append('<br />\n') |
| 530 | - first_line = False |
| 531 | - # escape ampersands, etc in text |
| 532 | - line = cgi.escape(line) |
| 533 | - # convert leading space in logical line to non-breaking space |
| 534 | - line = self._re_leadingspace.sub( |
| 535 | - self._substitute_matchgroup_for_spaces, line) |
| 536 | - output.append(line) |
| 537 | - output.append('</p>') |
| 538 | - |
| 539 | - text = ''.join(output) |
| 540 | - |
| 541 | - # Linkify the text. |
| 542 | - text = re_substitute(self._re_linkify, self._linkify_substitution, |
| 543 | - break_long_words, text) |
| 544 | - |
| 545 | - return text |
| 546 | - |
| 547 | - def nice_pre(self): |
| 548 | - """<pre>, except the browser knows it is allowed to break long lines |
| 549 | - |
| 550 | - Note that CSS will eventually have a property to specify this |
| 551 | - behaviour, but we want this now. To do this we need to use the mozilla |
| 552 | - specific -moz-pre-wrap value of the white-space property. We try to |
| 553 | - fall back for IE by using the IE specific word-wrap property. |
| 554 | - |
| 555 | - TODO: Test IE compatibility. StuartBishop 20041118 |
| 556 | - """ |
| 557 | - if not self._stringtoformat: |
| 558 | - return self._stringtoformat |
| 559 | - else: |
| 560 | - linkified_text = re_substitute(self._re_linkify, |
| 561 | - self._linkify_substitution, break_long_words, |
| 562 | - cgi.escape(self._stringtoformat)) |
| 563 | - return '<pre class="wrap">%s</pre>' % linkified_text |
| 564 | - |
| 565 | - # Match lines that start with one or more quote symbols followed |
| 566 | - # by a space. Quote symbols are commonly '|', or '>'; they are |
| 567 | - # used for quoting passages from another email. Both '>> ' and |
| 568 | - # '> > ' are valid quoting sequences. |
| 569 | - # The dpkg version is used for exceptional cases where it |
| 570 | - # is better to not assume '|' is a start of a quoted passage. |
| 571 | - _re_quoted = re.compile('^(([|] ?)+|(> ?)+)') |
| 572 | - _re_dpkg_quoted = re.compile('^(> ?)+ ') |
| 573 | - |
| 574 | - # Match blocks that start as signatures or PGP inclusions. |
| 575 | - _re_include = re.compile('^<p>(--<br />|-----BEGIN PGP)') |
| 576 | - |
| 577 | - def email_to_html(self): |
| 578 | - """text_to_html and hide signatures and full-quoted emails. |
| 579 | - |
| 580 | - This method wraps inclusions like signatures and PGP blocks in |
| 581 | - <span class="foldable"></span> tags. Quoted passages are wrapped |
| 582 | - <span class="foldable-quoted"></span> tags. The tags identify the |
| 583 | - extra content in the message to the presentation layer. CSS and |
| 584 | - JavaScript may use this markup to control the content's display |
| 585 | - behaviour. |
| 586 | - """ |
| 587 | - start_fold_markup = '<span class="foldable">' |
| 588 | - start_fold_quoted_markup = '<span class="foldable-quoted">' |
| 589 | - end_fold_markup = '%s\n</span></p>' |
| 590 | - re_quoted = self._re_quoted |
| 591 | - re_include = self._re_include |
| 592 | - output = [] |
| 593 | - in_fold = False |
| 594 | - in_quoted = False |
| 595 | - in_false_paragraph = False |
| 596 | - |
| 597 | - def is_quoted(line): |
| 598 | - """Test that a line is a quote and not Python. |
| 599 | - |
| 600 | - Note that passages may be wrongly be interpreted as Python |
| 601 | - because they start with '>>> '. The function does not check |
| 602 | - that next and previous lines of text consistently uses '>>> ' |
| 603 | - as Python would. |
| 604 | - """ |
| 605 | - python_block = '>>> ' |
| 606 | - return (not line.startswith(python_block) |
| 607 | - and re_quoted.match(line) is not None) |
| 608 | - |
| 609 | - def strip_leading_p_tag(line): |
| 610 | - """Return the characters after the paragraph mark (<p>). |
| 611 | - |
| 612 | - The caller must be certain the line starts with a paragraph mark. |
| 613 | - """ |
| 614 | - assert line.startswith('<p>'), ( |
| 615 | - "The line must start with a paragraph mark (<p>).") |
| 616 | - return line[3:] |
| 617 | - |
| 618 | - def strip_trailing_p_tag(line): |
| 619 | - """Return the characters before the line paragraph mark (</p>). |
| 620 | - |
| 621 | - The caller must be certain the line ends with a paragraph mark. |
| 622 | - """ |
| 623 | - assert line.endswith('</p>'), ( |
| 624 | - "The line must end with a paragraph mark (</p>).") |
| 625 | - return line[:-4] |
| 626 | - |
| 627 | - for line in self.text_to_html().split('\n'): |
| 628 | - if 'Desired=<wbr></wbr>Unknown/' in line and not in_fold: |
| 629 | - # When we see a evidence of dpkg output, we switch the |
| 630 | - # quote matching rules. We do not assume lines that start |
| 631 | - # with a pipe are quoted passages. dpkg output is often |
| 632 | - # reformatted by users and tools. When we see the dpkg |
| 633 | - # output header, we change the rules regardless of if the |
| 634 | - # lines that follow are legitimate. |
| 635 | - re_quoted = self._re_dpkg_quoted |
| 636 | - elif not in_fold and re_include.match(line) is not None: |
| 637 | - # This line is a paragraph with a signature or PGP inclusion. |
| 638 | - # Start a foldable paragraph. |
| 639 | - in_fold = True |
| 640 | - line = '<p>%s%s' % (start_fold_markup, |
| 641 | - strip_leading_p_tag(line)) |
| 642 | - elif (not in_fold and line.startswith('<p>') |
| 643 | - and is_quoted(strip_leading_p_tag(line))): |
| 644 | - # The paragraph starts with quoted marks. |
| 645 | - # Start a foldable quoted paragraph. |
| 646 | - in_fold = True |
| 647 | - line = '<p>%s%s' % ( |
| 648 | - start_fold_quoted_markup, strip_leading_p_tag(line)) |
| 649 | - elif not in_fold and is_quoted(line): |
| 650 | - # This line in the paragraph is quoted. |
| 651 | - # Start foldable quoted lines in a paragraph. |
| 652 | - in_quoted = True |
| 653 | - in_fold = True |
| 654 | - output.append(start_fold_quoted_markup) |
| 655 | - else: |
| 656 | - # This line is continues the current state. |
| 657 | - # This line is not a transition. |
| 658 | - pass |
| 659 | - |
| 660 | - # We must test line starts and ends in separate blocks to |
| 661 | - # close the rare single line that is foldable. |
| 662 | - if in_fold and line.endswith('</p>') and in_false_paragraph: |
| 663 | - # The line ends with a false paragraph in a PGP signature. |
| 664 | - # Restore the line break to join with the next paragraph. |
| 665 | - line = '%s<br />\n<br />' % strip_trailing_p_tag(line) |
| 666 | - elif (in_quoted and self._re_quoted.match(line) is None): |
| 667 | - # The line is not quoted like the previous line. |
| 668 | - # End fold before we append this line. |
| 669 | - in_fold = False |
| 670 | - in_quoted = False |
| 671 | - output.append("</span>\n") |
| 672 | - elif in_fold and line.endswith('</p>'): |
| 673 | - # The line is quoted or an inclusion, and ends the paragraph. |
| 674 | - # End the fold before the close paragraph mark. |
| 675 | - in_fold = False |
| 676 | - in_quoted = False |
| 677 | - line = end_fold_markup % strip_trailing_p_tag(line) |
| 678 | - elif in_false_paragraph and line.startswith('<p>'): |
| 679 | - # This line continues a PGP signature, but starts a paragraph. |
| 680 | - # Remove the paragraph to join with the previous paragraph. |
| 681 | - in_false_paragraph = False |
| 682 | - line = strip_leading_p_tag(line) |
| 683 | - else: |
| 684 | - # This line is continues the current state. |
| 685 | - # This line is not a transition. |
| 686 | - pass |
| 687 | - |
| 688 | - if in_fold and 'PGP SIGNATURE' in line: |
| 689 | - # PGP signature blocks are split into two paragraphs |
| 690 | - # by the text_to_html. The foldable feature works with |
| 691 | - # a single paragraph, so we merge this paragraph with |
| 692 | - # the next one. |
| 693 | - in_false_paragraph = True |
| 694 | - |
| 695 | - output.append(line) |
| 696 | - return '\n'.join(output) |
| 697 | - |
| 698 | - # This is a regular expression that matches email address embedded in |
| 699 | - # text. It is not RFC 2821 compliant, nor does it need to be. This |
| 700 | - # expression strives to identify probable email addresses so that they |
| 701 | - # can be obfuscated when viewed by unauthenticated users. See |
| 702 | - # http://www.email-unlimited.com/stuff/email_address_validator.htm |
| 703 | - |
| 704 | - # localnames do not have [&?%!@<>,;:`|{}()#*^~ ] in practice |
| 705 | - # (regardless of RFC 2821) because they conflict with other systems. |
| 706 | - # See https://lists.ubuntu.com |
| 707 | - # /mailman/private/launchpad-reviews/2007-June/006081.html |
| 708 | - |
| 709 | - # This verson of the re is more than 5x faster that the orginal |
| 710 | - # version used in ftest/test_tales.testObfuscateEmail. |
| 711 | - _re_email = re.compile(r""" |
| 712 | - \b[a-zA-Z0-9._/="'+-]{1,64}@ # The localname. |
| 713 | - [a-zA-Z][a-zA-Z0-9-]{1,63} # The hostname. |
| 714 | - \.[a-zA-Z0-9.-]{1,251}\b # Dot starts one or more domains. |
| 715 | - """, re.VERBOSE) # ' <- font-lock turd |
| 716 | - |
| 717 | - def obfuscate_email(self): |
| 718 | - """Obfuscate an email address if there's no authenticated user. |
| 719 | - |
| 720 | - The email address is obfuscated as <email address hidden>. |
| 721 | - |
| 722 | - This formatter is intended to hide possible email addresses from |
| 723 | - unauthenticated users who view this text on the Web. Run this before |
| 724 | - the text is converted to html because text-to-html and email-to-html |
| 725 | - will insert markup into the address. eg. |
| 726 | - foo/fmt:obfuscate-email/fmt:email-to-html |
| 727 | - |
| 728 | - The pattern used to identify an email address is not 2822. It strives |
| 729 | - to match any possible email address embedded in the text. For example, |
| 730 | - mailto:person@domain.dom and http://person:password@domain.dom both |
| 731 | - match, though the http match is in fact not an email address. |
| 732 | - """ |
| 733 | - if getUtility(ILaunchBag).user is not None: |
| 734 | - return self._stringtoformat |
| 735 | - text = self._re_email.sub( |
| 736 | - r'<email address hidden>', self._stringtoformat) |
| 737 | - text = text.replace( |
| 738 | - "<<email address hidden>>", "<email address hidden>") |
| 739 | - return text |
| 740 | - |
| 741 | - def linkify_email(self, preloaded_person_data=None): |
| 742 | - """Linkify any email address recognised in Launchpad. |
| 743 | - |
| 744 | - If an email address is recognised as one registered in Launchpad, |
| 745 | - it is linkified to point to the profile page for that person. |
| 746 | - |
| 747 | - Note that someone could theoretically register any old email |
| 748 | - address in Launchpad and then have it linkified. This may or not |
| 749 | - may be a concern but is noted here for posterity anyway. |
| 750 | - """ |
| 751 | - text = self._stringtoformat |
| 752 | - |
| 753 | - matches = re.finditer(self._re_email, text) |
| 754 | - for match in matches: |
| 755 | - address = match.group() |
| 756 | - person = None |
| 757 | - # First try to find the person required in the preloaded person |
| 758 | - # data dictionary. |
| 759 | - if preloaded_person_data is not None: |
| 760 | - person = preloaded_person_data.get(address, None) |
| 761 | - else: |
| 762 | - # No pre-loaded data -> we need to perform a database lookup. |
| 763 | - person = getUtility(IPersonSet).getByEmail(address) |
| 764 | - # Only linkify if person exists and does not want to hide |
| 765 | - # their email addresses. |
| 766 | - if person is not None and not person.hide_email_addresses: |
| 767 | - css_sprite = ObjectImageDisplayAPI(person).sprite_css() |
| 768 | - text = text.replace( |
| 769 | - address, '<a href="%s" class="%s">%s</a>' % ( |
| 770 | - canonical_url(person), css_sprite, address)) |
| 771 | - |
| 772 | - return text |
| 773 | - |
| 774 | - def lower(self): |
| 775 | - """Return the string in lowercase""" |
| 776 | - return self._stringtoformat.lower() |
| 777 | - |
| 778 | - def shorten(self, maxlength): |
| 779 | - """Use like tal:content="context/foo/fmt:shorten/60".""" |
| 780 | - if len(self._stringtoformat) > maxlength: |
| 781 | - return '%s...' % self._stringtoformat[:maxlength-3] |
| 782 | - else: |
| 783 | - return self._stringtoformat |
| 784 | - |
| 785 | - def ellipsize(self, maxlength): |
| 786 | - """Use like tal:content="context/foo/fmt:ellipsize/60".""" |
| 787 | - if len(self._stringtoformat) > maxlength: |
| 788 | - length = (maxlength - 3) / 2 |
| 789 | - return ( |
| 790 | - self._stringtoformat[:maxlength - length - 3] + '...' + |
| 791 | - self._stringtoformat[-length:]) |
| 792 | - else: |
| 793 | - return self._stringtoformat |
| 794 | - |
| 795 | - def format_diff(self): |
| 796 | - """Format the string as a diff in a table with line numbers.""" |
| 797 | - # Trim off trailing carriage returns. |
| 798 | - text = self._stringtoformat.rstrip('\n') |
| 799 | - if len(text) == 0: |
| 800 | - return text |
| 801 | - result = ['<table class="diff">'] |
| 802 | - |
| 803 | - max_format_lines = config.diff.max_format_lines |
| 804 | - for row, line in enumerate(text.splitlines()[:max_format_lines]): |
| 805 | - result.append('<tr>') |
| 806 | - result.append('<td class="line-no">%s</td>' % (row+1)) |
| 807 | - if line.startswith('==='): |
| 808 | - css_class = 'diff-file text' |
| 809 | - elif (line.startswith('+++') or |
| 810 | - line.startswith('---')): |
| 811 | - css_class = 'diff-header text' |
| 812 | - elif line.startswith('@@'): |
| 813 | - css_class = 'diff-chunk text' |
| 814 | - elif line.startswith('+'): |
| 815 | - css_class = 'diff-added text' |
| 816 | - elif line.startswith('-'): |
| 817 | - css_class = 'diff-removed text' |
| 818 | - elif line.startswith('#'): |
| 819 | - # This doesn't occur in normal unified diffs, but does |
| 820 | - # appear in merge directives, which use text/x-diff or |
| 821 | - # text/x-patch. |
| 822 | - css_class = 'diff-comment text' |
| 823 | - else: |
| 824 | - css_class = 'text' |
| 825 | - result.append( |
| 826 | - '<td class="%s">%s</td>' % (css_class, escape(line))) |
| 827 | - result.append('</tr>') |
| 828 | - |
| 829 | - result.append('</table>') |
| 830 | - return ''.join(result) |
| 831 | - |
| 832 | - _css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-]+') |
| 833 | - |
| 834 | - def css_id(self, prefix=None): |
| 835 | - """Return a CSS compliant id. |
| 836 | - |
| 837 | - The id may contain letters, numbers, and hyphens. The first |
| 838 | - character must be a letter. Unsupported characters are converted |
| 839 | - to hyphens. Multiple characters are replaced by a single hyphen. The |
| 840 | - letter 'j' will start the id if the string's first character is not a |
| 841 | - letter. |
| 842 | - |
| 843 | - :param prefix: an optional string to prefix to the id. It can be |
| 844 | - used to ensure that the start of the id is predicable. |
| 845 | - """ |
| 846 | - if prefix is not None: |
| 847 | - raw_text = prefix + self._stringtoformat |
| 848 | - else: |
| 849 | - raw_text = self._stringtoformat |
| 850 | - id_ = self._css_id_strip_pattern.sub('-', raw_text) |
| 851 | - if id_[0] in '-0123456789': |
| 852 | - # 'j' is least common starting character in technical usage; |
| 853 | - # engineers love 'z', 'q', and 'y'. |
| 854 | - return 'j' + id_ |
| 855 | - else: |
| 856 | - return id_ |
| 857 | - |
| 858 | - def oops_id(self): |
| 859 | - """Format an OOPS ID for display.""" |
| 860 | - if not getUtility(ILaunchBag).developer: |
| 861 | - # We only linkify OOPS IDs for Launchpad developers. |
| 862 | - return self._stringtoformat |
| 863 | - |
| 864 | - root_url = config.launchpad.oops_root_url |
| 865 | - url = root_url + self._stringtoformat |
| 866 | - return '<a href="%s">%s</a>' % (url, self._stringtoformat) |
| 867 | - |
| 868 | - def traverse(self, name, furtherPath): |
| 869 | - if name == 'nl_to_br': |
| 870 | - return self.nl_to_br() |
| 871 | - elif name == 'escape': |
| 872 | - return self.escape() |
| 873 | - elif name == 'lower': |
| 874 | - return self.lower() |
| 875 | - elif name == 'break-long-words': |
| 876 | - return self.break_long_words() |
| 877 | - elif name == 'text-to-html': |
| 878 | - return self.text_to_html() |
| 879 | - elif name == 'nice_pre': |
| 880 | - return self.nice_pre() |
| 881 | - elif name == 'email-to-html': |
| 882 | - return self.email_to_html() |
| 883 | - elif name == 'obfuscate-email': |
| 884 | - return self.obfuscate_email() |
| 885 | - elif name == 'linkify-email': |
| 886 | - return self.linkify_email() |
| 887 | - elif name == 'shorten': |
| 888 | - if len(furtherPath) == 0: |
| 889 | - raise TraversalError( |
| 890 | - "you need to traverse a number after fmt:shorten") |
| 891 | - maxlength = int(furtherPath.pop()) |
| 892 | - return self.shorten(maxlength) |
| 893 | - elif name == 'ellipsize': |
| 894 | - if len(furtherPath) == 0: |
| 895 | - raise TraversalError( |
| 896 | - "you need to traverse a number after fmt:ellipsize") |
| 897 | - maxlength = int(furtherPath.pop()) |
| 898 | - return self.ellipsize(maxlength) |
| 899 | - elif name == 'diff': |
| 900 | - return self.format_diff() |
| 901 | - elif name == 'css-id': |
| 902 | - if len(furtherPath) > 0: |
| 903 | - return self.css_id(furtherPath.pop()) |
| 904 | - else: |
| 905 | - return self.css_id() |
| 906 | - elif name == 'oops-id': |
| 907 | - return self.oops_id() |
| 908 | - else: |
| 909 | - raise TraversalError(name) |
| 910 | - |
| 911 | - |
| 912 | class PermissionRequiredQuery: |
| 913 | """Check if the logged in user has a given permission on a given object. |
| 914 | |
| 915 | |
| 916 | === modified file 'lib/canonical/launchpad/webapp/tests/test_tales.py' |
| 917 | --- lib/canonical/launchpad/webapp/tests/test_tales.py 2010-05-17 16:43:54 +0000 |
| 918 | +++ lib/canonical/launchpad/webapp/tests/test_tales.py 2010-05-21 02:35:37 +0000 |
| 919 | @@ -12,8 +12,8 @@ |
| 920 | from canonical.config import config |
| 921 | from canonical.launchpad.testing.pages import find_tags_by_class |
| 922 | from canonical.launchpad.webapp.interfaces import ILaunchBag |
| 923 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 924 | from canonical.testing import DatabaseFunctionalLayer |
| 925 | +from lp.app.browser.stringformatter import FormattersAPI |
| 926 | from lp.testing import TestCase |
| 927 | |
| 928 | |
| 929 | @@ -112,7 +112,7 @@ |
| 930 | into paragraphs, which are separated by one or more blank lines. |
| 931 | Paragraphs are yielded as a list of lines in the paragraph. |
| 932 | |
| 933 | - >>> from canonical.launchpad.webapp.tales import split_paragraphs |
| 934 | + >>> from lp.app.browser.stringformatter import split_paragraphs |
| 935 | >>> for paragraph in split_paragraphs('\na\nb\n\nc\nd\n\n\n'): |
| 936 | ... print paragraph |
| 937 | ['a', 'b'] |
| 938 | @@ -127,7 +127,7 @@ |
| 939 | lets us do that. |
| 940 | |
| 941 | >>> import re |
| 942 | - >>> from canonical.launchpad.webapp.tales import re_substitute |
| 943 | + >>> from lp.app.browser.stringformatter import re_substitute |
| 944 | |
| 945 | >>> def match_func(match): |
| 946 | ... return '[%s]' % match.group() |
| 947 | @@ -147,7 +147,7 @@ |
| 948 | 15 characters, but will break on as little as 7 characters if |
| 949 | there is a suitable non-alphanumeric character to break after. |
| 950 | |
| 951 | - >>> from canonical.launchpad.webapp.tales import add_word_breaks |
| 952 | + >>> from lp.app.browser.stringformatter import add_word_breaks |
| 953 | |
| 954 | >>> print add_word_breaks('abcdefghijklmnop') |
| 955 | abcdefghijklmno<wbr></wbr>p |
| 956 | @@ -170,7 +170,7 @@ |
| 957 | add word breaks to the long words. It will not add breaks inside HTML |
| 958 | tags. Only words longer than 20 characters will have breaks added. |
| 959 | |
| 960 | - >>> from canonical.launchpad.webapp.tales import break_long_words |
| 961 | + >>> from lp.app.browser.stringformatter import break_long_words |
| 962 | |
| 963 | >>> print break_long_words('1234567890123456') |
| 964 | 1234567890123456 |
| 965 | |
| 966 | === modified file 'lib/lp/app/browser/configure.zcml' |
| 967 | --- lib/lp/app/browser/configure.zcml 2010-01-12 20:52:19 +0000 |
| 968 | +++ lib/lp/app/browser/configure.zcml 2010-05-21 02:35:37 +0000 |
| 969 | @@ -84,11 +84,20 @@ |
| 970 | template="../templates/launchpad-search-form.pt" |
| 971 | permission="zope.Public" /> |
| 972 | |
| 973 | - <!-- TALES watermark: namespace --> |
| 974 | - <adapter |
| 975 | - for="*" |
| 976 | - provides="zope.traversing.interfaces.IPathAdapter" |
| 977 | - factory="lp.app.browser.watermark.WatermarkTalesAdapter" |
| 978 | - name="watermark" |
| 979 | - /> |
| 980 | + <!-- TALES watermark: namespace --> |
| 981 | + <adapter |
| 982 | + for="*" |
| 983 | + provides="zope.traversing.interfaces.IPathAdapter" |
| 984 | + factory="lp.app.browser.watermark.WatermarkTalesAdapter" |
| 985 | + name="watermark" |
| 986 | + /> |
| 987 | + |
| 988 | + <!-- TALES fmt: namespace for strings --> |
| 989 | + <adapter |
| 990 | + for="basestring" |
| 991 | + provides="zope.traversing.interfaces.IPathAdapter" |
| 992 | + factory="lp.app.browser.stringformatter.FormattersAPI" |
| 993 | + name="fmt" |
| 994 | + /> |
| 995 | + |
| 996 | </configure> |
| 997 | |
| 998 | === added file 'lib/lp/app/browser/stringformatter.py' |
| 999 | --- lib/lp/app/browser/stringformatter.py 1970-01-01 00:00:00 +0000 |
| 1000 | +++ lib/lp/app/browser/stringformatter.py 2010-05-21 02:35:37 +0000 |
| 1001 | @@ -0,0 +1,830 @@ |
| 1002 | +# Copyright 2010 Canonical Ltd. This software is licensed under the |
| 1003 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
| 1004 | + |
| 1005 | +"""TALES formatter for strings.""" |
| 1006 | + |
| 1007 | +__metaclass__ = type |
| 1008 | +__all__ = [ |
| 1009 | + 'add_word_breaks', |
| 1010 | + 'break_long_words', |
| 1011 | + 'escape', |
| 1012 | + 'FormattersAPI', |
| 1013 | + 're_substitute', |
| 1014 | + 'split_paragraphs', |
| 1015 | + ] |
| 1016 | + |
| 1017 | +import cgi |
| 1018 | +import re |
| 1019 | +from xml.sax.saxutils import unescape as xml_unescape |
| 1020 | + |
| 1021 | +from zope.component import getUtility |
| 1022 | +from zope.interface import implements |
| 1023 | +from zope.traversing.interfaces import ( |
| 1024 | + ITraversable, TraversalError) |
| 1025 | + |
| 1026 | +from canonical.config import config |
| 1027 | +from canonical.launchpad.webapp import canonical_url |
| 1028 | +from canonical.launchpad.webapp.interfaces import ILaunchBag |
| 1029 | + |
| 1030 | +from lp.answers.interfaces.faq import IFAQSet |
| 1031 | +from lp.registry.interfaces.person import IPersonSet |
| 1032 | + |
| 1033 | + |
| 1034 | +def escape(text, quote=True): |
| 1035 | + """Escape text for insertion into HTML. |
| 1036 | + |
| 1037 | + Wraps `cgi.escape` to make the default to escape double-quotes. |
| 1038 | + """ |
| 1039 | + return cgi.escape(text, quote) |
| 1040 | + |
| 1041 | + |
| 1042 | +def split_paragraphs(text): |
| 1043 | + """Split text into paragraphs. |
| 1044 | + |
| 1045 | + This function yields lists of strings that represent lines of text |
| 1046 | + in each paragraph. |
| 1047 | + |
| 1048 | + Paragraphs are split by one or more blank lines. |
| 1049 | + """ |
| 1050 | + paragraph = [] |
| 1051 | + for line in text.splitlines(): |
| 1052 | + line = line.rstrip() |
| 1053 | + |
| 1054 | + # blank lines split paragraphs |
| 1055 | + if not line: |
| 1056 | + if paragraph: |
| 1057 | + yield paragraph |
| 1058 | + paragraph = [] |
| 1059 | + continue |
| 1060 | + |
| 1061 | + paragraph.append(line) |
| 1062 | + |
| 1063 | + if paragraph: |
| 1064 | + yield paragraph |
| 1065 | + |
| 1066 | + |
| 1067 | +def re_substitute(pattern, replace_match, replace_nomatch, string): |
| 1068 | + """Transform a string, replacing matched and non-matched sections. |
| 1069 | + |
| 1070 | + :param patter: a regular expression |
| 1071 | + :param replace_match: a function used to transform matches |
| 1072 | + :param replace_nomatch: a function used to transform non-matched text |
| 1073 | + :param string: the string to transform |
| 1074 | + |
| 1075 | + This function behaves similarly to re.sub() when a function is |
| 1076 | + passed as the second argument, except that the non-matching |
| 1077 | + portions of the string can be transformed by a second function. |
| 1078 | + """ |
| 1079 | + if replace_match is None: |
| 1080 | + replace_match = lambda match: match.group() |
| 1081 | + if replace_nomatch is None: |
| 1082 | + replace_nomatch = lambda text: text |
| 1083 | + parts = [] |
| 1084 | + position = 0 |
| 1085 | + for match in re.finditer(pattern, string): |
| 1086 | + if match.start() != position: |
| 1087 | + parts.append(replace_nomatch(string[position:match.start()])) |
| 1088 | + parts.append(replace_match(match)) |
| 1089 | + position = match.end() |
| 1090 | + remainder = string[position:] |
| 1091 | + if remainder: |
| 1092 | + parts.append(replace_nomatch(remainder)) |
| 1093 | + return ''.join(parts) |
| 1094 | + |
| 1095 | + |
| 1096 | +def next_word_chunk(word, pos, minlen, maxlen): |
| 1097 | + """Return the next chunk of the word of length between minlen and maxlen. |
| 1098 | + |
| 1099 | + Shorter word chunks are preferred, preferably ending in a non |
| 1100 | + alphanumeric character. The index of the end of the chunk is also |
| 1101 | + returned. |
| 1102 | + |
| 1103 | + This function treats HTML entities in the string as single |
| 1104 | + characters. The string should not include HTML tags. |
| 1105 | + """ |
| 1106 | + nchars = 0 |
| 1107 | + endpos = pos |
| 1108 | + while endpos < len(word): |
| 1109 | + # advance by one character |
| 1110 | + if word[endpos] == '&': |
| 1111 | + # make sure we grab the entity as a whole |
| 1112 | + semicolon = word.find(';', endpos) |
| 1113 | + assert semicolon >= 0, 'badly formed entity: %r' % word[endpos:] |
| 1114 | + endpos = semicolon + 1 |
| 1115 | + else: |
| 1116 | + endpos += 1 |
| 1117 | + nchars += 1 |
| 1118 | + if nchars >= maxlen: |
| 1119 | + # stop if we've reached the maximum chunk size |
| 1120 | + break |
| 1121 | + if nchars >= minlen and not word[endpos-1].isalnum(): |
| 1122 | + # stop if we've reached the minimum chunk size and the last |
| 1123 | + # character wasn't alphanumeric. |
| 1124 | + break |
| 1125 | + return word[pos:endpos], endpos |
| 1126 | + |
| 1127 | + |
| 1128 | +def add_word_breaks(word): |
| 1129 | + """Insert manual word breaks into a string. |
| 1130 | + |
| 1131 | + The word may be entity escaped, but is not expected to contain |
| 1132 | + any HTML tags. |
| 1133 | + |
| 1134 | + Breaks are inserted at least every 7 to 15 characters, |
| 1135 | + preferably after puctuation. |
| 1136 | + """ |
| 1137 | + broken = [] |
| 1138 | + pos = 0 |
| 1139 | + while pos < len(word): |
| 1140 | + chunk, pos = next_word_chunk(word, pos, 7, 15) |
| 1141 | + broken.append(chunk) |
| 1142 | + return '<wbr></wbr>'.join(broken) |
| 1143 | + |
| 1144 | + |
| 1145 | +break_text_pat = re.compile(r''' |
| 1146 | + (?P<tag> |
| 1147 | + <[^>]*> |
| 1148 | + ) | |
| 1149 | + (?P<longword> |
| 1150 | + (?<![^\s<>])(?:[^\s<>&]|&[^;]*;){20,} |
| 1151 | + ) |
| 1152 | +''', re.VERBOSE) |
| 1153 | + |
| 1154 | + |
| 1155 | +def break_long_words(text): |
| 1156 | + """Add word breaks to long words in a run of text. |
| 1157 | + |
| 1158 | + The text may contain entity references or HTML tags. |
| 1159 | + """ |
| 1160 | + def replace(match): |
| 1161 | + if match.group('tag'): |
| 1162 | + return match.group() |
| 1163 | + elif match.group('longword'): |
| 1164 | + return add_word_breaks(match.group()) |
| 1165 | + else: |
| 1166 | + raise AssertionError('text matched but neither named group found') |
| 1167 | + return break_text_pat.sub(replace, text) |
| 1168 | + |
| 1169 | + |
| 1170 | +class FormattersAPI: |
| 1171 | + """Adapter from strings to HTML formatted text.""" |
| 1172 | + |
| 1173 | + implements(ITraversable) |
| 1174 | + |
| 1175 | + def __init__(self, stringtoformat): |
| 1176 | + self._stringtoformat = stringtoformat |
| 1177 | + |
| 1178 | + def nl_to_br(self): |
| 1179 | + """Quote HTML characters, then replace newlines with <br /> tags.""" |
| 1180 | + return cgi.escape(self._stringtoformat).replace('\n','<br />\n') |
| 1181 | + |
| 1182 | + def escape(self): |
| 1183 | + return escape(self._stringtoformat) |
| 1184 | + |
| 1185 | + def break_long_words(self): |
| 1186 | + """Add manual word breaks to long words.""" |
| 1187 | + return break_long_words(cgi.escape(self._stringtoformat)) |
| 1188 | + |
| 1189 | + @staticmethod |
| 1190 | + def _substitute_matchgroup_for_spaces(match): |
| 1191 | + """Return a string made up of ' ' for each character in the |
| 1192 | + first match group. |
| 1193 | + |
| 1194 | + Used when replacing leading spaces with nbsps. |
| 1195 | + |
| 1196 | + There must be only one match group. |
| 1197 | + """ |
| 1198 | + groups = match.groups() |
| 1199 | + assert len(groups) == 1 |
| 1200 | + return ' ' * len(groups[0]) |
| 1201 | + |
| 1202 | + @staticmethod |
| 1203 | + def _split_url_and_trailers(url): |
| 1204 | + """Given a URL return a tuple of the URL and punctuation trailers. |
| 1205 | + |
| 1206 | + :return: an unescaped url, an unescaped trailer. |
| 1207 | + """ |
| 1208 | + # The text will already have been cgi escaped. We temporarily |
| 1209 | + # unescape it so that we can strip common trailing characters |
| 1210 | + # that aren't part of the URL. |
| 1211 | + url = xml_unescape(url) |
| 1212 | + match = FormattersAPI._re_url_trailers.search(url) |
| 1213 | + if match: |
| 1214 | + trailers = match.group(1) |
| 1215 | + url = url[:-len(trailers)] |
| 1216 | + else: |
| 1217 | + trailers = '' |
| 1218 | + return url, trailers |
| 1219 | + |
| 1220 | + @staticmethod |
| 1221 | + def _linkify_bug_number(text, bugnum, trailers=''): |
| 1222 | + # Don't look up the bug or display anything about the bug, just |
| 1223 | + # linkify to the general bug url. |
| 1224 | + url = '/bugs/%s' % bugnum |
| 1225 | + # The text will have already been cgi escaped. |
| 1226 | + return '<a href="%s">%s</a>%s' % (url, text, trailers) |
| 1227 | + |
| 1228 | + @staticmethod |
| 1229 | + def _linkify_substitution(match): |
| 1230 | + if match.group('bug') is not None: |
| 1231 | + return FormattersAPI._linkify_bug_number( |
| 1232 | + match.group('bug'), match.group('bugnum')) |
| 1233 | + elif match.group('url') is not None: |
| 1234 | + # The text will already have been cgi escaped. We temporarily |
| 1235 | + # unescape it so that we can strip common trailing characters |
| 1236 | + # that aren't part of the URL. |
| 1237 | + url = match.group('url') |
| 1238 | + url, trailers = FormattersAPI._split_url_and_trailers(url) |
| 1239 | + # We use nofollow for these links to reduce the value of |
| 1240 | + # adding spam URLs to our comments; it's a way of moderately |
| 1241 | + # devaluing the return on effort for spammers that consider |
| 1242 | + # using Launchpad. |
| 1243 | + return '<a rel="nofollow" href="%s">%s</a>%s' % ( |
| 1244 | + cgi.escape(url, quote=True), |
| 1245 | + add_word_breaks(cgi.escape(url)), |
| 1246 | + cgi.escape(trailers)) |
| 1247 | + elif match.group('faq') is not None: |
| 1248 | + # This is *BAD*. We shouldn't be doing database lookups to |
| 1249 | + # linkify text. |
| 1250 | + text = match.group('faq') |
| 1251 | + faqnum = match.group('faqnum') |
| 1252 | + faqset = getUtility(IFAQSet) |
| 1253 | + faq = faqset.getFAQ(faqnum) |
| 1254 | + if not faq: |
| 1255 | + return text |
| 1256 | + url = canonical_url(faq) |
| 1257 | + return '<a href="%s">%s</a>' % (url, text) |
| 1258 | + elif match.group('oops') is not None: |
| 1259 | + text = match.group('oops') |
| 1260 | + |
| 1261 | + if not getUtility(ILaunchBag).developer: |
| 1262 | + return text |
| 1263 | + |
| 1264 | + root_url = config.launchpad.oops_root_url |
| 1265 | + url = root_url + match.group('oopscode') |
| 1266 | + return '<a href="%s">%s</a>' % (url, text) |
| 1267 | + elif match.group('lpbranchurl') is not None: |
| 1268 | + lp_url = match.group('lpbranchurl') |
| 1269 | + path = match.group('branch') |
| 1270 | + lp_url, trailers = FormattersAPI._split_url_and_trailers(lp_url) |
| 1271 | + path, trailers = FormattersAPI._split_url_and_trailers(path) |
| 1272 | + if path.isdigit(): |
| 1273 | + return FormattersAPI._linkify_bug_number( |
| 1274 | + lp_url, path, trailers) |
| 1275 | + url = '/+branch/%s' % path |
| 1276 | + return '<a href="%s">%s</a>%s' % ( |
| 1277 | + cgi.escape(url, quote=True), |
| 1278 | + cgi.escape(lp_url), |
| 1279 | + cgi.escape(trailers)) |
| 1280 | + elif match.group("clbug") is not None: |
| 1281 | + # 'clbug' matches Ubuntu changelog format bugs. 'bugnumbers' is |
| 1282 | + # all of the bug numbers, that look something like "#1234, #434". |
| 1283 | + # 'leader' is the 'LP: ' bit at the beginning. |
| 1284 | + bug_parts = [] |
| 1285 | + # Split the bug numbers into multiple bugs. |
| 1286 | + splitted = re.split("(,(?:\s|<br\s*/>)+)", |
| 1287 | + match.group("bugnumbers")) + [""] |
| 1288 | + for bug_id, spacer in zip(splitted[::2], splitted[1::2]): |
| 1289 | + bug_parts.append(FormattersAPI._linkify_bug_number( |
| 1290 | + bug_id, bug_id.lstrip("#"))) |
| 1291 | + bug_parts.append(spacer) |
| 1292 | + return match.group("leader") + "".join(bug_parts) |
| 1293 | + else: |
| 1294 | + raise AssertionError("Unknown pattern matched.") |
| 1295 | + |
| 1296 | + # match whitespace at the beginning of a line |
| 1297 | + _re_leadingspace = re.compile(r'^(\s+)') |
| 1298 | + |
| 1299 | + # From RFC 3986 ABNF for URIs: |
| 1300 | + # |
| 1301 | + # URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] |
| 1302 | + # hier-part = "//" authority path-abempty |
| 1303 | + # / path-absolute |
| 1304 | + # / path-rootless |
| 1305 | + # / path-empty |
| 1306 | + # |
| 1307 | + # authority = [ userinfo "@" ] host [ ":" port ] |
| 1308 | + # userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) |
| 1309 | + # host = IP-literal / IPv4address / reg-name |
| 1310 | + # reg-name = *( unreserved / pct-encoded / sub-delims ) |
| 1311 | + # port = *DIGIT |
| 1312 | + # |
| 1313 | + # path-abempty = *( "/" segment ) |
| 1314 | + # path-absolute = "/" [ segment-nz *( "/" segment ) ] |
| 1315 | + # path-rootless = segment-nz *( "/" segment ) |
| 1316 | + # path-empty = 0<pchar> |
| 1317 | + # |
| 1318 | + # segment = *pchar |
| 1319 | + # segment-nz = 1*pchar |
| 1320 | + # pchar = unreserved / pct-encoded / sub-delims / ":" / "@" |
| 1321 | + # |
| 1322 | + # query = *( pchar / "/" / "?" ) |
| 1323 | + # fragment = *( pchar / "/" / "?" ) |
| 1324 | + # |
| 1325 | + # unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" |
| 1326 | + # pct-encoded = "%" HEXDIG HEXDIG |
| 1327 | + # sub-delims = "!" / "$" / "&" / "'" / "(" / ")" |
| 1328 | + # / "*" / "+" / "," / ";" / "=" |
| 1329 | + # |
| 1330 | + # We only match a set of known scheme names too. We don't handle |
| 1331 | + # IP-literal either. |
| 1332 | + # |
| 1333 | + # We will simplify "unreserved / pct-encoded / sub-delims" as the |
| 1334 | + # following regular expression: |
| 1335 | + # [-a-zA-Z0-9._~%!$&'()*+,;=] |
| 1336 | + # |
| 1337 | + # We also require that the path-rootless form not begin with a |
| 1338 | + # colon to avoid matching strings like "http::foo" (to avoid bug |
| 1339 | + # #40255). |
| 1340 | + # |
| 1341 | + # The path-empty pattern is not matched either, due to false |
| 1342 | + # positives. |
| 1343 | + # |
| 1344 | + # Some allowed URI punctuation characters will be trimmed if they |
| 1345 | + # appear at the end of the URI since they may be incidental in the |
| 1346 | + # flow of the text. |
| 1347 | + # |
| 1348 | + # apport has at one time produced query strings containing sqaure |
| 1349 | + # braces (that are not percent-encoded). In RFC 2986 they seem to be |
| 1350 | + # allowed by section 2.2 "Reserved Characters", yet section 3.4 |
| 1351 | + # "Query" appears to provide a strict definition of the query string |
| 1352 | + # that would forbid square braces. Either way, links with |
| 1353 | + # non-percent-encoded square braces are being used on Launchpad so |
| 1354 | + # it's probably best to accomodate them. |
| 1355 | + |
| 1356 | + # Match urls or bugs or oopses. |
| 1357 | + _re_linkify = re.compile(r''' |
| 1358 | + (?P<url> |
| 1359 | + \b |
| 1360 | + (?:about|gopher|http|https|sftp|news|ftp|mailto|file|irc|jabber) |
| 1361 | + : |
| 1362 | + (?: |
| 1363 | + (?: |
| 1364 | + # "//" authority path-abempty |
| 1365 | + // |
| 1366 | + (?: # userinfo |
| 1367 | + [%(unreserved)s:]* |
| 1368 | + @ |
| 1369 | + )? |
| 1370 | + (?: # host |
| 1371 | + \d+\.\d+\.\d+\.\d+ | |
| 1372 | + [%(unreserved)s]* |
| 1373 | + ) |
| 1374 | + (?: # port |
| 1375 | + : \d* |
| 1376 | + )? |
| 1377 | + (?: / [%(unreserved)s:@]* )* |
| 1378 | + ) | (?: |
| 1379 | + # path-absolute |
| 1380 | + / |
| 1381 | + (?: [%(unreserved)s:@]+ |
| 1382 | + (?: / [%(unreserved)s:@]* )* )? |
| 1383 | + ) | (?: |
| 1384 | + # path-rootless |
| 1385 | + [%(unreserved)s@] |
| 1386 | + [%(unreserved)s:@]* |
| 1387 | + (?: / [%(unreserved)s:@]* )* |
| 1388 | + ) |
| 1389 | + ) |
| 1390 | + (?: # query |
| 1391 | + \? |
| 1392 | + [%(unreserved)s:@/\?\[\]]* |
| 1393 | + )? |
| 1394 | + (?: # fragment |
| 1395 | + \# |
| 1396 | + [%(unreserved)s:@/\?]* |
| 1397 | + )? |
| 1398 | + ) | |
| 1399 | + (?P<clbug> |
| 1400 | + \b(?P<leader>lp:(\s|<br\s*/>)+) |
| 1401 | + (?P<bugnumbers>\#\d+(,(\s|<br\s*/>)+\#\d+)* |
| 1402 | + ) |
| 1403 | + ) | |
| 1404 | + (?P<bug> |
| 1405 | + \bbug(?:[\s=-]|<br\s*/>)*(?:\#|report|number\.?|num\.?|no\.?)?(?:[\s=-]|<br\s*/>)* |
| 1406 | + 0*(?P<bugnum>\d+) |
| 1407 | + ) | |
| 1408 | + (?P<faq> |
| 1409 | + \bfaq(?:[\s=-]|<br\s*/>)*(?:\#|item|number\.?|num\.?|no\.?)?(?:[\s=-]|<br\s*/>)* |
| 1410 | + 0*(?P<faqnum>\d+) |
| 1411 | + ) | |
| 1412 | + (?P<oops> |
| 1413 | + \boops\s*-?\s* |
| 1414 | + (?P<oopscode> \d* [a-z]+ \d+) |
| 1415 | + ) | |
| 1416 | + (?P<lpbranchurl> |
| 1417 | + \blp:(?:///|/)? |
| 1418 | + (?P<branch>[%(unreserved)s][%(unreserved)s/]*) |
| 1419 | + ) |
| 1420 | + ''' % {'unreserved': "-a-zA-Z0-9._~%!$&'()*+,;="}, |
| 1421 | + re.IGNORECASE | re.VERBOSE) |
| 1422 | + |
| 1423 | + # a pattern to match common trailing punctuation for URLs that we |
| 1424 | + # don't want to include in the link. |
| 1425 | + _re_url_trailers = re.compile(r'([,.?:);>]+)$') |
| 1426 | + |
| 1427 | + def text_to_html(self): |
| 1428 | + """Quote text according to DisplayingParagraphsOfText.""" |
| 1429 | + # This is based on the algorithm in the |
| 1430 | + # DisplayingParagraphsOfText spec, but is a little more |
| 1431 | + # complicated. |
| 1432 | + |
| 1433 | + # 1. Blank lines are used to detect paragraph boundaries. |
| 1434 | + # 2. Two lines are considered to be part of the same logical line |
| 1435 | + # only if the first is between 60 and 80 characters and the |
| 1436 | + # second does not begin with white space. |
| 1437 | + # 3. Use <br /> to split logical lines within a paragraph. |
| 1438 | + output = [] |
| 1439 | + first_para = True |
| 1440 | + for para in split_paragraphs(self._stringtoformat): |
| 1441 | + if not first_para: |
| 1442 | + output.append('\n') |
| 1443 | + first_para = False |
| 1444 | + output.append('<p>') |
| 1445 | + first_line = True |
| 1446 | + for line in para: |
| 1447 | + if not first_line: |
| 1448 | + output.append('<br />\n') |
| 1449 | + first_line = False |
| 1450 | + # escape ampersands, etc in text |
| 1451 | + line = cgi.escape(line) |
| 1452 | + # convert leading space in logical line to non-breaking space |
| 1453 | + line = self._re_leadingspace.sub( |
| 1454 | + self._substitute_matchgroup_for_spaces, line) |
| 1455 | + output.append(line) |
| 1456 | + output.append('</p>') |
| 1457 | + |
| 1458 | + text = ''.join(output) |
| 1459 | + |
| 1460 | + # Linkify the text. |
| 1461 | + text = re_substitute(self._re_linkify, self._linkify_substitution, |
| 1462 | + break_long_words, text) |
| 1463 | + |
| 1464 | + return text |
| 1465 | + |
| 1466 | + def nice_pre(self): |
| 1467 | + """<pre>, except the browser knows it is allowed to break long lines |
| 1468 | + |
| 1469 | + Note that CSS will eventually have a property to specify this |
| 1470 | + behaviour, but we want this now. To do this we need to use the mozilla |
| 1471 | + specific -moz-pre-wrap value of the white-space property. We try to |
| 1472 | + fall back for IE by using the IE specific word-wrap property. |
| 1473 | + |
| 1474 | + TODO: Test IE compatibility. StuartBishop 20041118 |
| 1475 | + """ |
| 1476 | + if not self._stringtoformat: |
| 1477 | + return self._stringtoformat |
| 1478 | + else: |
| 1479 | + linkified_text = re_substitute(self._re_linkify, |
| 1480 | + self._linkify_substitution, break_long_words, |
| 1481 | + cgi.escape(self._stringtoformat)) |
| 1482 | + return '<pre class="wrap">%s</pre>' % linkified_text |
| 1483 | + |
| 1484 | + # Match lines that start with one or more quote symbols followed |
| 1485 | + # by a space. Quote symbols are commonly '|', or '>'; they are |
| 1486 | + # used for quoting passages from another email. Both '>> ' and |
| 1487 | + # '> > ' are valid quoting sequences. |
| 1488 | + # The dpkg version is used for exceptional cases where it |
| 1489 | + # is better to not assume '|' is a start of a quoted passage. |
| 1490 | + _re_quoted = re.compile('^(([|] ?)+|(> ?)+)') |
| 1491 | + _re_dpkg_quoted = re.compile('^(> ?)+ ') |
| 1492 | + |
| 1493 | + # Match blocks that start as signatures or PGP inclusions. |
| 1494 | + _re_include = re.compile('^<p>(--<br />|-----BEGIN PGP)') |
| 1495 | + |
| 1496 | + def email_to_html(self): |
| 1497 | + """text_to_html and hide signatures and full-quoted emails. |
| 1498 | + |
| 1499 | + This method wraps inclusions like signatures and PGP blocks in |
| 1500 | + <span class="foldable"></span> tags. Quoted passages are wrapped |
| 1501 | + <span class="foldable-quoted"></span> tags. The tags identify the |
| 1502 | + extra content in the message to the presentation layer. CSS and |
| 1503 | + JavaScript may use this markup to control the content's display |
| 1504 | + behaviour. |
| 1505 | + """ |
| 1506 | + start_fold_markup = '<span class="foldable">' |
| 1507 | + start_fold_quoted_markup = '<span class="foldable-quoted">' |
| 1508 | + end_fold_markup = '%s\n</span></p>' |
| 1509 | + re_quoted = self._re_quoted |
| 1510 | + re_include = self._re_include |
| 1511 | + output = [] |
| 1512 | + in_fold = False |
| 1513 | + in_quoted = False |
| 1514 | + in_false_paragraph = False |
| 1515 | + |
| 1516 | + def is_quoted(line): |
| 1517 | + """Test that a line is a quote and not Python. |
| 1518 | + |
| 1519 | + Note that passages may be wrongly be interpreted as Python |
| 1520 | + because they start with '>>> '. The function does not check |
| 1521 | + that next and previous lines of text consistently uses '>>> ' |
| 1522 | + as Python would. |
| 1523 | + """ |
| 1524 | + python_block = '>>> ' |
| 1525 | + return (not line.startswith(python_block) |
| 1526 | + and re_quoted.match(line) is not None) |
| 1527 | + |
| 1528 | + def strip_leading_p_tag(line): |
| 1529 | + """Return the characters after the paragraph mark (<p>). |
| 1530 | + |
| 1531 | + The caller must be certain the line starts with a paragraph mark. |
| 1532 | + """ |
| 1533 | + assert line.startswith('<p>'), ( |
| 1534 | + "The line must start with a paragraph mark (<p>).") |
| 1535 | + return line[3:] |
| 1536 | + |
| 1537 | + def strip_trailing_p_tag(line): |
| 1538 | + """Return the characters before the line paragraph mark (</p>). |
| 1539 | + |
| 1540 | + The caller must be certain the line ends with a paragraph mark. |
| 1541 | + """ |
| 1542 | + assert line.endswith('</p>'), ( |
| 1543 | + "The line must end with a paragraph mark (</p>).") |
| 1544 | + return line[:-4] |
| 1545 | + |
| 1546 | + for line in self.text_to_html().split('\n'): |
| 1547 | + if 'Desired=<wbr></wbr>Unknown/' in line and not in_fold: |
| 1548 | + # When we see a evidence of dpkg output, we switch the |
| 1549 | + # quote matching rules. We do not assume lines that start |
| 1550 | + # with a pipe are quoted passages. dpkg output is often |
| 1551 | + # reformatted by users and tools. When we see the dpkg |
| 1552 | + # output header, we change the rules regardless of if the |
| 1553 | + # lines that follow are legitimate. |
| 1554 | + re_quoted = self._re_dpkg_quoted |
| 1555 | + elif not in_fold and re_include.match(line) is not None: |
| 1556 | + # This line is a paragraph with a signature or PGP inclusion. |
| 1557 | + # Start a foldable paragraph. |
| 1558 | + in_fold = True |
| 1559 | + line = '<p>%s%s' % (start_fold_markup, |
| 1560 | + strip_leading_p_tag(line)) |
| 1561 | + elif (not in_fold and line.startswith('<p>') |
| 1562 | + and is_quoted(strip_leading_p_tag(line))): |
| 1563 | + # The paragraph starts with quoted marks. |
| 1564 | + # Start a foldable quoted paragraph. |
| 1565 | + in_fold = True |
| 1566 | + line = '<p>%s%s' % ( |
| 1567 | + start_fold_quoted_markup, strip_leading_p_tag(line)) |
| 1568 | + elif not in_fold and is_quoted(line): |
| 1569 | + # This line in the paragraph is quoted. |
| 1570 | + # Start foldable quoted lines in a paragraph. |
| 1571 | + in_quoted = True |
| 1572 | + in_fold = True |
| 1573 | + output.append(start_fold_quoted_markup) |
| 1574 | + else: |
| 1575 | + # This line is continues the current state. |
| 1576 | + # This line is not a transition. |
| 1577 | + pass |
| 1578 | + |
| 1579 | + # We must test line starts and ends in separate blocks to |
| 1580 | + # close the rare single line that is foldable. |
| 1581 | + if in_fold and line.endswith('</p>') and in_false_paragraph: |
| 1582 | + # The line ends with a false paragraph in a PGP signature. |
| 1583 | + # Restore the line break to join with the next paragraph. |
| 1584 | + line = '%s<br />\n<br />' % strip_trailing_p_tag(line) |
| 1585 | + elif (in_quoted and self._re_quoted.match(line) is None): |
| 1586 | + # The line is not quoted like the previous line. |
| 1587 | + # End fold before we append this line. |
| 1588 | + in_fold = False |
| 1589 | + in_quoted = False |
| 1590 | + output.append("</span>\n") |
| 1591 | + elif in_fold and line.endswith('</p>'): |
| 1592 | + # The line is quoted or an inclusion, and ends the paragraph. |
| 1593 | + # End the fold before the close paragraph mark. |
| 1594 | + in_fold = False |
| 1595 | + in_quoted = False |
| 1596 | + line = end_fold_markup % strip_trailing_p_tag(line) |
| 1597 | + elif in_false_paragraph and line.startswith('<p>'): |
| 1598 | + # This line continues a PGP signature, but starts a paragraph. |
| 1599 | + # Remove the paragraph to join with the previous paragraph. |
| 1600 | + in_false_paragraph = False |
| 1601 | + line = strip_leading_p_tag(line) |
| 1602 | + else: |
| 1603 | + # This line is continues the current state. |
| 1604 | + # This line is not a transition. |
| 1605 | + pass |
| 1606 | + |
| 1607 | + if in_fold and 'PGP SIGNATURE' in line: |
| 1608 | + # PGP signature blocks are split into two paragraphs |
| 1609 | + # by the text_to_html. The foldable feature works with |
| 1610 | + # a single paragraph, so we merge this paragraph with |
| 1611 | + # the next one. |
| 1612 | + in_false_paragraph = True |
| 1613 | + |
| 1614 | + output.append(line) |
| 1615 | + return '\n'.join(output) |
| 1616 | + |
| 1617 | + # This is a regular expression that matches email address embedded in |
| 1618 | + # text. It is not RFC 2821 compliant, nor does it need to be. This |
| 1619 | + # expression strives to identify probable email addresses so that they |
| 1620 | + # can be obfuscated when viewed by unauthenticated users. See |
| 1621 | + # http://www.email-unlimited.com/stuff/email_address_validator.htm |
| 1622 | + |
| 1623 | + # localnames do not have [&?%!@<>,;:`|{}()#*^~ ] in practice |
| 1624 | + # (regardless of RFC 2821) because they conflict with other systems. |
| 1625 | + # See https://lists.ubuntu.com |
| 1626 | + # /mailman/private/launchpad-reviews/2007-June/006081.html |
| 1627 | + |
| 1628 | + # This verson of the re is more than 5x faster that the orginal |
| 1629 | + # version used in ftest/test_tales.testObfuscateEmail. |
| 1630 | + _re_email = re.compile(r""" |
| 1631 | + \b[a-zA-Z0-9._/="'+-]{1,64}@ # The localname. |
| 1632 | + [a-zA-Z][a-zA-Z0-9-]{1,63} # The hostname. |
| 1633 | + \.[a-zA-Z0-9.-]{1,251}\b # Dot starts one or more domains. |
| 1634 | + """, re.VERBOSE) # ' <- font-lock turd |
| 1635 | + |
| 1636 | + def obfuscate_email(self): |
| 1637 | + """Obfuscate an email address if there's no authenticated user. |
| 1638 | + |
| 1639 | + The email address is obfuscated as <email address hidden>. |
| 1640 | + |
| 1641 | + This formatter is intended to hide possible email addresses from |
| 1642 | + unauthenticated users who view this text on the Web. Run this before |
| 1643 | + the text is converted to html because text-to-html and email-to-html |
| 1644 | + will insert markup into the address. eg. |
| 1645 | + foo/fmt:obfuscate-email/fmt:email-to-html |
| 1646 | + |
| 1647 | + The pattern used to identify an email address is not 2822. It strives |
| 1648 | + to match any possible email address embedded in the text. For example, |
| 1649 | + mailto:person@domain.dom and http://person:password@domain.dom both |
| 1650 | + match, though the http match is in fact not an email address. |
| 1651 | + """ |
| 1652 | + if getUtility(ILaunchBag).user is not None: |
| 1653 | + return self._stringtoformat |
| 1654 | + text = self._re_email.sub( |
| 1655 | + r'<email address hidden>', self._stringtoformat) |
| 1656 | + text = text.replace( |
| 1657 | + "<<email address hidden>>", "<email address hidden>") |
| 1658 | + return text |
| 1659 | + |
| 1660 | + def linkify_email(self, preloaded_person_data=None): |
| 1661 | + """Linkify any email address recognised in Launchpad. |
| 1662 | + |
| 1663 | + If an email address is recognised as one registered in Launchpad, |
| 1664 | + it is linkified to point to the profile page for that person. |
| 1665 | + |
| 1666 | + Note that someone could theoretically register any old email |
| 1667 | + address in Launchpad and then have it linkified. This may or not |
| 1668 | + may be a concern but is noted here for posterity anyway. |
| 1669 | + """ |
| 1670 | + text = self._stringtoformat |
| 1671 | + |
| 1672 | + matches = re.finditer(self._re_email, text) |
| 1673 | + for match in matches: |
| 1674 | + address = match.group() |
| 1675 | + person = None |
| 1676 | + # First try to find the person required in the preloaded person |
| 1677 | + # data dictionary. |
| 1678 | + if preloaded_person_data is not None: |
| 1679 | + person = preloaded_person_data.get(address, None) |
| 1680 | + else: |
| 1681 | + # No pre-loaded data -> we need to perform a database lookup. |
| 1682 | + person = getUtility(IPersonSet).getByEmail(address) |
| 1683 | + # Only linkify if person exists and does not want to hide |
| 1684 | + # their email addresses. |
| 1685 | + if person is not None and not person.hide_email_addresses: |
| 1686 | + # Circular dependancies now. Should be resolved by moving the |
| 1687 | + # object image display api. |
| 1688 | + from canonical.launchpad.webapp.tales import ObjectImageDisplayAPI |
| 1689 | + css_sprite = ObjectImageDisplayAPI(person).sprite_css() |
| 1690 | + text = text.replace( |
| 1691 | + address, '<a href="%s" class="%s">%s</a>' % ( |
| 1692 | + canonical_url(person), css_sprite, address)) |
| 1693 | + |
| 1694 | + return text |
| 1695 | + |
| 1696 | + def lower(self): |
| 1697 | + """Return the string in lowercase""" |
| 1698 | + return self._stringtoformat.lower() |
| 1699 | + |
| 1700 | + def shorten(self, maxlength): |
| 1701 | + """Use like tal:content="context/foo/fmt:shorten/60".""" |
| 1702 | + if len(self._stringtoformat) > maxlength: |
| 1703 | + return '%s...' % self._stringtoformat[:maxlength-3] |
| 1704 | + else: |
| 1705 | + return self._stringtoformat |
| 1706 | + |
| 1707 | + def ellipsize(self, maxlength): |
| 1708 | + """Use like tal:content="context/foo/fmt:ellipsize/60".""" |
| 1709 | + if len(self._stringtoformat) > maxlength: |
| 1710 | + length = (maxlength - 3) / 2 |
| 1711 | + return ( |
| 1712 | + self._stringtoformat[:maxlength - length - 3] + '...' + |
| 1713 | + self._stringtoformat[-length:]) |
| 1714 | + else: |
| 1715 | + return self._stringtoformat |
| 1716 | + |
| 1717 | + def format_diff(self): |
| 1718 | + """Format the string as a diff in a table with line numbers.""" |
| 1719 | + # Trim off trailing carriage returns. |
| 1720 | + text = self._stringtoformat.rstrip('\n') |
| 1721 | + if len(text) == 0: |
| 1722 | + return text |
| 1723 | + result = ['<table class="diff">'] |
| 1724 | + |
| 1725 | + max_format_lines = config.diff.max_format_lines |
| 1726 | + for row, line in enumerate(text.splitlines()[:max_format_lines]): |
| 1727 | + result.append('<tr>') |
| 1728 | + result.append('<td class="line-no">%s</td>' % (row+1)) |
| 1729 | + if line.startswith('==='): |
| 1730 | + css_class = 'diff-file text' |
| 1731 | + elif (line.startswith('+++') or |
| 1732 | + line.startswith('---')): |
| 1733 | + css_class = 'diff-header text' |
| 1734 | + elif line.startswith('@@'): |
| 1735 | + css_class = 'diff-chunk text' |
| 1736 | + elif line.startswith('+'): |
| 1737 | + css_class = 'diff-added text' |
| 1738 | + elif line.startswith('-'): |
| 1739 | + css_class = 'diff-removed text' |
| 1740 | + elif line.startswith('#'): |
| 1741 | + # This doesn't occur in normal unified diffs, but does |
| 1742 | + # appear in merge directives, which use text/x-diff or |
| 1743 | + # text/x-patch. |
| 1744 | + css_class = 'diff-comment text' |
| 1745 | + else: |
| 1746 | + css_class = 'text' |
| 1747 | + result.append( |
| 1748 | + '<td class="%s">%s</td>' % (css_class, escape(line))) |
| 1749 | + result.append('</tr>') |
| 1750 | + |
| 1751 | + result.append('</table>') |
| 1752 | + return ''.join(result) |
| 1753 | + |
| 1754 | + _css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-]+') |
| 1755 | + |
| 1756 | + def css_id(self, prefix=None): |
| 1757 | + """Return a CSS compliant id. |
| 1758 | + |
| 1759 | + The id may contain letters, numbers, and hyphens. The first |
| 1760 | + character must be a letter. Unsupported characters are converted |
| 1761 | + to hyphens. Multiple characters are replaced by a single hyphen. The |
| 1762 | + letter 'j' will start the id if the string's first character is not a |
| 1763 | + letter. |
| 1764 | + |
| 1765 | + :param prefix: an optional string to prefix to the id. It can be |
| 1766 | + used to ensure that the start of the id is predicable. |
| 1767 | + """ |
| 1768 | + if prefix is not None: |
| 1769 | + raw_text = prefix + self._stringtoformat |
| 1770 | + else: |
| 1771 | + raw_text = self._stringtoformat |
| 1772 | + id_ = self._css_id_strip_pattern.sub('-', raw_text) |
| 1773 | + if id_[0] in '-0123456789': |
| 1774 | + # 'j' is least common starting character in technical usage; |
| 1775 | + # engineers love 'z', 'q', and 'y'. |
| 1776 | + return 'j' + id_ |
| 1777 | + else: |
| 1778 | + return id_ |
| 1779 | + |
| 1780 | + def oops_id(self): |
| 1781 | + """Format an OOPS ID for display.""" |
| 1782 | + if not getUtility(ILaunchBag).developer: |
| 1783 | + # We only linkify OOPS IDs for Launchpad developers. |
| 1784 | + return self._stringtoformat |
| 1785 | + |
| 1786 | + root_url = config.launchpad.oops_root_url |
| 1787 | + url = root_url + self._stringtoformat |
| 1788 | + return '<a href="%s">%s</a>' % (url, self._stringtoformat) |
| 1789 | + |
| 1790 | + def traverse(self, name, furtherPath): |
| 1791 | + if name == 'nl_to_br': |
| 1792 | + return self.nl_to_br() |
| 1793 | + elif name == 'escape': |
| 1794 | + return self.escape() |
| 1795 | + elif name == 'lower': |
| 1796 | + return self.lower() |
| 1797 | + elif name == 'break-long-words': |
| 1798 | + return self.break_long_words() |
| 1799 | + elif name == 'text-to-html': |
| 1800 | + return self.text_to_html() |
| 1801 | + elif name == 'nice_pre': |
| 1802 | + return self.nice_pre() |
| 1803 | + elif name == 'email-to-html': |
| 1804 | + return self.email_to_html() |
| 1805 | + elif name == 'obfuscate-email': |
| 1806 | + return self.obfuscate_email() |
| 1807 | + elif name == 'linkify-email': |
| 1808 | + return self.linkify_email() |
| 1809 | + elif name == 'shorten': |
| 1810 | + if len(furtherPath) == 0: |
| 1811 | + raise TraversalError( |
| 1812 | + "you need to traverse a number after fmt:shorten") |
| 1813 | + maxlength = int(furtherPath.pop()) |
| 1814 | + return self.shorten(maxlength) |
| 1815 | + elif name == 'ellipsize': |
| 1816 | + if len(furtherPath) == 0: |
| 1817 | + raise TraversalError( |
| 1818 | + "you need to traverse a number after fmt:ellipsize") |
| 1819 | + maxlength = int(furtherPath.pop()) |
| 1820 | + return self.ellipsize(maxlength) |
| 1821 | + elif name == 'diff': |
| 1822 | + return self.format_diff() |
| 1823 | + elif name == 'css-id': |
| 1824 | + if len(furtherPath) > 0: |
| 1825 | + return self.css_id(furtherPath.pop()) |
| 1826 | + else: |
| 1827 | + return self.css_id() |
| 1828 | + elif name == 'oops-id': |
| 1829 | + return self.oops_id() |
| 1830 | + else: |
| 1831 | + raise TraversalError(name) |
| 1832 | |
| 1833 | === added directory 'lib/lp/app/doc' |
| 1834 | === renamed file 'lib/canonical/launchpad/doc/displaying-paragraphs-of-text.txt' => 'lib/lp/app/doc/displaying-paragraphs-of-text.txt' |
| 1835 | --- lib/canonical/launchpad/doc/displaying-paragraphs-of-text.txt 2009-08-06 14:10:00 +0000 |
| 1836 | +++ lib/lp/app/doc/displaying-paragraphs-of-text.txt 2010-05-21 02:35:37 +0000 |
| 1837 | @@ -225,45 +225,40 @@ |
| 1838 | ... '#123\n' |
| 1839 | ... 'debug #52\n') |
| 1840 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1841 | - <p><a href="/bugs/123" title="No such bug">bug 123</a><br /> |
| 1842 | - <a href="/bugs/123" title="No such bug">bug 123</a><br /> |
| 1843 | - <a href="/bugs/123" title="No such bug">bug #123</a><br /> |
| 1844 | - <a href="/bugs/123" title="No such bug">bug number 123</a><br /> |
| 1845 | - <a href="/bugs/123" title="No such bug">bug number. 123</a><br /> |
| 1846 | - <a href="/bugs/123" title="No such bug">bug num 123</a><br /> |
| 1847 | - <a href="/bugs/123" title="No such bug">bug num. 123</a><br /> |
| 1848 | - <a href="/bugs/123" title="No such bug">bug no 123</a><br /> |
| 1849 | - <a href="/bugs/123" title="No such bug">bug report 123</a><br /> |
| 1850 | - <a href="/bugs/123" title="No such bug">bug no. 123</a><br /> |
| 1851 | - <a href="/bugs/123" title="No such bug">bug#123</a><br /> |
| 1852 | - <a href="/bugs/123" title="No such bug">bug-123</a><br /> |
| 1853 | - <a href="/bugs/123" title="No such bug">bug-report-123</a><br /> |
| 1854 | - <a href="/bugs/123" title="No such bug">bug=123</a><br /> |
| 1855 | - <a href="/bugs/123" title="No such bug">bug<br /> |
| 1856 | - #123</a><br /> |
| 1857 | + <p><a href="/bugs/123">bug 123</a><br /> |
| 1858 | + <a href="/bugs/123">bug 123</a><br /> |
| 1859 | + <a href="/bugs/123">bug #123</a><br /> |
| 1860 | + <a href="/bugs/123">bug number 123</a><br /> |
| 1861 | + <a href="/bugs/123">bug number. 123</a><br /> |
| 1862 | + <a href="/bugs/123">bug num 123</a><br /> |
| 1863 | + <a href="/bugs/123">bug num. 123</a><br /> |
| 1864 | + <a href="/bugs/123">bug no 123</a><br /> |
| 1865 | + <a href="/bugs/123">bug report 123</a><br /> |
| 1866 | + <a href="/bugs/123">bug no. 123</a><br /> |
| 1867 | + <a href="/bugs/123">bug#123</a><br /> |
| 1868 | + <a href="/bugs/123">bug-123</a><br /> |
| 1869 | + <a href="/bugs/123">bug-report-123</a><br /> |
| 1870 | + <a href="/bugs/123">bug=123</a><br /> |
| 1871 | + <a href="/bugs/123">bug<br /> #123</a><br /> |
| 1872 | debug #52</p> |
| 1873 | |
| 1874 | >>> text = ( |
| 1875 | ... 'bug 123\n' |
| 1876 | ... 'bug 123\n') |
| 1877 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1878 | - <p><a href="/bugs/123" title="No such bug">bug 123</a><br /> |
| 1879 | - <a href="/bugs/123" title="No such bug">bug 123</a></p> |
| 1880 | + <p><a href="/bugs/123">bug 123</a><br /> |
| 1881 | + <a href="/bugs/123">bug 123</a></p> |
| 1882 | |
| 1883 | >>> text = ( |
| 1884 | ... 'bug 1234\n' |
| 1885 | ... 'bug 123\n') |
| 1886 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1887 | - <p><a href="/bugs/1234" title="No such bug">bug 1234</a><br /> |
| 1888 | - <a href="/bugs/123" title="No such bug">bug 123</a></p> |
| 1889 | + <p><a href="/bugs/1234">bug 1234</a><br /> |
| 1890 | + <a href="/bugs/123">bug 123</a></p> |
| 1891 | |
| 1892 | >>> text = 'bug 0123\n' |
| 1893 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1894 | - <p><a href="/bugs/123" title="No such bug">bug 0123</a></p> |
| 1895 | - |
| 1896 | - >>> text = 'bug 2\n' |
| 1897 | - >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1898 | - <p><a href="/bugs/2" title="Blackhole Trash folder">bug 2</a></p> |
| 1899 | + <p><a href="/bugs/123">bug 0123</a></p> |
| 1900 | |
| 1901 | |
| 1902 | We linkify bugs that are in the Ubuntu convention for referring to bugs in |
| 1903 | @@ -271,26 +266,26 @@ |
| 1904 | |
| 1905 | >>> text = 'LP: #123.\n' |
| 1906 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1907 | - <p>LP: <a href="/bugs/123" title="No such bug">#123</a>.</p> |
| 1908 | + <p>LP: <a href="/bugs/123">#123</a>.</p> |
| 1909 | |
| 1910 | Works with multiple bugs: |
| 1911 | |
| 1912 | >>> text = 'LP: #123, #2.\n' |
| 1913 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1914 | - <p>LP: <a href="/bugs/123" title="No such bug">#123</a>, <a href="/bugs/2" title="Blackhole Trash folder">#2</a>.</p> |
| 1915 | + <p>LP: <a href="/bugs/123">#123</a>, <a href="/bugs/2">#2</a>.</p> |
| 1916 | |
| 1917 | And with lower case 'lp' too: |
| 1918 | |
| 1919 | >>> text = 'lp: #123, #2.\n' |
| 1920 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1921 | - <p>lp: <a href="/bugs/123" title="No such bug">#123</a>, <a href="/bugs/2" title="Blackhole Trash folder">#2</a>.</p> |
| 1922 | + <p>lp: <a href="/bugs/123">#123</a>, <a href="/bugs/2">#2</a>.</p> |
| 1923 | |
| 1924 | Even line breaks cannot stop the power of bug linking: |
| 1925 | |
| 1926 | >>> text = 'LP: #123,\n#2.\n' |
| 1927 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1928 | - <p>LP: <a href="/bugs/123" title="No such bug">#123</a>,<br /> |
| 1929 | - <a href="/bugs/2" title="Blackhole Trash folder">#2</a>.</p> |
| 1930 | + <p>LP: <a href="/bugs/123">#123</a>,<br /> |
| 1931 | + <a href="/bugs/2">#2</a>.</p> |
| 1932 | |
| 1933 | To check a private bug, we need to log in and set a bug to be private. |
| 1934 | |
| 1935 | @@ -309,11 +304,11 @@ |
| 1936 | |
| 1937 | >>> firefox_crashes.unsubscribe(current_user, current_user) |
| 1938 | |
| 1939 | -A private bug has a title "private bug". |
| 1940 | +A private bug is still linked as no check is made on the actual bug. |
| 1941 | |
| 1942 | >>> text = 'bug 6\n' |
| 1943 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1944 | - <p><a href="/bugs/6" title="private bug">bug 6</a></p> |
| 1945 | + <p><a href="/bugs/6">bug 6</a></p> |
| 1946 | |
| 1947 | |
| 1948 | == FAQ references == |
| 1949 | @@ -379,14 +374,14 @@ |
| 1950 | |
| 1951 | >>> text = 'lp:1234' |
| 1952 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1953 | - <p><a href="/bugs/1234" title="No such bug">lp:1234</a></p> |
| 1954 | + <p><a href="/bugs/1234">lp:1234</a></p> |
| 1955 | |
| 1956 | We are even smart enough to notice the trailing punctuation gunk and separate |
| 1957 | that from the link. |
| 1958 | |
| 1959 | >>> text = 'lp:1234,' |
| 1960 | >>> print test_tales('foo/fmt:text-to-html', foo=text) |
| 1961 | - <p><a href="/bugs/1234" title="No such bug">lp:1234</a>,</p> |
| 1962 | + <p><a href="/bugs/1234">lp:1234</a>,</p> |
| 1963 | |
| 1964 | |
| 1965 | == OOPS references == |
| 1966 | @@ -481,7 +476,7 @@ |
| 1967 | we want to replace a variable number of spaces with the same number of |
| 1968 | entities. |
| 1969 | |
| 1970 | - >>> from canonical.launchpad.webapp.tales import FormattersAPI |
| 1971 | + >>> from lp.app.browser.stringformatter import FormattersAPI |
| 1972 | >>> import re |
| 1973 | >>> matchobj = re.match('foo(.*)bar', 'fooX Ybar') |
| 1974 | >>> matchobj.groups() |
| 1975 | @@ -529,7 +524,7 @@ |
| 1976 | >>> sorted(matchobj.groupdict().items()) |
| 1977 | [('bug', 'xxxx'), ('bugnum', '2'), ('url', None)] |
| 1978 | >>> FormattersAPI._linkify_substitution(matchobj) |
| 1979 | - u'<a href="/bugs/2" title="Blackhole Trash folder">xxxx</a>' |
| 1980 | + '<a href="/bugs/2">xxxx</a>' |
| 1981 | |
| 1982 | When the bugnum doesn't match any bug, we still get a link, but get a message |
| 1983 | in the link's title. |
| 1984 | @@ -539,15 +534,4 @@ |
| 1985 | >>> sorted(matchobj.groupdict().items()) |
| 1986 | [('bug', 'xxxx'), ('bugnum', '2000'), ('url', None)] |
| 1987 | >>> FormattersAPI._linkify_substitution(matchobj) |
| 1988 | - '<a href="/bugs/2000" title="No such bug">xxxx</a>' |
| 1989 | - |
| 1990 | -When the bug is private, we still get a link, but get a message in the link's |
| 1991 | -title. |
| 1992 | - |
| 1993 | - >>> matchobj = re.match( |
| 1994 | - ... '(?P<bug>xxxx)?(?P<bugnum>6)?(?P<url>yyy)?', 'xxxx6') |
| 1995 | - >>> sorted(matchobj.groupdict().items()) |
| 1996 | - [('bug', 'xxxx'), ('bugnum', '6'), ('url', None)] |
| 1997 | - >>> FormattersAPI._linkify_substitution(matchobj) |
| 1998 | - '<a href="/bugs/6" title="private bug">xxxx</a>' |
| 1999 | - |
| 2000 | + '<a href="/bugs/2000">xxxx</a>' |
| 2001 | |
| 2002 | === modified file 'lib/lp/bugs/browser/bug.py' |
| 2003 | --- lib/lp/bugs/browser/bug.py 2010-04-15 10:58:02 +0000 |
| 2004 | +++ lib/lp/bugs/browser/bug.py 2010-05-21 02:35:37 +0000 |
| 2005 | @@ -68,7 +68,7 @@ |
| 2006 | custom_widget, redirection, stepthrough, structured) |
| 2007 | from canonical.launchpad.webapp.authorization import check_permission |
| 2008 | from canonical.launchpad.webapp.interfaces import ICanonicalUrlData |
| 2009 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 2010 | +from lp.app.browser.stringformatter import FormattersAPI |
| 2011 | |
| 2012 | from canonical.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription |
| 2013 | from canonical.widgets.bug import BugTagsWidget |
| 2014 | |
| 2015 | === modified file 'lib/lp/bugs/tests/test_bugs_webservice.py' |
| 2016 | --- lib/lp/bugs/tests/test_bugs_webservice.py 2010-04-08 11:40:09 +0000 |
| 2017 | +++ lib/lp/bugs/tests/test_bugs_webservice.py 2010-05-21 02:35:37 +0000 |
| 2018 | @@ -57,7 +57,7 @@ |
| 2019 | self.assertEqual( |
| 2020 | self.findBugDescription(response), |
| 2021 | u'<p>Useless bugs are useless. ' |
| 2022 | - 'See <a href="/bugs/%d" title="generic">Bug %d</a>.</p>' % ( |
| 2023 | + 'See <a href="/bugs/%d">Bug %d</a>.</p>' % ( |
| 2024 | self.bug_one.id, self.bug_one.id)) |
| 2025 | |
| 2026 | def test_PATCH_xhtml_representation(self): |
| 2027 | @@ -76,7 +76,7 @@ |
| 2028 | |
| 2029 | self.assertEqual( |
| 2030 | self.findBugDescription(response), |
| 2031 | - u'<p>See <a href="/bugs/%d" title="generic">bug %d</a></p>' % ( |
| 2032 | + u'<p>See <a href="/bugs/%d">bug %d</a></p>' % ( |
| 2033 | self.bug_one.id, self.bug_one.id)) |
| 2034 | |
| 2035 | |
| 2036 | |
| 2037 | === modified file 'lib/lp/code/browser/branchmergeproposal.py' |
| 2038 | --- lib/lp/code/browser/branchmergeproposal.py 2010-02-26 09:54:20 +0000 |
| 2039 | +++ lib/lp/code/browser/branchmergeproposal.py 2010-05-21 02:35:37 +0000 |
| 2040 | @@ -63,11 +63,11 @@ |
| 2041 | from canonical.launchpad.webapp.breadcrumb import Breadcrumb |
| 2042 | from canonical.launchpad.webapp.interfaces import IPrimaryContext |
| 2043 | from canonical.launchpad.webapp.menu import NavigationMenu |
| 2044 | -from canonical.launchpad.webapp.tales import ( |
| 2045 | - DateTimeFormatterAPI, FormattersAPI) |
| 2046 | +from canonical.launchpad.webapp.tales import DateTimeFormatterAPI |
| 2047 | from canonical.widgets.lazrjs import ( |
| 2048 | TextAreaEditorWidget, vocabulary_to_choice_edit_items) |
| 2049 | |
| 2050 | +from lp.app.browser.stringformatter import FormattersAPI |
| 2051 | from lp.code.adapters.branch import BranchMergeProposalDelta |
| 2052 | from lp.code.browser.codereviewcomment import CodeReviewDisplayComment |
| 2053 | from lp.code.enums import ( |
| 2054 | |
| 2055 | === modified file 'lib/lp/registry/browser/person.py' |
| 2056 | --- lib/lp/registry/browser/person.py 2010-05-19 17:03:40 +0000 |
| 2057 | +++ lib/lp/registry/browser/person.py 2010-05-21 02:35:37 +0000 |
| 2058 | @@ -223,13 +223,14 @@ |
| 2059 | from canonical.launchpad.webapp.menu import get_current_view |
| 2060 | from canonical.launchpad.webapp.publisher import LaunchpadView |
| 2061 | from canonical.launchpad.webapp.tales import ( |
| 2062 | - DateTimeFormatterAPI, FormattersAPI, PersonFormatterAPI) |
| 2063 | + DateTimeFormatterAPI, PersonFormatterAPI) |
| 2064 | from lazr.uri import URI, InvalidURIError |
| 2065 | |
| 2066 | from canonical.launchpad import _ |
| 2067 | |
| 2068 | from canonical.lazr.utils import smartquote |
| 2069 | |
| 2070 | +from lp.app.browser.stringformatter import FormattersAPI |
| 2071 | from lp.answers.interfaces.questioncollection import IQuestionSet |
| 2072 | from lp.answers.interfaces.questionsperson import IQuestionsPerson |
| 2073 | |
| 2074 | |
| 2075 | === modified file 'lib/lp/registry/doc/sourcepackage.txt' |
| 2076 | --- lib/lp/registry/doc/sourcepackage.txt 2010-02-25 07:42:32 +0000 |
| 2077 | +++ lib/lp/registry/doc/sourcepackage.txt 2010-05-21 02:35:37 +0000 |
| 2078 | @@ -351,32 +351,10 @@ |
| 2079 | >>> from lp.soyuz.browser.sourcepackagerelease import ( |
| 2080 | ... linkify_bug_numbers) |
| 2081 | >>> linkify_bug_numbers("LP: #10") |
| 2082 | - u'LP: <a href="/bugs/10" title="another test bug">#10</a>' |
| 2083 | - |
| 2084 | - >>> linkify_bug_numbers("LP: #10") |
| 2085 | - u'LP: <a href="/bugs/10" title="another test bug">#10</a>' |
| 2086 | - |
| 2087 | - >>> linkify_bug_numbers("LP:#10") |
| 2088 | - u'LP:<a href="/bugs/10" title="another test bug">#10</a>' |
| 2089 | - |
| 2090 | - >>> linkify_bug_numbers("LP: #999") |
| 2091 | - 'LP: <a href="/bugs/999" title="No such bug">#999</a>' |
| 2092 | - |
| 2093 | - >>> linkify_bug_numbers("garbage") |
| 2094 | - 'garbage' |
| 2095 | - |
| 2096 | - >>> linkify_bug_numbers("LP: #10, #7") |
| 2097 | - u'LP: <a href="/bugs/10" title="another test bug">#10</a>, |
| 2098 | - <a href="/bugs/7" title="A test bug">#7</a>' |
| 2099 | - |
| 2100 | - >>> linkify_bug_numbers("LP: #10 LP: #10") |
| 2101 | - u'LP: <a href="/bugs/10" title="another test bug">#10</a> |
| 2102 | - LP: <a href="/bugs/10" title="another test bug">#10</a>' |
| 2103 | - |
| 2104 | -The regex is case-insensitive, so "lp: #nnn" also works. |
| 2105 | - |
| 2106 | - >>> linkify_bug_numbers("lp: #10") |
| 2107 | - u'lp: <a href="/bugs/10" title="another test bug">#10</a>' |
| 2108 | + 'LP: <a href="/bugs/10">#10</a>' |
| 2109 | + |
| 2110 | +More complete examples of the bug linkification can be found in the doctest |
| 2111 | +displaying-paragraphs-of-text. |
| 2112 | |
| 2113 | |
| 2114 | Comparing Sourcepackages |
| 2115 | |
| 2116 | === modified file 'lib/lp/registry/feed/announcement.py' |
| 2117 | --- lib/lp/registry/feed/announcement.py 2010-02-17 12:13:47 +0000 |
| 2118 | +++ lib/lp/registry/feed/announcement.py 2010-05-21 02:35:37 +0000 |
| 2119 | @@ -26,7 +26,7 @@ |
| 2120 | from lp.registry.interfaces.product import IProduct |
| 2121 | from lp.registry.interfaces.projectgroup import IProjectGroup |
| 2122 | from canonical.launchpad.interfaces.launchpad import IFeedsApplication |
| 2123 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 2124 | +from lp.app.browser.stringformatter import FormattersAPI |
| 2125 | from canonical.lazr.feed import ( |
| 2126 | FeedBase, FeedEntry, FeedPerson, FeedTypedData) |
| 2127 | |
| 2128 | |
| 2129 | === modified file 'lib/lp/soyuz/browser/archive.py' |
| 2130 | --- lib/lp/soyuz/browser/archive.py 2010-05-10 15:50:23 +0000 |
| 2131 | +++ lib/lp/soyuz/browser/archive.py 2010-05-21 02:35:37 +0000 |
| 2132 | @@ -93,7 +93,7 @@ |
| 2133 | from canonical.launchpad.webapp.batching import BatchNavigator |
| 2134 | from canonical.launchpad.webapp.interfaces import ICanonicalUrlData |
| 2135 | from canonical.launchpad.webapp.menu import structured, NavigationMenu |
| 2136 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 2137 | +from lp.app.browser.stringformatter import FormattersAPI |
| 2138 | from canonical.widgets import ( |
| 2139 | LabeledMultiCheckBoxWidget, PlainMultiCheckBoxWidget) |
| 2140 | from canonical.widgets.itemswidgets import ( |
| 2141 | |
| 2142 | === modified file 'lib/lp/soyuz/browser/sourcepackagerelease.py' |
| 2143 | --- lib/lp/soyuz/browser/sourcepackagerelease.py 2010-01-12 15:39:36 +0000 |
| 2144 | +++ lib/lp/soyuz/browser/sourcepackagerelease.py 2010-05-21 02:35:37 +0000 |
| 2145 | @@ -18,7 +18,7 @@ |
| 2146 | import re |
| 2147 | |
| 2148 | from canonical.launchpad.webapp import LaunchpadView |
| 2149 | -from canonical.launchpad.webapp.tales import FormattersAPI |
| 2150 | +from lp.app.browser.stringformatter import FormattersAPI |
| 2151 | |
| 2152 | |
| 2153 | def extract_bug_numbers(text): |
| 2154 | |
| 2155 | === modified file 'lib/lp/soyuz/stories/soyuz/xx-sourcepackage-changelog.txt' |
| 2156 | --- lib/lp/soyuz/stories/soyuz/xx-sourcepackage-changelog.txt 2010-05-19 05:47:50 +0000 |
| 2157 | +++ lib/lp/soyuz/stories/soyuz/xx-sourcepackage-changelog.txt 2010-05-21 02:35:37 +0000 |
| 2158 | @@ -104,8 +104,8 @@ |
| 2159 | <pre ... id="alsa-utils_1.0.9a-4ubuntu1">alsa-utils (1.0.9a-4ubuntu1) ... |
| 2160 | <BLANKLINE> |
| 2161 | ... |
| 2162 | - LP: <a href="/bugs/10" title="another test bug">#10</a> |
| 2163 | - LP: <a href="/bugs/999" title="No such bug">#999</a> |
| 2164 | + LP: <a href="/bugs/10">#10</a> |
| 2165 | + LP: <a href="/bugs/999">#999</a> |
| 2166 | LP: #badid |
| 2167 | ... |
| 2168 |

Fly-by: could we actually insert the memcache tags? That would (a) be exciting, and (b) prove that these are the necessary changes to make that happen.