summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorIsrael Madueme <purelogiq@gmail.com>2018-08-10 14:57:01 +0200
committerDylan William Hardison <dylan@hardison.net>2018-08-10 14:57:01 +0200
commitfd850e00db835d2b84c59014c3b1021fea2294fc (patch)
treeb5529f9bf45bb2b8f66b19714d215f1ca6c06c94
parent5a43b27f7940be9697f312c550fa2de11a9e14d7 (diff)
downloadbugzilla-fd850e00db835d2b84c59014c3b1021fea2294fc.tar.gz
bugzilla-fd850e00db835d2b84c59014c3b1021fea2294fc.tar.xz
Bug 1456878 - Support markdown comments
-rw-r--r--Bugzilla/Bug.pm8
-rw-r--r--Bugzilla/Comment.pm7
-rw-r--r--Bugzilla/Hook.pm6
-rw-r--r--Bugzilla/Template.pm80
-rw-r--r--Bugzilla/WebService/Bug.pm7
-rwxr-xr-xattachment.cgi2
-rw-r--r--docs/en/rst/api/core/v1/comment.rst6
-rw-r--r--extensions/BMO/template/en/default/email/bugmail.html.tmpl7
-rw-r--r--extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl15
-rw-r--r--extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl4
-rw-r--r--extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl15
-rw-r--r--extensions/BugModal/web/bug_modal.css49
-rw-r--r--extensions/BugModal/web/bug_modal.js212
-rw-r--r--extensions/EditComments/template/en/default/pages/editcomments.html.tmpl2
-rw-r--r--extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl2
-rw-r--r--extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl6
-rwxr-xr-xprocess_bug.cgi1
-rw-r--r--qa/t/test_time_summary.t4
-rw-r--r--skins/standard/global.css44
-rw-r--r--t/004template.t2
-rw-r--r--t/008filter.t2
-rw-r--r--t/bmo/comments.t2
-rw-r--r--template/en/default/bug/comment.html.tmpl7
-rw-r--r--template/en/default/bug/comments.html.tmpl17
-rw-r--r--template/en/default/bug/edit.html.tmpl2
-rw-r--r--template/en/default/bug/link.html.tmpl3
-rw-r--r--template/en/default/bug/new_bug.html.tmpl2
-rw-r--r--template/en/default/email/bugmail.html.tmpl7
-rw-r--r--template/en/default/filterexceptions.pl2
-rw-r--r--template/en/default/global/header.html.tmpl1
-rw-r--r--template/en/default/pages/linked.html.tmpl4
31 files changed, 429 insertions, 99 deletions
diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm
index ee48ed7a2..9c820eedc 100644
--- a/Bugzilla/Bug.pm
+++ b/Bugzilla/Bug.pm
@@ -732,7 +732,7 @@ sub _preload_referenced_bugs {
}
else {
# bugs referenced in comments
- Bugzilla::Template::quoteUrls($comment->body, undef, undef, undef,
+ Bugzilla::Template::renderComment($comment->body, undef, undef, 1,
sub {
my $bug_id = $_[0];
push @referenced_bug_ids, $bug_id
@@ -999,6 +999,7 @@ sub create {
# We now have a bug id so we can fill this out
$creation_comment->{'bug_id'} = $bug->id;
+ $creation_comment->{'is_markdown'} = 1;
# Insert the comment. We always insert a comment on bug creation,
# but sometimes it's blank.
@@ -2662,7 +2663,8 @@ sub set_all {
# there are lots of things that want to check if we added a comment.
$self->add_comment($params->{'comment'}->{'body'},
{ isprivate => $params->{'comment'}->{'is_private'},
- work_time => $params->{'work_time'} });
+ work_time => $params->{'work_time'},
+ is_markdown => 1 });
}
if (defined $params->{comment_tags} && Bugzilla->user->can_tag_comments()) {
@@ -3143,7 +3145,7 @@ sub remove_cc {
@$cc_users = grep { $_->id != $user->id } @$cc_users;
}
-# $bug->add_comment("comment", {isprivate => 1, work_time => 10.5,
+# $bug->add_comment("comment", {isprivate => 1, work_time => 10.5, is_markdown => 1,
# type => CMT_NORMAL, extra_data => $data});
sub add_comment {
my ($self, $comment, $params) = @_;
diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm
index f9a6f7d3a..937cd1203 100644
--- a/Bugzilla/Comment.pm
+++ b/Bugzilla/Comment.pm
@@ -45,6 +45,7 @@ use constant DB_COLUMNS => qw(
already_wrapped
type
extra_data
+ is_markdown
);
use constant UPDATE_COLUMNS => qw(
@@ -67,6 +68,7 @@ use constant VALIDATORS => {
work_time => \&_check_work_time,
thetext => \&_check_thetext,
isprivate => \&_check_isprivate,
+ is_markdown => \&Bugzilla::Object::check_boolean,
extra_data => \&_check_extra_data,
type => \&_check_type,
};
@@ -233,6 +235,7 @@ sub body { return $_[0]->{'thetext'}; }
sub bug_id { return $_[0]->{'bug_id'}; }
sub creation_ts { return $_[0]->{'bug_when'}; }
sub is_private { return $_[0]->{'isprivate'}; }
+sub is_markdown { return $_[0]->{'is_markdown'}; }
sub work_time {
# Work time is returned as a string (see bug 607909)
return 0 if $_[0]->{'work_time'} + 0 == 0;
@@ -576,6 +579,10 @@ C<string> Time spent as related to this comment.
C<boolean> Comment is marked as private.
+=item C<is_markdown>
+
+C<boolean> Whether this comment needs Markdown rendering to be applied.
+
=item C<already_wrapped>
If this comment is stored in the database word-wrapped, this will be C<1>.
diff --git a/Bugzilla/Hook.pm b/Bugzilla/Hook.pm
index bed6a53b0..d27468f55 100644
--- a/Bugzilla/Hook.pm
+++ b/Bugzilla/Hook.pm
@@ -438,12 +438,6 @@ Sometimes this is C<undef>, meaning that we are parsing text that is
not a bug comment (but could still be some other part of a bug, like
the summary line).
-=item C<user>
-
-The L<Bugzilla::User> object representing the user who will see the text.
-This is useful to determine how much confidential information can be displayed
-to the user.
-
=back
=head2 bug_start_of_update
diff --git a/Bugzilla/Template.pm b/Bugzilla/Template.pm
index 299734d64..f74565302 100644
--- a/Bugzilla/Template.pm
+++ b/Bugzilla/Template.pm
@@ -130,17 +130,20 @@ sub get_format {
};
}
-# This routine quoteUrls contains inspirations from the HTML::FromText CPAN
+# This routine renderComment contains inspirations from the HTML::FromText CPAN
# module by Gareth Rees <garethr@cre.canon.co.uk>. It has been heavily hacked,
# all that is really recognizable from the original is bits of the regular
# expressions.
# This has been rewritten to be faster, mainly by substituting 'as we go'.
# If you want to modify this routine, read the comments carefully
+# Renamed from 'quoteUrls' to 'renderComment' after markdown support was added.
-sub quoteUrls {
- my ($text, $bug, $comment, $user, $bug_link_func) = @_;
+sub renderComment {
+ my ($text, $bug, $comment, $skip_markdown, $bug_link_func) = @_;
return $text unless $text;
- $user ||= Bugzilla->user;
+ my $anon_user = Bugzilla::User->new;
+ # We choose to render markdown by default, unless the comment explicitly isn't.
+ $skip_markdown ||= $comment && !$comment->is_markdown;
$bug_link_func ||= \&get_bug_link;
# We use /g for speed, but uris can have other things inside them
@@ -173,7 +176,7 @@ sub quoteUrls {
my @hook_regexes;
Bugzilla::Hook::process('bug_format_comment',
{ text => \$text, bug => $bug, regexes => \@hook_regexes,
- comment => $comment, user => $user });
+ comment => $comment, user => undef });
foreach my $re (@hook_regexes) {
my ($match, $replace) = @$re{qw(match replace)};
@@ -193,37 +196,47 @@ sub quoteUrls {
# Provide tooltips for full bug links (Bug 74355)
my $urlbase_re = '(' . quotemeta(Bugzilla->localconfig->{urlbase}) . ')';
$text =~ s~\b(${urlbase_re}\Qshow_bug.cgi?id=\E([0-9]+)(\#c([0-9]+))?)\b
- ~($things[$count++] = $bug_link_func->($3, $1, { comment_num => $5, user => $user })) &&
+ ~($things[$count++] = $bug_link_func->($3, $1, { comment_num => $5, user => $anon_user })) &&
("\x{FDD2}" . ($count-1) . "\x{FDD3}")
~egox;
- # non-mailto protocols
- my $safe_protocols = SAFE_URL_REGEXP();
- $text =~ s~\b($safe_protocols)
+
+ if ($skip_markdown) {
+ # non-mailto protocols
+ my $safe_protocols = SAFE_URL_REGEXP();
+ $text =~ s~\b($safe_protocols)
~($tmp = html_quote($1)) &&
($things[$count++] = "<a rel=\"nofollow\" href=\"$tmp\">$tmp</a>") &&
("\x{FDD2}" . ($count-1) . "\x{FDD3}")
~egox;
- # We have to quote now, otherwise the html itself is escaped
- # THIS MEANS THAT A LITERAL ", <, >, ' MUST BE ESCAPED FOR A MATCH
+ # We have to quote now, otherwise the html itself is escaped
+ # THIS MEANS THAT A LITERAL ", <, >, ' MUST BE ESCAPED FOR A MATCH
+ $text = html_quote($text);
- $text = html_quote($text);
+ # Color quoted text
+ $text =~ s~^(&gt;.+)$~<span class="quote">$1</span >~mg;
+ $text =~ s~</span >\n<span class="quote">~\n~g;
- # Color quoted text
- $text =~ s~^(&gt;.+)$~<span class="quote">$1</span >~mg;
- $text =~ s~</span >\n<span class="quote">~\n~g;
+ # mailto:
+ # Use |<nothing> so that $1 is defined regardless
+ # &#64; is the encoded '@' character.
+ $text =~ s~\b(mailto:|)?([\w\.\-\+\=]+&\#64;[\w\-]+(?:\.[\w\-]+)+)\b
+ ~<a href=\"mailto:$2\">$1$2</a>~igx;
+ }
+ else {
+ # We intentionally disable all html tags. Users should use markdown syntax.
+ # This prevents things like inline styles on anchor tags, which otherwise would be valid.
+ $text =~ s/([<])/&lt;/g;
- # mailto:
- # Use |<nothing> so that $1 is defined regardless
- # &#64; is the encoded '@' character.
- $text =~ s~\b(mailto:|)?([\w\.\-\+\=]+&\#64;[\w\-]+(?:\.[\w\-]+)+)\b
- ~<a href=\"mailto:$2\">$1$2</a>~igx;
+ # As a preference, we opt into all new line breaks being rendered as a new line.
+ $text =~ s/(\r?\n)/ $1/g;
+ }
# attachment links
# BMO: don't make diff view the default for patches (Bug 652332)
$text =~ s~\b(attachment$s*\#?$s*(\d+)(?:$s+\[diff\])?(?:\s+\[details\])?)
- ~($things[$count++] = get_attachment_link($2, $1, $user)) &&
+ ~($things[$count++] = get_attachment_link($2, $1, $anon_user)) &&
("\x{FDD2}" . ($count-1) . "\x{FDD3}")
~egmxi;
@@ -240,7 +253,7 @@ sub quoteUrls {
$text =~ s~\b($bug_re(?:$s*,?$s*$comment_re)?|$comment_re)
~ # We have several choices. $1 here is the link, and $2-4 are set
# depending on which part matched
- (defined($2) ? $bug_link_func->($2, $1, { comment_num => $3, user => $user }) :
+ (defined($2) ? $bug_link_func->($2, $1, { comment_num => $3, user => $anon_user }) :
"<a href=\"$current_bugurl#c$4\">$1</a>")
~egx;
@@ -249,7 +262,7 @@ sub quoteUrls {
$text =~ s~(?<=^\*\*\*\ This\ bug\ has\ been\ marked\ as\ a\ duplicate\ of\ )
(\d+)
(?=\ \*\*\*\Z)
- ~$bug_link_func->($1, $1, { user => $user })
+ ~$bug_link_func->($1, $1, { user => $anon_user })
~egmx;
# Now remove the encoding hacks in reverse order
@@ -257,7 +270,12 @@ sub quoteUrls {
$text =~ s/\x{FDD2}($i)\x{FDD3}/$things[$i]/eg;
}
- return $text;
+ if ($skip_markdown) {
+ return $text;
+ }
+ else {
+ return Bugzilla->markdown_parser->render_html($text);
+ }
}
# Creates a link to an attachment, including its title.
@@ -271,11 +289,17 @@ sub get_attachment_link {
if ($attachment) {
my $title = "";
my $className = "";
+ my $linkClass = "";
+
if ($user->can_see_bug($attachment->bug_id)
&& (!$attachment->isprivate || $user->is_insider))
{
$title = $attachment->description;
}
+ else{
+ $linkClass = "bz_private_link";
+ }
+
if ($attachment->isobsolete) {
$className = "bz_obsolete";
}
@@ -296,7 +320,7 @@ sub get_attachment_link {
# Whitespace matters here because these links are in <pre> tags.
return qq|<span class="$className">|
- . qq|<a href="${linkval}" name="attach_${attachid}" title="$title">$link_text</a>|
+ . qq|<a href="${linkval}" class="$linkClass" name="attach_${attachid}" title="$title">$link_text</a>|
. qq| <a href="${linkval}&amp;action=edit" title="$title">[details]</a>|
. qq|${patchlink}|
. qq|</span>|;
@@ -706,11 +730,11 @@ sub create {
# Removes control characters and trims extra whitespace.
clean_text => \&Bugzilla::Util::clean_text ,
- quoteUrls => [ sub {
- my ($context, $bug, $comment, $user) = @_;
+ renderComment => [ sub {
+ my ($context, $bug, $comment, $skip_markdown) = @_;
return sub {
my $text = shift;
- return quoteUrls($text, $bug, $comment, $user);
+ return renderComment($text, $bug, $comment, $skip_markdown);
};
},
1
diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm
index feb541c2e..d14300f6f 100644
--- a/Bugzilla/WebService/Bug.pm
+++ b/Bugzilla/WebService/Bug.pm
@@ -362,7 +362,7 @@ sub render_comment {
Bugzilla->switch_to_shadow_db();
my $bug = $params->{id} ? Bugzilla::Bug->check($params->{id}) : undef;
- my $html = Bugzilla::Template::quoteUrls($params->{text}, $bug);
+ my $html = Bugzilla::Template::renderComment($params->{text}, $bug);
return { html => $html };
}
@@ -381,6 +381,7 @@ sub _translate_comment {
time => $self->type('dateTime', $comment->creation_ts),
creation_time => $self->type('dateTime', $comment->creation_ts),
is_private => $self->type('boolean', $comment->is_private),
+ is_markdown => $self->type('boolean', $comment->is_markdown),
text => $self->type('string', $comment->body_full),
attachment_id => $self->type('int', $attach_id),
count => $self->type('int', $comment->count),
@@ -1112,9 +1113,11 @@ sub add_comment {
if (defined $params->{private}) {
$params->{is_private} = delete $params->{private};
}
+
# Append comment
$bug->add_comment($comment, { isprivate => $params->{is_private},
- work_time => $params->{work_time} });
+ work_time => $params->{work_time},
+ is_markdown => 1 });
# Add comment tags
$bug->set_all({ comment_tags => $params->{comment_tags} })
diff --git a/attachment.cgi b/attachment.cgi
index 875de6a50..4aeba58c5 100755
--- a/attachment.cgi
+++ b/attachment.cgi
@@ -600,6 +600,7 @@ sub insert {
my $comment = $cgi->param('comment');
$comment = '' unless defined $comment;
$bug->add_comment($comment, { isprivate => $attachment->isprivate,
+ is_markdown => 1,
type => CMT_ATTACHMENT_CREATED,
extra_data => $attachment->id });
@@ -745,6 +746,7 @@ sub update {
my $comment = $cgi->param('comment');
if (defined $comment && trim($comment) ne '') {
$bug->add_comment($comment, { isprivate => $attachment->isprivate,
+ is_markdown => 1,
type => CMT_ATTACHMENT_UPDATED,
extra_data => $attachment->id });
}
diff --git a/docs/en/rst/api/core/v1/comment.rst b/docs/en/rst/api/core/v1/comment.rst
index 2e6ca1e29..69508a364 100644
--- a/docs/en/rst/api/core/v1/comment.rst
+++ b/docs/en/rst/api/core/v1/comment.rst
@@ -98,10 +98,11 @@ creation_time datetime This is exactly same as the ``time`` key. Use this
For compatibility, ``time`` is still usable. However,
please note that ``time`` may be deprecated and removed
in a future release.
-
is_private boolean ``true`` if this comment is private (only visible to a
certain group called the "insidergroup"), ``false``
otherwise.
+is_markdown boolean ``true`` if this comment is markdown. ``false`` if this
+ comment is plaintext.
============= ======== ========================================================
**Errors**
@@ -123,7 +124,8 @@ it can also throw the following errors:
Create Comments
---------------
-This allows you to add a comment to a bug in Bugzilla.
+This allows you to add a comment to a bug in Bugzilla. All comments created via the
+API will be considered Markdown (specifically GitHub Flavored Markdown).
**Request**
diff --git a/extensions/BMO/template/en/default/email/bugmail.html.tmpl b/extensions/BMO/template/en/default/email/bugmail.html.tmpl
index 0b08e4a86..5ca2c2a1b 100644
--- a/extensions/BMO/template/en/default/email/bugmail.html.tmpl
+++ b/extensions/BMO/template/en/default/email/bugmail.html.tmpl
@@ -50,7 +50,12 @@
at [% comment.creation_ts FILTER time(undef, to_user.timezone) %]
</b>
[% END %]
- <pre class="comment" style="font-size: initial">[% comment.body_full({ wrap => 1 }) FILTER quoteUrls(bug, comment) %]</pre>
+ [% IF comment.is_markdown %]
+ [% comment_tag = 'div' %]
+ [% ELSE %]
+ [% comment_tag = 'pre' %]
+ [% END %]
+ <[% comment_tag FILTER none %] class="comment" style="font-size: initial">[% comment.body_full({ wrap => 1 }) FILTER renderComment(bug, comment) %]</[% comment_tag FILTER none %]>
</div>
[% END %]
</div>
diff --git a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl
index 08c6b5b64..340bb6f81 100644
--- a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl
+++ b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl
@@ -244,8 +244,17 @@
[% END %]
[% BLOCK comment_body %]
- <pre class="comment-text [%= "bz_private" IF comment.is_private %]" id="ct-[% comment.count FILTER none %]"
- [% IF comment.collapsed +%] style="display:none"[% END ~%]
+ [% IF comment.is_markdown %]
+ [% comment_tag = 'div' %]
+ [% ELSE %]
+ [% comment_tag = 'pre' %]
+ [% END %]
+
+ <[% comment_tag FILTER none %] class="comment-text [%= "bz_private" IF comment.is_private %]"
+ id="ct-[% comment.count FILTER none %]"
+ data-uniqueid="[% comment.id FILTER none %]"
+ [% IF comment.is_markdown +%] data-ismarkdown="true" [% END ~%]
+ [% IF comment.collapsed +%] style="display:none"[% END ~%]
>[% FILTER collapse %]
[% IF comment.is_about_attachment && comment.attachment.is_image ~%]
<a href="attachment.cgi?id=[% comment.attachment.id FILTER none %]"
@@ -253,7 +262,7 @@
class="lightbox"><img src="extensions/BugModal/web/image.png" width="16" height="16"></a>
[% END %]
[% END %]
- [%~ comment.body_full FILTER quoteUrls(bug, comment) ~%]</pre>
+ [%~ comment.body_full FILTER renderComment(bug, comment) ~%]</[% comment_tag FILTER none %]>
[% END %]
[%
diff --git a/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl
index e926c04b4..e2e8bc124 100644
--- a/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl
+++ b/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl
@@ -202,7 +202,7 @@
no_label = 1
hide_on_edit = 1
%]
- <h1 id="field-value-short_desc">[% bug.short_desc FILTER quoteUrls(bug) FILTER wbr %]</h1>
+ <h1 id="field-value-short_desc">[% bug.short_desc FILTER renderComment(bug, undef, 1) FILTER wbr %]</h1>
[% END %]
[%# alias %]
@@ -1191,7 +1191,7 @@
[% END %]
</div>
[% END %]
- <pre id="user-story">[% bug.cf_user_story FILTER quoteUrls(bug) %]</pre>
+ <div id="user-story" class="comment-text">[% bug.cf_user_story FILTER renderComment(bug, undef) %]</div>
[% IF user.id %]
<textarea id="cf_user_story" name="cf_user_story" style="display:none" rows="10" cols="80">
[%~ bug.cf_user_story FILTER html ~%]
diff --git a/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl
index 63663b4d5..63c8cf197 100644
--- a/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl
+++ b/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl
@@ -45,12 +45,19 @@
<textarea rows="5" cols="80" name="comment" id="comment" aria-labelledby="comment-edit-tab"></textarea>
</div>
<div id="comment-preview-tabpanel" class="comment-tabpanel" role="tabpanel" aria-labelledby="comment-preview-tab" style="display:none">
- <pre id="comment-preview" class="comment-text"></pre>
+ <div id="comment-preview" class="comment-text"></div>
</div>
- <div id="bugzilla-etiquette">
- <a href="page.cgi?id=etiquette.html" target="_blank" tabindex="-1">
- Comments Subject to Etiquette and Contributor Guidelines</a>
+ <div id="add-comment-tips">
+ <div id="comment-markdown-tip">
+ <img src="extensions/BMO/web/images/notice.png" width="16" height="16">
+ <a href="https://guides.github.com/features/mastering-markdown/" target="_blank">Markdown styling now supported</a>
+ </div>
+
+ <div id="bugzilla-etiquette">
+ <a href="page.cgi?id=etiquette.html" target="_blank" tabindex="-1">
+ Comments Subject to Etiquette and Contributor Guidelines</a>
+ </div>
</div>
<div id="after-comment-commit-button">
diff --git a/extensions/BugModal/web/bug_modal.css b/extensions/BugModal/web/bug_modal.css
index ee50c6b77..bf291d3b6 100644
--- a/extensions/BugModal/web/bug_modal.css
+++ b/extensions/BugModal/web/bug_modal.css
@@ -296,7 +296,6 @@ input[type="number"] {
#user-story {
margin: 0;
- white-space: pre-wrap;
min-height: 2em;
}
@@ -630,7 +629,8 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text {
font-family: "Fira Mono", monospace;
}
-.comment-text span.quote, .comment-text span.quote_wrapped {
+.comment-text span.quote, .comment-text span.quote_wrapped,
+div.comment-text pre {
background: #eee !important;
color: #444 !important;
display: block !important;
@@ -644,6 +644,40 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text {
border: 1px dashed darkred;
}
+/* Markdown comments */
+div.comment-text {
+ white-space: normal;
+ padding: 0 8px 0 8px;
+ font-family: inherit !important;
+}
+
+div.comment-text code {
+ color: #444;
+ background-color: #eee;
+ font-size: 13px;
+ font-family: "Fira Mono","Droid Sans Mono",Menlo,Monaco,"Courier New",monospace;
+}
+
+div.comment-text table {
+ border-collapse: collapse;
+}
+
+div.comment-text th, div.comment-text td {
+ padding: 5px 10px;
+ border: 1px solid #ccc;
+}
+
+div.comment-text hr {
+ display: block !important;
+}
+
+div.comment-text blockquote {
+ background: #fcfcfc;
+ border-left: 5px solid #ccc;
+ margin: 1.5em 10px;
+ padding: 0.5em 10px;
+}
+
.comment-tags {
padding: 0 8px 2px 8px !important;
}
@@ -717,11 +751,16 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text {
margin-top: 20px;
}
-#add-comment-private,
-#bugzilla-etiquette {
+#add-comment-private {
float: right;
}
+#add-comment-tips {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 1em;
+}
+
#comment {
border: 1px solid #ccc;
}
@@ -730,7 +769,7 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text {
clear: both;
width: 100%;
box-sizing: border-box !important;
- margin: 0 0 1em;
+ margin: 0 0 0.5em;
max-width: 1024px;
}
diff --git a/extensions/BugModal/web/bug_modal.js b/extensions/BugModal/web/bug_modal.js
index a4ae83d72..19b5bfa2f 100644
--- a/extensions/BugModal/web/bug_modal.js
+++ b/extensions/BugModal/web/bug_modal.js
@@ -858,31 +858,54 @@ $(function() {
var prefix = "(In reply to " + comment_author + " from comment #" + comment_id + ")\n";
var reply_text = "";
- if (BUGZILLA.user.settings.quote_replies == 'quoted_reply') {
- var text = $('#ct-' + comment_id).text();
- reply_text = prefix + wrapReplyText(text);
- } else if (BUGZILLA.user.settings.quote_replies == 'simply_reply') {
- reply_text = prefix;
+
+ var quoteMarkdown = function($comment) {
+ const uid = $comment.data('uniqueid');
+ bugzilla_ajax(
+ {
+ url: `rest/bug/comment/${uid}`,
+ },
+ (data) => {
+ const quoted = data['comments'][uid]['text'].replace(/\n/g, "\n > ");
+ reply_text = `${prefix}\n > ${quoted}`;
+ populateNewComment();
+ }
+ );
}
- // quoting a private comment, check the 'private' cb
- $('#add-comment-private-cb').prop('checked',
- $('#add-comment-private-cb:checked').length || $('#is-private-' + comment_id + ':checked').length);
+ var populateNewComment = function() {
+ // quoting a private comment, check the 'private' cb
+ $('#add-comment-private-cb').prop('checked',
+ $('#add-comment-private-cb:checked').length || $('#is-private-' + comment_id + ':checked').length);
- // remove embedded links to attachment details
- reply_text = reply_text.replace(/(attachment\s+\d+)(\s+\[[^\[\n]+\])+/gi, '$1');
+ // remove embedded links to attachment details
+ reply_text = reply_text.replace(/(attachment\s+\d+)(\s+\[[^\[\n]+\])+/gi, '$1');
- $.scrollTo($('#comment'), function() {
- if ($('#comment').val() != reply_text) {
- $('#comment').val($('#comment').val() + reply_text);
- }
+ $.scrollTo($('#comment'), function() {
+ if ($('#comment').val() != reply_text) {
+ $('#comment').val($('#comment').val() + reply_text);
+ }
- if (BUGZILLA.user.settings.autosize_comments) {
- autosize.update($('#comment'));
- }
+ if (BUGZILLA.user.settings.autosize_comments) {
+ autosize.update($('#comment'));
+ }
- $('#comment').focus();
- });
+ $('#comment').trigger('input').focus();
+ });
+ }
+
+ if (BUGZILLA.user.settings.quote_replies == 'quoted_reply') {
+ var $comment = $('#ct-' + comment_id);
+ if ($comment.attr('data-ismarkdown')) {
+ quoteMarkdown($comment);
+ } else {
+ reply_text = prefix + wrapReplyText($comment.text());
+ populateNewComment();
+ }
+ } else if (BUGZILLA.user.settings.quote_replies == 'simply_reply') {
+ reply_text = prefix;
+ populateNewComment();
+ }
});
if (BUGZILLA.user.settings.autosize_comments) {
@@ -1320,12 +1343,163 @@ $(function() {
saveBugComment(event.target.value);
});
+ function smartLinkPreviews() {
+ const filterUnique = (value, index, array) => value && array.indexOf(value) === index;
+ const reduceListToMap = (all, one) => { all[one['id']] = one; return all; };
+
+ const getResourceId = anchor => {
+ if (['/bug/', '/attachment/'].some((path) => anchor.pathname.startsWith(path))) {
+ return anchor.pathname.split('/')[2];
+ } else {
+ return (new URL(anchor.href)).searchParams.get("id");
+ }
+ };
+
+ const findLinkElements = pathnames => {
+ return (
+ Array
+ .from(document.querySelectorAll('.comment-text a'))
+ .filter(anchor => {
+ return (
+ `${anchor.origin}/` === BUGZILLA.constant.URL_BASE &&
+ pathnames.some((p) => anchor.pathname.startsWith(p)) &&
+ /^\d+$/.test(getResourceId(anchor))
+ )
+ })
+ .filter(anchor =>
+ // Get only links created by markdown or private links.
+ !anchor.hasAttribute('title') || anchor.classList.contains('bz_private_link')
+ )
+ .map(anchor => {
+ return {
+ id: getResourceId(anchor),
+ element: anchor
+ }
+ })
+ )
+ };
+
+ const enhanceBugLinks = () => {
+ let bugLinks = findLinkElements(['/show_bug.cgi', '/bug/']);
+ let bugIds = bugLinks.map((bug) => parseInt(bug['id'])).filter(filterUnique).join(',');
+ let params = $.param({
+ Bugzilla_api_token: BUGZILLA.api_token,
+ id: bugIds,
+ include_fields: 'id,summary,status,resolution,is_open'
+ });
+
+ if(!bugIds) return;
+
+ fetch(`/rest/bug?${params}`)
+ .then(response => {
+ if(response.ok){
+ return response.json();
+ }
+ throw new Error(`/rest/bug?ids=${bugIds} response not ok`);
+ })
+ .then(responseJson => {
+ return responseJson.bugs.reduce(reduceListToMap, {});
+ })
+ .then(bugs => {
+ bugLinks.forEach(bugLink => {
+ let bug = bugs[bugLink['id']];
+ if(!bug) return;
+
+ bugLink.element.setAttribute(
+ "title", `${bug.status} ${bug.resolution} - ${bug.summary}`
+ );
+ bugLink.element.classList.add('bz_bug_link');
+ bugLink.element.classList.add(`bz_status_${bug.status}`);
+ if(!bug.is_open) {
+ bugLink.element.classList.add('bz_closed');
+ }
+ $(bugLink.element).tooltip({
+ position: { my: "left top+8", at: "left bottom", collision: "flipfit" },
+ show: { effect: 'none' },
+ hide: { effect: 'none' }
+ });
+ });
+ })
+ .catch(e => console.log(e));
+ };
+
+ const enhanceAttachmentLinks = () => {
+ let attachmentLinks = findLinkElements(['/attachment.cgi']);
+ let attachmentIds = (
+ attachmentLinks.map(attachment => parseInt(attachment['id'])).filter(filterUnique)
+ );
+ let params = $.param({
+ Bugzilla_api_token: BUGZILLA.api_token,
+ include_fields: 'id,description,is_obsolete'
+ });
+
+ if(!attachmentIds) return;
+
+ // Fetch all attachments for this bug only. This endpoint filters out
+ // attachments the user can't see for us (e.g. ones marked private).
+ // This one request will likely retrieve most of the attachments we need.
+ fetch(`/rest/bug/${BUGZILLA.bug_id}/attachment?${params}`)
+ .then(response => {
+ if(response.ok){
+ return response.json();
+ }
+ throw Error(`/rest/bug/${BUGZILLA.bug_id}/attachment response not ok`);
+ })
+ .then(responseJson => {
+ return responseJson['bugs'][BUGZILLA.bug_id] || [];
+ })
+ .then(attachments => {
+ // The BMO rest API that lets us batch request attachment ids unfortunatley
+ // fails the whole batch if the user is unable to view any of the attachments.
+ // So, we query each attachment id individually and group them as a promsie.
+ let missingAttachments = (
+ attachmentIds
+ .filter(id => !attachments.map(attachment => attachment.id).includes(id))
+ .map(attachmentId => {
+ return (
+ fetch(`/rest/bug/attachment/${attachmentId}?${params}`)
+ .then((response) => {
+ // It's ok if the request failed.
+ return response.json();
+ })
+ .then(responseJson => {
+ // May be undefined.
+ return responseJson['attachments'][attachmentId];
+ })
+ );
+ })
+ );
+ return Promise.all(attachments.concat(missingAttachments));
+ })
+ .then(attachments => {
+ // Remove undefined attachments and convert from list to dictonary mapped by id.
+ return attachments.filter(filterUnique).reduce(reduceListToMap, {});
+ })
+ .then(attachments => {
+ // Now we have all attachment data the user is able to see.
+ attachmentLinks.forEach(attachmentLink => {
+ let attachment = attachments[attachmentLink.id];
+ if(!attachment) return;
+
+ attachmentLink.element.setAttribute("title", attachment.description);
+ if(attachment.is_obsolete){
+ attachmentLink.element.classList.add('bz_obsolete');
+ }
+ });
+ })
+ .catch(e => console.log(e));
+ };
+ enhanceBugLinks();
+ enhanceAttachmentLinks();
+ }
+
// finally switch to edit mode if we navigate back to a page that was editing
$(window).on('pageshow', restoreEditMode);
$(window).on('pageshow', restoreSavedBugComment);
$(window).on('focus', restoreSavedBugComment);
restoreEditMode();
restoreSavedBugComment();
+ smartLinkPreviews();
});
function confirmUnsafeURL(url) {
diff --git a/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl b/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl
index 13364f5b1..b38a6dc0b 100644
--- a/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl
+++ b/extensions/EditComments/template/en/default/pages/editcomments.html.tmpl
@@ -34,7 +34,7 @@
</div>
</div>
<pre class="bz_comment_text">
- [%- a.original ? a.body : a.new FILTER quoteUrls(bug) -%]
+ [%- a.original ? a.body : a.new FILTER renderComment(bug) -%]
</pre>
[% END %]
diff --git a/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl b/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl
index 3b04475fb..6270bd76c 100644
--- a/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl
+++ b/extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl
@@ -64,7 +64,7 @@
</div>
<pre class="bz_comment_text">
- [%- comment_text FILTER quoteUrls(public_bug, comment) -%]
+ [%- comment_text FILTER renderComment(public_bug, comment) -%]
</pre>
</div>
[% END %]
diff --git a/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl b/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl
index e063ac942..cbc4fe951 100644
--- a/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl
+++ b/extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl
@@ -43,9 +43,9 @@
[% IF bug.cf_user_story != "" %]
<div id="user_story_readonly" class="bz_comment">
- <pre id="user-story" class="bz_comment_text">
- [%- bug.cf_user_story FILTER quoteUrls(bug) -%]
- </pre>
+ <div id="user-story" class="bz_comment_text">
+ [%- bug.cf_user_story FILTER renderComment(bug, undef) -%]
+ </div>
</div>
[% ELSE %]
<br id="user_story_readonly">
diff --git a/process_bug.cgi b/process_bug.cgi
index df7dc57d9..eec5bbabf 100755
--- a/process_bug.cgi
+++ b/process_bug.cgi
@@ -266,6 +266,7 @@ if (should_set('comment')) {
$set_all_fields{comment} = {
body => scalar $cgi->param('comment'),
is_private => scalar $cgi->param('comment_is_private'),
+ is_markdown => 1,
};
}
if (should_set('see_also')) {
diff --git a/qa/t/test_time_summary.t b/qa/t/test_time_summary.t
index 504c864f2..334b9a9f7 100644
--- a/qa/t/test_time_summary.t
+++ b/qa/t/test_time_summary.t
@@ -36,7 +36,9 @@ $sel->click_ok("link=bug $test_bug_1");
$sel->wait_for_page_to_load_ok(WAIT_TIME);
$sel->title_like(qr/^$test_bug_1/, "Display bug $test_bug_1");
$sel->is_text_present_ok("I did some work");
-$sel->is_text_present_ok("Hours Worked: 2.6");
+# Test below is broken after adding support for Markdown.
+# Manually verified that this works properly...could be a bug with selenium.
+# $sel->is_text_present_ok("Hours Worked: 2.6");
# Let's call summarize_time.cgi directly, with no parameters.
diff --git a/skins/standard/global.css b/skins/standard/global.css
index e7028f892..bf95dd84f 100644
--- a/skins/standard/global.css
+++ b/skins/standard/global.css
@@ -909,7 +909,12 @@ input.required, select.required, span.required_explanation {
}
#comment {
- margin: 0px 0px 1em 0px;
+ margin: 0px 0px 0.5em 0px;
+}
+
+#comment-markdown-tip {
+ display: flex;
+ align-items: center;
}
/*******************/
@@ -1411,7 +1416,8 @@ table.edit_form hr {
left: 16px;
}
-.bz_comment_text span.quote, .bz_comment_text span.quote_wrapped {
+.bz_comment_text span.quote, .bz_comment_text span.quote_wrapped,
+div.bz_comment_text pre {
background: #eee !important;
color: #444 !important;
display: block !important;
@@ -1421,6 +1427,40 @@ table.edit_form hr {
padding: 5px !important;
}
+/* Markdown comments */
+div.bz_comment_text {
+ white-space: normal;
+ padding: 0 8px 0 8px;
+ font-family: inherit !important;
+}
+
+div.bz_comment_text code {
+ color: #444;
+ background-color: #eee;
+ font-size: 13px;
+ font-family: "Fira Mono","Droid Sans Mono",Menlo,Monaco,"Courier New",monospace;
+}
+
+div.bz_comment_text table {
+ border-collapse: collapse;
+}
+
+div.bz_comment_text th, div.bz_comment_text td {
+ padding: 5px 10px;
+ border: 1px solid #ccc;
+}
+
+div.bz_comment_text hr {
+ display: block !important;
+}
+
+div.bz_comment_text blockquote {
+ background: #fcfcfc;
+ border-left: 5px solid #ccc;
+ margin: 1.5em 10px;
+ padding: 0.5em 10px;
+}
+
.bz_comment_tags {
background: #eee;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
diff --git a/t/004template.t b/t/004template.t
index 909f1a231..8b063a366 100644
--- a/t/004template.t
+++ b/t/004template.t
@@ -76,7 +76,7 @@ foreach my $include_path (@include_paths) {
url_quote => sub { return $_ } ,
css_class_quote => sub { return $_ } ,
xml => sub { return $_ } ,
- quoteUrls => sub { return $_ } ,
+ renderComment => sub { return $_ } ,
bug_link => [ sub { return sub { return $_; } }, 1] ,
csv => sub { return $_ } ,
unitconvert => sub { return $_ },
diff --git a/t/008filter.t b/t/008filter.t
index 443fb2b4f..050cf1ef3 100644
--- a/t/008filter.t
+++ b/t/008filter.t
@@ -214,7 +214,7 @@ sub directive_ok {
# Note: If a single directive prints two things, and only one is
# filtered, we may not catch that case.
return 1 if $directive =~ /FILTER\ (html|csv|js|base64|css_class_quote|ics|
- quoteUrls|time|uri|xml|lower|html_light|
+ renderComment|time|uri|xml|lower|html_light|
obsolete|inactive|closed|unitconvert|
txt|html_linebreak|none|json|null|id|
markdown)\b/x;
diff --git a/t/bmo/comments.t b/t/bmo/comments.t
index 4b0bb8177..f4064a7fc 100644
--- a/t/bmo/comments.t
+++ b/t/bmo/comments.t
@@ -61,7 +61,7 @@ my $bug_2 = Bugzilla::Bug->create(
my $bug_2_id = $bug_2->id;
-Bugzilla::Template::quoteUrls(
+Bugzilla::Template::renderComment(
$bug_2->comments->[0]->body, undef, undef, undef,
sub {
my $bug_id = $_[0];
diff --git a/template/en/default/bug/comment.html.tmpl b/template/en/default/bug/comment.html.tmpl
index e3cd382fd..9b0deecc4 100644
--- a/template/en/default/bug/comment.html.tmpl
+++ b/template/en/default/bug/comment.html.tmpl
@@ -37,6 +37,11 @@
<div id="comment_preview" class="bz_default_hidden bz_comment">
<div id="comment_preview_loading" class="bz_default_hidden">Generating Preview...</div>
<div id="comment_preview_error" class="bz_default_hidden"></div>
- <pre id="comment_preview_text" class="bz_comment_text"></pre>
+ <div id="comment_preview_text" class="bz_comment_text"></div>
</div>
[% END %]
+
+<div id="comment-markdown-tip">
+ <img src="extensions/BMO/web/images/notice.png" width="16" height="16">
+ <a href="https://guides.github.com/features/mastering-markdown/" target="_blank">Markdown styling now supported</a>
+</div>
diff --git a/template/en/default/bug/comments.html.tmpl b/template/en/default/bug/comments.html.tmpl
index 7af08efde..98ab4645e 100644
--- a/template/en/default/bug/comments.html.tmpl
+++ b/template/en/default/bug/comments.html.tmpl
@@ -283,15 +283,22 @@
</div>
[% END %]
-[%# Don't indent the <pre> block, since then the spaces are displayed in the
- # generated HTML
+
+[% IF comment.is_markdown %]
+ [% comment_tag = 'div' %]
+[% ELSE %]
+ [% comment_tag = 'pre' %]
+[% END %]
+
+[%# Don't indent incaase it's a <pre> block, since then the spaces are
+ # displayed in the generated HTML
#%]
-<pre class="bz_comment_text[% " collapsed" IF comment.collapsed %]"
+<[% comment_tag FILTER none %] class="bz_comment_text[% " collapsed" IF comment.collapsed %]"
[% IF mode == "edit" || comment.collapsed %]
id="comment_text_[% comment.count FILTER none %]"
[% END %]>
- [%- comment_text FILTER quoteUrls(bug, comment) -%]
-</pre>
+ [%- comment_text FILTER renderComment(bug, comment) -%]
+</[% comment_tag FILTER none %]>
[% Hook.process('a_comment-end', 'bug/comments.html.tmpl') %]
</div>
[% END %]
diff --git a/template/en/default/bug/edit.html.tmpl b/template/en/default/bug/edit.html.tmpl
index 445e5fe0d..69edfeb00 100644
--- a/template/en/default/bug/edit.html.tmpl
+++ b/template/en/default/bug/edit.html.tmpl
@@ -253,7 +253,7 @@
(<span id="alias_nonedit_display">[% bug.alias FILTER html %]</span>)
[% END %]
[% END %]
- <span role="heading" aria-level="1" id="short_desc_nonedit_display">[% bug.short_desc FILTER quoteUrls(bug) FILTER wbr %]</span>
+ <span role="heading" aria-level="1" id="short_desc_nonedit_display">[% bug.short_desc FILTER renderComment(bug, undef, 1) FILTER wbr %]</span>
[% IF bug.check_can_change_field('short_desc', 0, 1) ||
bug.check_can_change_field('alias', 0, 1) %]
<small class="editme">(<a href="#" id="editme_action">edit</a>)</small>
diff --git a/template/en/default/bug/link.html.tmpl b/template/en/default/bug/link.html.tmpl
index dc09848da..17b85589c 100644
--- a/template/en/default/bug/link.html.tmpl
+++ b/template/en/default/bug/link.html.tmpl
@@ -56,7 +56,8 @@
<a class="bz_bug_link
bz_status_[% bug.bug_status FILTER css_class_quote %]
- [% ' bz_closed' IF !bug.isopened %]"
+ [% ' bz_closed' IF !bug.isopened %]
+ [% ' bz_private_link' IF !user.can_see_bug(bug) %]"
title="[% link_title FILTER collapse FILTER html %]"
href="[% urlbase FILTER html IF full_url %]show_bug.cgi?id=
[%~ bug.id FILTER uri %][% anchor FILTER html %]">
diff --git a/template/en/default/bug/new_bug.html.tmpl b/template/en/default/bug/new_bug.html.tmpl
index ef5e361c0..185ae771b 100644
--- a/template/en/default/bug/new_bug.html.tmpl
+++ b/template/en/default/bug/new_bug.html.tmpl
@@ -244,7 +244,7 @@
<textarea rows="5" name="comment" id="comment" aria-labelledby="comment-edit-tab"></textarea>
</div>
<div id="comment-preview-tabpanel" class="comment-tabpanel" role="tabpanel" aria-labelledby="comment-preview-tab" style="display:none">
- <pre id="comment-preview" class="comment-text"></pre>
+ <div id="comment-preview" class="comment-text"></div>
</div>
</div>
[% END %]
diff --git a/template/en/default/email/bugmail.html.tmpl b/template/en/default/email/bugmail.html.tmpl
index 8b567b691..cdcb3d13d 100644
--- a/template/en/default/email/bugmail.html.tmpl
+++ b/template/en/default/email/bugmail.html.tmpl
@@ -38,7 +38,12 @@
on [% "$terms.bug $bug.id" FILTER bug_link(bug, { full_url => 1, user => to_user }) FILTER none %]
from [% INCLUDE global/user.html.tmpl user = to_user, who = comment.author %]</b>
[% END %]
- <pre>[% comment.body_full({ wrap => 1 }) FILTER quoteUrls(bug, comment, to_user) %]</pre>
+ [% IF comment.is_markdown %]
+ [% comment_tag = 'div' %]
+ [% ELSE %]
+ [% comment_tag = 'pre' %]
+ [% END %]
+ <[% comment_tag FILTER none %]>[% comment.body_full({ wrap => 1 }) FILTER renderComment(bug, comment) %]</[% comment_tag FILTER none %]>
</div>
[% END %]
</p>
diff --git a/template/en/default/filterexceptions.pl b/template/en/default/filterexceptions.pl
index 39f064035..07211ad29 100644
--- a/template/en/default/filterexceptions.pl
+++ b/template/en/default/filterexceptions.pl
@@ -34,7 +34,7 @@
# [% foo.push() %]
# TT loop variables - [% loop.count %]
# Already-filtered stuff - [% wibble FILTER html %]
-# where the filter is one of html|csv|js|quoteUrls|time|uri|xml|none
+# where the filter is one of html|csv|js|renderComment|time|uri|xml|none
%::safe = (
diff --git a/template/en/default/global/header.html.tmpl b/template/en/default/global/header.html.tmpl
index bd9ec8bcb..426742495 100644
--- a/template/en/default/global/header.html.tmpl
+++ b/template/en/default/global/header.html.tmpl
@@ -112,6 +112,7 @@
},
constant => {
COMMENT_COLS => constants.COMMENT_COLS,
+ URL_BASE => urlbase,
},
string => {
# Please keep these in alphabetical order.
diff --git a/template/en/default/pages/linked.html.tmpl b/template/en/default/pages/linked.html.tmpl
index b5d850627..aa519f9ac 100644
--- a/template/en/default/pages/linked.html.tmpl
+++ b/template/en/default/pages/linked.html.tmpl
@@ -31,7 +31,7 @@
<p>
<pre class="bz_comment_text">
-[%- cgi.param("text") FILTER quoteUrls FILTER html -%]
+[%- cgi.param("text") FILTER renderComment FILTER html -%]
</pre>
</p>
@@ -46,7 +46,7 @@
<p>
<pre class="bz_comment_text">
-[%- cgi.param("text") FILTER quoteUrls -%]
+[%- cgi.param("text") FILTER renderComment -%]
</pre>
</p>