]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1456878 - Support markdown comments
authorIsrael Madueme <purelogiq@gmail.com>
Fri, 10 Aug 2018 12:57:01 +0000 (08:57 -0400)
committerDylan William Hardison <dylan@hardison.net>
Fri, 10 Aug 2018 12:57:01 +0000 (08:57 -0400)
31 files changed:
Bugzilla/Bug.pm
Bugzilla/Comment.pm
Bugzilla/Hook.pm
Bugzilla/Template.pm
Bugzilla/WebService/Bug.pm
attachment.cgi
docs/en/rst/api/core/v1/comment.rst
extensions/BMO/template/en/default/email/bugmail.html.tmpl
extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl
extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl
extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl
extensions/BugModal/web/bug_modal.css
extensions/BugModal/web/bug_modal.js
extensions/EditComments/template/en/default/pages/editcomments.html.tmpl
extensions/ShadowBugs/template/en/default/hook/bug/comments-aftercomments.html.tmpl
extensions/UserStory/template/en/default/hook/bug/comments-comment_banner.html.tmpl
process_bug.cgi
qa/t/test_time_summary.t
skins/standard/global.css
t/004template.t
t/008filter.t
t/bmo/comments.t
template/en/default/bug/comment.html.tmpl
template/en/default/bug/comments.html.tmpl
template/en/default/bug/edit.html.tmpl
template/en/default/bug/link.html.tmpl
template/en/default/bug/new_bug.html.tmpl
template/en/default/email/bugmail.html.tmpl
template/en/default/filterexceptions.pl
template/en/default/global/header.html.tmpl
template/en/default/pages/linked.html.tmpl

index ee48ed7a2b030840688a6b6a3bee3cde9e8beb9a..9c820eedca294f91ab21372d6c597dae12b04bf4 100644 (file)
@@ -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) = @_;
index f9a6f7d3a948717ff7171a50f1e7dc2cdc87380d..937cd12034c496b64ea4a7f2c33e237fd394d983 100644 (file)
@@ -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>.
index bed6a53b0558ac12475ee48de0884ae9dba9c447..d27468f55bf0c918a7d490b93ee206cff6b01512 100644 (file)
@@ -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
index 299734d64a995a60e0bb1436c808a70a94066155..f74565302602b3d59d93ce8ef37ca303ba3645ac 100644 (file)
@@ -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
index feb541c2e7465e3579b13a7f713ed76837286c59..d14300f6f9aecdb22cf783a98bbf744ef7edd807 100644 (file)
@@ -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} })
index 875de6a50117a6824f7d79d87d850bfd47e8de2d..4aeba58c5ab0487d6109d905b85ef69e32f0a54d 100755 (executable)
@@ -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 });
     }
index 2e6ca1e29ce2e5f31cb5be74a8f4fb1850ac474c..69508a3647fdff729d0c68dd0b7be385e4036b8f 100644 (file)
@@ -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**
 
index 0b08e4a866631b2a5eaaab9ba211657bb3600f41..5ca2c2a1bb779cbe4ac63ff4ec3dd9e401085368 100644 (file)
               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>
index 08c6b5b64880240a74213a1a5f1b4988ae2062f7..340bb6f81b90f8bcf5e2aaa44b31995e29c34718 100644 (file)
 [% 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 %]"
         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 %]
 
 [%
index e926c04b4c374b47665cbdc6a0856da17b1cf30d..e2e8bc124657d1c03d0c98c77c2428df494c92e0 100644 (file)
         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 %]
         [% 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 ~%]
index 63663b4d56d8faec986b932c242edfa6acc1baad..63c8cf19713483c08a8ec192e6ed4ee866d98792 100644 (file)
     <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">
index ee50c6b776baf2bc13c792edc3d66da4f13c664d..bf291d3b62d66fc73fb322288297e417c44b905b 100644 (file)
@@ -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;
 }
 
index a4ae83d72cd01a5077016abaf0385d10baf93ce4..19b5bfa2fca9616ac1ef855a1d33c090b74bc120 100644 (file)
@@ -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) {
index 13364f5b1690df7242410c9684e2a382c675efd0..b38a6dc0b1238be1688c8747add2acdd118ab21c 100644 (file)
@@ -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 %]
 
index 3b04475fb6eb76a242212e65e75c2357d350c269..6270bd76c412d5e247148a77072fab75e19d7e89 100644 (file)
@@ -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 %]
index e063ac9420c095ea4d4fcec6885215412cfa1e16..cbc4fe951c5b9694b5306e6cbccbfd00724f2437 100644 (file)
@@ -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">
index df7dc57d983af56bd761c7d460da7ac7fdd6d3fc..eec5bbabf645149c443ff0a869623ea7c43c88a4 100755 (executable)
@@ -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')) {
index 504c864f2d7d91a80b85a63091cbc5975f473bf3..334b9a9f79b023a4952e4bb3ad7bd1c801b6c27f 100644 (file)
@@ -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.
 
index e7028f8927bbb47dbdf027a4fb811581c200b1f8..bf95dd84fcec3f19e18ff70ee0c3019dc5c8cb4e 100644 (file)
@@ -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);
index 909f1a231d9285b59e701026965549118cd52161..8b063a3667b2b210ffa3b1772c9b833b0d57192f 100644 (file)
@@ -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 $_ },
index 443fb2b4f03c54d5b7e8e521a85d9363e5e7cd7a..050cf1ef312238c7d3d05c62bd0ab1edd8a9dace 100644 (file)
@@ -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;
index 4b0bb81779333d894cb6ea1172cba92c36d65dd7..f4064a7fcabe8d6163cf9bff8e85367469b75500 100644 (file)
@@ -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];
index e3cd382fdfd80bab210336fd9c9168a4416b3937..9b0deecc44398fa96ee90db5db478d485cef7a9d 100644 (file)
   <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>
index 7af08efdefd3aae2be89f5ed08b6dbfa8c41df1d..98ab4645e8a71e171f5c5de3af4c0be319ae63ad 100644 (file)
         </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 %]
index 445e5fe0d1dd66e88a15abb19e9da1fcf84026fe..69edfeb00f0715998f7e757a9324154346a9142c 100644 (file)
           (<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>
index dc09848da0558dab958321861b6d754a66106791..17b85589cf2c33db6fc6289363c9b073d00023ab 100644 (file)
@@ -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 %]">
index ef5e361c05033412a1d708908eae4ea60a2061d8..185ae771b9b4dccfefa56cd33c53175029e9ad83 100644 (file)
     <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 %]
index 8b567b6911da67983761c54accb96e09e6f07dce..cdcb3d13d51211005fa4f07302e2b03b0178fa38 100644 (file)
               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>
index 39f064035d2681f2c01b42b36b56f76c9cc10483..07211ad29eac95d68660fc6161bd7363e62fb567 100644 (file)
@@ -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 = (
 
index bd9ec8bcb485ce7e9d91ab3835ab520a42e02808..426742495c385ac74623208aeb2d8968fa07e47e 100644 (file)
             },
             constant => {
                 COMMENT_COLS => constants.COMMENT_COLS,
+                URL_BASE => urlbase,
             },
             string => {
                 # Please keep these in alphabetical order.
index b5d850627e7b639af03173b913d457e1db1ee325..aa519f9acfb72d785ba447bc0d6314a87aa41b71 100644 (file)
@@ -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>