Merge lp:~thumper/launchpad/bug-linkification into lp:launchpad

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
Reviewer Review Type Date Requested Status
Francis J. Lacoste (community) 2010-05-18 Approve on 2010-05-21
Review via email: mp+25488@code.launchpad.net

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-paragraphs-of-text

To post a comment you must log in.
Gary Poster (gary) wrote :

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.

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 '&nbsp;' 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 '&nbsp;' * 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('^(([|] ?)+|(&gt; ?)+)')
572- _re_dpkg_quoted = re.compile('^(&gt; ?)+ ')
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 = '&gt;&gt;&gt; '
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 '&nbsp;' 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 '&nbsp;' * 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('^(([|] ?)+|(&gt; ?)+)')
1491+ _re_dpkg_quoted = re.compile('^(&gt; ?)+ ')
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 = '&gt;&gt;&gt; '
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 &nbsp; 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