]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 793963: add the ability to tag comments with arbitrary tags
authorByron Jones <bjones@mozilla.com>
Mon, 25 Nov 2013 08:21:03 +0000 (16:21 +0800)
committerByron Jones <bjones@mozilla.com>
Mon, 25 Nov 2013 08:21:03 +0000 (16:21 +0800)
r=dkl, a=glob

24 files changed:
Bugzilla/Bug.pm
Bugzilla/Comment.pm
Bugzilla/Comment/TagWeights.pm [new file with mode: 0644]
Bugzilla/Config/BugFields.pm
Bugzilla/Config/Common.pm
Bugzilla/Config/GroupSecurity.pm
Bugzilla/Constants.pm
Bugzilla/DB/Schema.pm
Bugzilla/Field.pm
Bugzilla/User.pm
Bugzilla/WebService/Bug.pm
Bugzilla/WebService/Constants.pm
Bugzilla/WebService/Server/REST/Resources/Bug.pm
js/comment-tagging.js [new file with mode: 0644]
js/comments.js
js/util.js
show_activity.cgi
skins/standard/show_bug.css
template/en/default/admin/params/bugfields.html.tmpl
template/en/default/admin/params/groupsecurity.html.tmpl
template/en/default/bug/comments.html.tmpl
template/en/default/bug/edit.html.tmpl
template/en/default/bug/show-header.html.tmpl
template/en/default/global/user-error.html.tmpl

index 1e82da30db26b84ab540d8f500f448d7cf0b4d43..9669352cd0c42112c0c21067930457144f7e7fb1 100644 (file)
@@ -3822,7 +3822,7 @@ sub _bugs_in_order {
 # Get the activity of a bug, starting from $starttime (if given).
 # This routine assumes Bugzilla::Bug->check has been previously called.
 sub get_activity {
-    my ($self, $attach_id, $starttime) = @_;
+    my ($self, $attach_id, $starttime, $include_comment_tags) = @_;
     my $dbh = Bugzilla->dbh;
     my $user = Bugzilla->user;
 
@@ -3834,7 +3834,7 @@ sub get_activity {
     if (defined $starttime) {
         trick_taint($starttime);
         push (@args, $starttime);
-        $datepart = "AND bugs_activity.bug_when > ?";
+        $datepart = "AND bug_when > ?";
     }
 
     my $attachpart = "";
@@ -3854,7 +3854,7 @@ sub get_activity {
 
     my $query = "SELECT fielddefs.name, bugs_activity.attach_id, " .
         $dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s') .
-            ", bugs_activity.removed, bugs_activity.added, profiles.login_name, 
+            " AS bug_when, bugs_activity.removed, bugs_activity.added, profiles.login_name,
                bugs_activity.comment_id
           FROM bugs_activity
                $suppjoins
@@ -3865,8 +3865,31 @@ sub get_activity {
          WHERE bugs_activity.bug_id = ?
                $datepart
                $attachpart
-               $suppwhere
-      ORDER BY bugs_activity.bug_when, bugs_activity.id";
+               $suppwhere ";
+
+    if (Bugzilla->params->{'comment_taggers_group'}
+        && $include_comment_tags
+        && !$attach_id)
+    {
+        $query .= "
+            UNION ALL
+            SELECT 'comment_tag' AS name,
+                   NULL AS attach_id," .
+                   $dbh->sql_date_format('longdescs_tags_activity.bug_when', '%Y.%m.%d %H:%i:%s') . " AS bug_when,
+                   longdescs_tags_activity.removed,
+                   longdescs_tags_activity.added,
+                   profiles.login_name,
+                   longdescs_tags_activity.comment_id as comment_id
+              FROM longdescs_tags_activity
+                   INNER JOIN profiles ON profiles.userid = longdescs_tags_activity.who
+             WHERE longdescs_tags_activity.bug_id = ?
+                   $datepart
+        ";
+        push @args, $self->id;
+        push @args, $starttime if defined $starttime;
+    }
+
+    $query .= "ORDER BY bug_when, comment_id";
 
     my $list = $dbh->selectall_arrayref($query, undef, @args);
 
index d4e3383eaf07c253a6b5402e2636f1fcb783c77e..d1e1e253017a47ee1514da7f05dfae6505436649 100644 (file)
@@ -13,11 +13,13 @@ use strict;
 use parent qw(Bugzilla::Object);
 
 use Bugzilla::Attachment;
+use Bugzilla::Comment::TagWeights;
 use Bugzilla::Constants;
 use Bugzilla::Error;
 use Bugzilla::User;
 use Bugzilla::Util;
 
+use List::Util qw(first);
 use Scalar::Util qw(blessed);
 
 ###############################
@@ -79,21 +81,90 @@ use constant VALIDATOR_DEPENDENCIES => {
 
 sub update {
     my $self = shift;
-    my $changes = $self->SUPER::update(@_);
-    $self->bug->_sync_fulltext( update_comments => 1);
+    my ($changes, $old_comment) = $self->SUPER::update(@_);
+
+    if (exists $changes->{'thetext'} || exists $changes->{'isprivate'}) {
+        $self->bug->_sync_fulltext( update_comments => 1);
+    }
+
+    my @old_tags = @{ $old_comment->tags };
+    my @new_tags = @{ $self->tags };
+    my ($removed_tags, $added_tags) = diff_arrays(\@old_tags, \@new_tags);
+
+    if (@$removed_tags || @$added_tags) {
+        my $dbh = Bugzilla->dbh;
+        my $when = $dbh->selectrow_array("SELECT LOCALTIMESTAMP(0)");
+        my $sth_delete = $dbh->prepare(
+            "DELETE FROM longdescs_tags WHERE comment_id = ? AND tag = ?"
+        );
+        my $sth_insert = $dbh->prepare(
+            "INSERT INTO longdescs_tags(comment_id, tag) VALUES (?, ?)"
+        );
+        my $sth_activity = $dbh->prepare(
+            "INSERT INTO longdescs_tags_activity
+            (bug_id, comment_id, who, bug_when, added, removed)
+            VALUES (?, ?, ?, ?, ?, ?)"
+        );
+
+        foreach my $tag (@$removed_tags) {
+            my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag });
+            if ($weighted) {
+                if ($weighted->weight == 1) {
+                    $weighted->remove_from_db();
+                } else {
+                    $weighted->set_weight($weighted->weight - 1);
+                    $weighted->update();
+                }
+            }
+            trick_taint($tag);
+            $sth_delete->execute($self->id, $tag);
+            $sth_activity->execute(
+                $self->bug_id, $self->id, Bugzilla->user->id, $when, '', $tag);
+        }
+
+        foreach my $tag (@$added_tags) {
+            my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag });
+            if ($weighted) {
+                $weighted->set_weight($weighted->weight + 1);
+                $weighted->update();
+            } else {
+                Bugzilla::Comment::TagWeights->create({ tag => $tag, weight => 1 });
+            }
+            trick_taint($tag);
+            $sth_insert->execute($self->id, $tag);
+            $sth_activity->execute(
+                $self->bug_id, $self->id, Bugzilla->user->id, $when, $tag, '');
+        }
+    }
+
     return $changes;
 }
 
-# Speeds up displays of comment lists by loading all ->author objects
-# at once for a whole list.
+# Speeds up displays of comment lists by loading all author objects and tags at
+# once for a whole list.
 sub preload {
     my ($class, $comments) = @_;
+    # Author
     my %user_ids = map { $_->{who} => 1 } @$comments;
     my $users = Bugzilla::User->new_from_list([keys %user_ids]);
     my %user_map = map { $_->id => $_ } @$users;
     foreach my $comment (@$comments) {
         $comment->{author} = $user_map{$comment->{who}};
     }
+    # Tags
+    if (Bugzilla->params->{'comment_taggers_group'}) {
+        my $dbh = Bugzilla->dbh;
+        my @comment_ids = map { $_->id } @$comments;
+        my %comment_map = map { $_->id => $_ } @$comments;
+        my $rows = $dbh->selectall_arrayref(
+            "SELECT comment_id, " . $dbh->sql_group_concat('tag', "','") . "
+               FROM longdescs_tags
+              WHERE " . $dbh->sql_in('comment_id', \@comment_ids) . "
+              GROUP BY comment_id");
+        foreach my $row (@$rows) {
+            $comment_map{$row->[0]}->{tags} = [ split(/,/, $row->[1]) ];
+        }
+    }
 }
 
 ###############################
@@ -113,6 +184,39 @@ sub work_time   {
 sub type        { return $_[0]->{'type'};      }
 sub extra_data  { return $_[0]->{'extra_data'} }
 
+sub tags {
+    my ($self) = @_;
+    return [] unless Bugzilla->params->{'comment_taggers_group'};
+    $self->{'tags'} ||= Bugzilla->dbh->selectcol_arrayref(
+        "SELECT tag
+           FROM longdescs_tags
+          WHERE comment_id = ?
+          ORDER BY tag",
+        undef, $self->id);
+    return $self->{'tags'};
+}
+
+sub collapsed {
+    my ($self) = @_;
+    return 0 unless Bugzilla->params->{'comment_taggers_group'};
+    return $self->{collapsed} if exists $self->{collapsed};
+    $self->{collapsed} = 0;
+    Bugzilla->request_cache->{comment_tags_collapsed}
+            ||= [ split(/\s*,\s*/, Bugzilla->params->{'collapsed_comment_tags'}) ];
+    my @collapsed_tags = @{ Bugzilla->request_cache->{comment_tags_collapsed} };
+    foreach my $my_tag (@{ $self->tags }) {
+        $my_tag = lc($my_tag);
+        foreach my $collapsed_tag (@collapsed_tags) {
+            if ($my_tag eq lc($collapsed_tag)) {
+                $self->{collapsed} = 1;
+                last;
+            }
+        }
+        last if $self->{collapsed};
+    }
+    return $self->{collapsed};
+}
+
 sub bug {
     my $self = shift;
     require Bugzilla::Bug;
@@ -170,6 +274,26 @@ sub set_is_private  { $_[0]->set('isprivate',  $_[1]); }
 sub set_type        { $_[0]->set('type',       $_[1]); }
 sub set_extra_data  { $_[0]->set('extra_data', $_[1]); }
 
+sub add_tag {
+    my ($self, $tag) = @_;
+    $tag = $self->_check_tag($tag);
+
+    my $tags = $self->tags;
+    return if grep { lc($tag) eq lc($_) } @$tags;
+    push @$tags, $tag;
+    $self->{'tags'} = [ sort @$tags ];
+}
+
+sub remove_tag {
+    my ($self, $tag) = @_;
+    $tag = $self->_check_tag($tag);
+
+    my $tags = $self->tags;
+    my $index = first { lc($tags->[$_]) eq lc($tag) } 0..scalar(@$tags) - 1;
+    return unless defined $index;
+    splice(@$tags, $index, 1);
+}
+
 ##############
 # Validators #
 ##############
@@ -312,6 +436,17 @@ sub _check_isprivate {
     return $isprivate ? 1 : 0;
 }
 
+sub _check_tag {
+    my ($invocant, $tag) = @_;
+    length($tag) < MIN_COMMENT_TAG_LENGTH
+        and ThrowUserError('comment_tag_too_short', { tag => $tag });
+    length($tag) > MAX_COMMENT_TAG_LENGTH
+        and ThrowUserError('comment_tag_too_long', { tag => $tag });
+    $tag =~ /^[\w\d\._-]+$/
+        or ThrowUserError('comment_tag_invalid', { tag => $tag });
+    return $tag;
+}
+
 sub count {
     my ($self) = @_;
 
@@ -326,7 +461,7 @@ sub count {
         undef, $self->bug_id, $self->creation_ts);
 
     return --$self->{'count'};
-}   
+}
 
 1;
 
@@ -372,7 +507,7 @@ C<string> Time spent as related to this comment.
 
 =item C<is_private>
 
-C<boolean> Comment is marked as private
+C<boolean> Comment is marked as private.
 
 =item C<already_wrapped>
 
@@ -387,6 +522,54 @@ L<Bugzilla::User> who created the comment.
 
 C<int> The position this comment is located in the full list of comments for a bug starting from 0.
 
+=item C<collapsed>
+
+C<boolean> Comment should be displayed as collapsed by default.
+
+=item C<tags>
+
+C<array of strings> The tags attached to the comment.
+
+=item C<add_tag>
+
+=over
+
+=item B<Description>
+
+Attaches the specified tag to the comment.
+
+=item B<Params>
+
+=over
+
+=item C<tag>
+
+C<string> The tag to attach.
+
+=back
+
+=back
+
+=item C<remove_tag>
+
+=over
+
+=item B<Description>
+
+Detaches the specified tag from the comment.
+
+=item B<Params>
+
+=over
+
+=item C<tag>
+
+C<string> The tag to detach.
+
+=back
+
+=back
+
 =item C<body_full>
 
 =over
diff --git a/Bugzilla/Comment/TagWeights.pm b/Bugzilla/Comment/TagWeights.pm
new file mode 100644 (file)
index 0000000..5835efb
--- /dev/null
@@ -0,0 +1,74 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Comment::TagWeights;
+
+use 5.10.1;
+use strict;
+
+use parent qw(Bugzilla::Object);
+
+use Bugzilla::Constants;
+
+# No auditing required
+use constant AUDIT_CREATES => 0;
+use constant AUDIT_UPDATES => 0;
+use constant AUDIT_REMOVES => 0;
+
+use constant DB_COLUMNS => qw(
+    id
+    tag
+    weight
+);
+
+use constant UPDATE_COLUMNS => qw(
+    weight
+);
+
+use constant DB_TABLE   => 'longdescs_tags_weights';
+use constant ID_FIELD   => 'id';
+use constant NAME_FIELD => 'tag';
+use constant LIST_ORDER => 'weight DESC';
+use constant VALIDATORS => { };
+
+sub tag    { return $_[0]->{'tag'} }
+sub weight { return $_[0]->{'weight'} }
+
+sub set_weight { $_[0]->set('weight', $_[1]); }
+
+1;
+
+=head1 NAME
+
+Comment::TagWeights - Bugzilla comment weighting class.
+
+=head1 DESCRIPTION
+
+TagWeights.pm represents a Comment::TagWeight object. It is an implementation
+of L<Bugzilla::Object>, and thus provides all methods that L<Bugzilla::Object>
+provides.
+
+TagWeights is used to quickly find tags and order by their usage count.
+
+=head1 PROPERTIES
+
+=over
+
+=item C<tag>
+
+C<getter string> The tag
+
+=item C<weight>
+
+C<getter int> The tag's weight. The value returned corresponds to the number of
+comments with this tag attached.
+
+=item C<set_weight>
+
+C<setter int> Set the tag's weight.
+
+=back
index e24f75661892657ea1e440157defd60a319f23cb..39c43cb923516948f56932ae816803ea367af31a 100644 (file)
@@ -84,7 +84,13 @@ sub get_param_list {
    choices => ['', @legal_OS],
    default => '',
    checker => \&check_opsys
-  } );
+  },
+
+  {
+   name => 'collapsed_comment_tags',
+   type => 't',
+   default => 'obsolete, spam',
+  });
   return @param_list;
 }
 
index 5872ae1e1cfe59b394549b7d78232a3261552e85..96d7c1c89ca6c979bfc1e5b7f3709768e6589a55 100644 (file)
@@ -28,6 +28,7 @@ use parent qw(Exporter);
        check_mail_delivery_method check_notification check_utf8
        check_bug_status check_smtp_auth check_theschwartz_available
        check_maxattachmentsize check_email check_smtp_ssl
+       check_comment_taggers_group
 );
 
 # Checking functions for the various values
@@ -367,6 +368,14 @@ sub check_theschwartz_available {
     return "";
 }
 
+sub check_comment_taggers_group {
+    my $group_name = shift;
+    if ($group_name && !Bugzilla->feature('jsonrpc')) {
+        return "Comment tagging requires installation of the JSONRPC feature";
+    }
+    return check_group($group_name);
+}
+
 # OK, here are the parameter definitions themselves.
 #
 # Each definition is a hash with keys:
@@ -465,6 +474,11 @@ Checks that the value is a valid number
 
 Checks that the value is a valid regexp
 
+=item C<check_comment_taggers_group>
+
+Checks that the required modules for comment tagging are installed, and that a
+valid group is provided.
+
 =back
 
 =head1 B<Methods in need of POD>
index d57573de332ef43ac8276a9265060e39333fd9b7..1d3878415a4885eada5efd90bffe0400046189c0 100644 (file)
@@ -56,7 +56,15 @@ sub get_param_list {
    default => 'editbugs',
    checker => \&check_group
   },
-  
+
+  {
+   name => 'comment_taggers_group',
+   type => 's',
+   choices => \&_get_all_group_names,
+   default => 'editbugs',
+   checker => \&check_comment_taggers_group
+  },
+
   {
    name => 'debug_group',
    type => 's',
@@ -84,4 +92,5 @@ sub _get_all_group_names {
     unshift(@group_names, '');
     return \@group_names;
 }
+
 1;
index 60c7a34e04b6d78a34de26ca8cf784051627e343..de045273b7714ad7c1a5bfab442004c7891a002a 100644 (file)
@@ -69,6 +69,9 @@ use Memoize;
     COMMENT_COLS
     MAX_COMMENT_LENGTH
 
+    MIN_COMMENT_TAG_LENGTH
+    MAX_COMMENT_TAG_LENGTH
+
     CMT_NORMAL
     CMT_DUPE_OF
     CMT_HAS_DUPE
@@ -291,6 +294,10 @@ use constant COMMENT_COLS => 80;
 # Used in _check_comment(). Gives the max length allowed for a comment.
 use constant MAX_COMMENT_LENGTH => 65535;
 
+# The minimum and maximum length of comment tags.
+use constant MIN_COMMENT_TAG_LENGTH => 3;
+use constant MAX_COMMENT_TAG_LENGTH => 24;
+
 # The type of bug comments.
 use constant CMT_NORMAL => 0;
 use constant CMT_DUPE_OF => 1;
index c619a578079ca2fe2e9a1b5a5028c3a3e5184ed7..30ed55b9adcc9eab29c956f8f929613608b11c6c 100644 (file)
@@ -406,6 +406,54 @@ use constant ABSTRACT_SCHEMA => {
         ],
     },
 
+    longdescs_tags => {
+        FIELDS => [
+            id         => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1 },
+            comment_id => { TYPE => 'INT4',
+                            REFERENCES => { TABLE  => 'longdescs',
+                                            COLUMN => 'comment_id',
+                                            DELETE => 'CASCADE' }},
+            tag        => { TYPE => 'varchar(24)',  NOTNULL => 1 },
+        ],
+        INDEXES => [
+            longdescs_tags_idx => { FIELDS => ['comment_id', 'tag'], TYPE => 'UNIQUE' },
+        ],
+    },
+
+    longdescs_tags_weights => {
+        FIELDS => [
+            id     => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1 },
+            tag    => { TYPE => 'varchar(24)',  NOTNULL => 1 },
+            weight => { TYPE => 'INT3',         NOTNULL => 1 },
+        ],
+        INDEXES => [
+            longdescs_tags_weights_tag_idx => { FIELDS => ['tag'], TYPE => 'UNIQUE' },
+        ],
+    },
+
+    longdescs_tags_activity => {
+        FIELDS => [
+            id         => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1 },
+            bug_id     => { TYPE => 'INT3', NOTNULL => 1,
+                            REFERENCES =>  { TABLE  =>  'bugs',
+                                             COLUMN =>  'bug_id',
+                                             DELETE => 'CASCADE' }},
+            comment_id => { TYPE => 'INT4',
+                            REFERENCES => { TABLE  => 'longdescs',
+                                            COLUMN => 'comment_id',
+                                            DELETE => 'CASCADE' }},
+            who        => { TYPE => 'INT3', NOTNULL => 1,
+                            REFERENCES => { TABLE  => 'profiles',
+                                            COLUMN => 'userid' }},
+            bug_when  => { TYPE => 'DATETIME', NOTNULL => 1 },
+            added     => { TYPE => 'varchar(24)' },
+            removed   => { TYPE => 'varchar(24)' },
+        ],
+        INDEXES => [
+            longdescs_tags_activity_bug_id_idx  => ['bug_id'],
+        ],
+    },
+
     dependencies => {
         FIELDS => [
             blocked   => {TYPE => 'INT3', NOTNULL => 1,
index cda2525425a478d5b01f67e02da42fdfa86da3b5..82c82161af7176efe80530e54a1d294a73f08045 100644 (file)
@@ -255,6 +255,7 @@ use constant DEFAULT_FIELDS => (
      type => FIELD_TYPE_BUG_URLS},
     {name => 'tag',                   desc => 'Tags', buglist => 1,
      type => FIELD_TYPE_KEYWORDS},
+    {name => 'comment_tag',           desc => 'Comment Tag'},
 );
 
 ################
index 84487dbbede554e1877620dd0578a3eb3590ce9f..7a067fce0c5823ed180a2fe8bb55013933dc42e4 100644 (file)
@@ -1852,6 +1852,17 @@ sub is_timetracker {
     return $self->{'is_timetracker'};
 }
 
+sub can_tag_comments {
+    my $self = shift;
+
+    if (!defined $self->{'can_tag_comments'}) {
+        my $group = Bugzilla->params->{'comment_taggers_group'};
+        $self->{'can_tag_comments'} =
+            ($group && $self->in_group($group)) ? 1 : 0;
+    }
+    return $self->{'can_tag_comments'};
+}
+
 sub get_userlist {
     my $self = shift;
 
@@ -2648,6 +2659,12 @@ i.e. if the 'insidergroup' parameter is set and the user belongs to this group.
 Returns true if the user is a global watcher,
 i.e. if the 'globalwatchers' parameter contains the user.
 
+=item C<can_tag_comments>
+
+Returns true if the user can attach tags to comments.
+i.e. if the 'comment_taggers_group' parameter is set and the user belongs to
+this group.
+
 =back
 
 =head1 CLASS FUNCTIONS
index 598316c5971b95f428b9886169430f867078e669..7f012160cad2d91e894544cbbb8adc614e6e6177 100644 (file)
@@ -13,6 +13,7 @@ use strict;
 use parent qw(Bugzilla::WebService);
 
 use Bugzilla::Comment;
+use Bugzilla::Comment::TagWeights;
 use Bugzilla::Constants;
 use Bugzilla::Error;
 use Bugzilla::Field;
@@ -20,7 +21,7 @@ use Bugzilla::WebService::Constants;
 use Bugzilla::WebService::Util qw(filter filter_wants validate translate);
 use Bugzilla::Bug;
 use Bugzilla::BugMail;
-use Bugzilla::Util qw(trick_taint trim diff_arrays);
+use Bugzilla::Util qw(trick_taint trim diff_arrays detaint_natural);
 use Bugzilla::Version;
 use Bugzilla::Milestone;
 use Bugzilla::Status;
@@ -320,7 +321,8 @@ sub _translate_comment {
     my ($self, $comment, $filters) = @_;
     my $attach_id = $comment->is_about_attachment ? $comment->extra_data
                                                   : undef;
-    return filter $filters, {
+
+    my $comment_hash = {
         id         => $self->type('int', $comment->id),
         bug_id     => $self->type('int', $comment->bug_id),
         creator    => $self->type('email', $comment->author->login),
@@ -332,6 +334,16 @@ sub _translate_comment {
         attachment_id => $self->type('int', $attach_id),
         count      => $self->type('int', $comment->count),
     };
+
+    # Don't load comment tags unless enabled
+    if (Bugzilla->params->{'comment_taggers_group'}) {
+        $comment_hash->{tags} = [
+            map { $self->type('string', $_) }
+            @{ $comment->tags }
+        ];
+    }
+
+    return filter $filters, $comment_hash;
 }
 
 sub get {
@@ -999,6 +1011,70 @@ sub update_tags {
     return { changes => \%changes };
 }
 
+sub update_comment_tags {
+    my ($self, $params) = @_;
+
+    my $user = Bugzilla->login(LOGIN_REQUIRED);
+    Bugzilla->params->{'comment_taggers_group'}
+        || ThrowUserError("comment_tag_disabled");
+    $user->can_tag_comments
+        || ThrowUserError("auth_failure",
+                          { group  => Bugzilla->params->{'comment_taggers_group'},
+                            action => "update",
+                            object => "comment_tags" });
+
+    my $comment_id  = $params->{comment_id}
+        // ThrowCodeError('param_required',
+                          { function => 'Bug.update_comment_tags',
+                            param    => 'comment_id' });
+
+    my $comment = Bugzilla::Comment->new($comment_id)
+        || return [];
+    $comment->bug->check_is_visible();
+    if ($comment->is_private && !$user->is_insider) {
+        ThrowUserError('comment_is_private', { id => $comment_id });
+    }
+
+    my $dbh = Bugzilla->dbh;
+    $dbh->bz_start_transaction();
+    foreach my $tag (@{ $params->{add} || [] }) {
+        $comment->add_tag($tag) if defined $tag;
+    }
+    foreach my $tag (@{ $params->{remove} || [] }) {
+        $comment->remove_tag($tag) if defined $tag;
+    }
+    $comment->update();
+    $dbh->bz_commit_transaction();
+
+    return $comment->tags;
+}
+
+sub search_comment_tags {
+    my ($self, $params) = @_;
+
+    Bugzilla->login(LOGIN_REQUIRED);
+    Bugzilla->params->{'comment_taggers_group'}
+        || ThrowUserError("comment_tag_disabled");
+    Bugzilla->user->can_tag_comments
+        || ThrowUserError("auth_failure", { group  => Bugzilla->params->{'comment_taggers_group'},
+                                            action => "search",
+                                            object => "comment_tags"});
+
+    my $query = $params->{query};
+    $query
+        // ThrowCodeError('param_required', { param => 'query' });
+    my $limit = detaint_natural($params->{limit}) || 7;
+
+    my $tags = Bugzilla::Comment::TagWeights->match({
+        WHERE => {
+            'tag LIKE ?' => "\%$query\%",
+        },
+        LIMIT => $limit,
+    });
+    return [ map { $_->tag } @$tags ];
+}
+
+
 ##############################
 # Private Helper Subroutines #
 ##############################
@@ -4032,6 +4108,137 @@ This method can throw the same errors as L</get>.
 
 =back
 
+=head2 search_comment_tags
+
+B<UNSTABLE>
+
+=over
+
+=item B<Description>
+
+Searches for tags which contain the provided substring.
+
+=item B<REST>
+
+To search for comment tags:
+
+GET /bug/comment/tags/<query>
+
+=item B<Params>
+
+=over
+
+=item C<query>
+
+B<Required> C<string> Only tags containg this substring will be returned.
+
+=item C<limit>
+
+C<int> If provided will return no more than C<limit> tags.  Defaults to C<10>.
+
+=back
+
+=item B<Returns>
+
+An C<array of strings> of matching tags.
+
+=item B<Errors>
+
+This method can throw all of the errors that L</get> throws, plus:
+
+=over
+
+=item 125 (Comment Tagging Disabled)
+
+Comment tagging support is not available or enabled.
+
+=back
+
+=item B<History>
+
+=over
+
+=item Added in Bugzilla B<5.0>.
+
+=back
+
+=back
+
+=head2 update_comment_tags
+
+B<UNSTABLE>
+
+=over
+
+=item B<Description>
+
+Adds or removes tags from a comment.
+
+=item B<REST>
+
+To update the tags comments attached to a comment:
+
+PUT /bug/comment/tags
+
+The params to include in the PUT body as well as the returned data format,
+are the same as below.
+
+=item B<Params>
+
+=over
+
+=item C<comment_id>
+
+B<Required> C<int> The ID of the comment to update.
+
+=item C<add>
+
+C<array of strings> The tags to attach to the comment.
+
+=item C<remove>
+
+C<array of strings> The tags to detach from the comment.
+
+=back
+
+=item B<Returns>
+
+An C<array of strings> containing the comment's updated tags.
+
+=item B<Errors>
+
+This method can throw all of the errors that L</get> throws, plus:
+
+=over
+
+=item 125 (Comment Tagging Disabled)
+
+Comment tagging support is not available or enabled.
+
+=item 126 (Invalid Comment Tag)
+
+The comment tag provided was not valid (eg. contains invalid characters).
+
+=item 127 (Comment Tag Too Short)
+
+The comment tag provided is shorter than the minimum length.
+
+=item 128 (Comment Tag Too Long)
+
+The comment tag provided is longer than the maximum length.
+
+=back
+
+=item B<History>
+
+=over
+
+=item Added in Bugzilla B<5.0>.
+
+=back
+
+=back
+
 =head1 B<Methods in need of POD>
 
 =over
index 1c3929e537c4a0398e59bce1f66ba29519cbae97..2c5bc31dda601318446e59d3f94cef411825e2d8 100644 (file)
@@ -98,7 +98,12 @@ use constant WS_ERROR_CODE => {
     comment_is_private => 110,
     comment_id_invalid => 111,
     comment_too_long => 114,
-    comment_invalid_isprivate => 117, 
+    comment_invalid_isprivate => 117,
+    # Comment tagging
+    comment_tag_disabled => 125,
+    comment_tag_invalid => 126,
+    comment_tag_too_long => 127,
+    comment_tag_too_short => 128,
     # See Also errors
     bug_url_invalid => 112,
     bug_url_too_long => 112,
index 98ae6049c20125cb75fbb6d745ff7564ba354f11..ea420b4eda9f5af00f9a4e769d38269f0785c10e 100644 (file)
@@ -65,6 +65,22 @@ sub _rest_resources {
                 }
             }
         },
+        qr{^/bug/comment/tags/([^/]+)$}, {
+            GET => {
+                method => 'search_comment_tags',
+                params => sub {
+                    return { query => $_[0] };
+                },
+            },
+        },
+        qr{^/bug/comment/([^/]+)/tags$}, {
+            PUT => {
+                method => 'update_comment_tags',
+                params => sub {
+                    return { comment_id => $_[0] };
+                },
+            },
+        },
         qr{^/bug/([^/]+)/history$}, {
             GET => {
                 method => 'history',
diff --git a/js/comment-tagging.js b/js/comment-tagging.js
new file mode 100644 (file)
index 0000000..b700fe1
--- /dev/null
@@ -0,0 +1,376 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ * This Source Code Form is "Incompatible With Secondary Licenses", as
+ * defined by the Mozilla Public License, v. 2.0. */
+
+var Dom = YAHOO.util.Dom;
+
+YAHOO.bugzilla.commentTagging = {
+    ctag_div  : false,
+    ctag_add  : false,
+    counter   : 0,
+    min_len   : 3,
+    max_len   : 24,
+    tags_by_no: {},
+    nos_by_tag: {},
+    current_id: 0,
+    current_no: -1,
+    can_edit  : false,
+    pending   : {},
+
+    init : function(can_edit) {
+        this.can_edit = can_edit;
+        this.ctag_div = Dom.get('bz_ctag_div');
+        this.ctag_add = Dom.get('bz_ctag_add');
+        YAHOO.util.Event.on(this.ctag_add, 'keypress', this.onKeyPress);
+        YAHOO.util.Event.onDOMReady(function() {
+            YAHOO.bugzilla.commentTagging.updateCollapseControls();
+        });
+        if (!can_edit) return;
+
+        var ds = new YAHOO.util.XHRDataSource("jsonrpc.cgi");
+        ds.connTimeout = 30000;
+        ds.connMethodPost = true;
+        ds.connXhrMode = "cancelStaleRequests";
+        ds.maxCacheEntries = 5;
+        ds.responseSchema = {
+            metaFields : { error: "error", jsonRpcId: "id"},
+            resultsList : "result"
+        };
+
+        var ac = new YAHOO.widget.AutoComplete('bz_ctag_add', 'bz_ctag_autocomp', ds);
+        ac.maxResultsDisplayed = 7;
+        ac.generateRequest = function(query) {
+            query = YAHOO.lang.trim(query);
+            YAHOO.bugzilla.commentTagging.last_query = query;
+            YAHOO.bugzilla.commentTagging.counter = YAHOO.bugzilla.commentTagging.counter + 1;
+            YAHOO.util.Connect.setDefaultPostHeader('application/json', true);
+            return YAHOO.lang.JSON.stringify({
+                method : "Bug.search_comment_tags",
+                id : YAHOO.bugzilla.commentTagging.counter,
+                params : [ { query : query, limit : 10 } ]
+            });
+        };
+        ac.minQueryLength = this.min_len;
+        ac.autoHighlight = false;
+        ac.typeAhead = true;
+        ac.queryDelay = 0.5;
+        ac.dataReturnEvent.subscribe(function(type, args) {
+            args[0].autoHighlight = args[2].length == 1;
+        });
+    },
+
+    toggle : function(comment_id, comment_no) {
+        if (!this.ctag_div) return;
+        var tags_container = Dom.get('ct_' + comment_no);
+
+        if (this.current_id == comment_id) {
+            // hide
+            this.current_id = 0;
+            this.current_no = -1;
+            Dom.addClass(this.ctag_div, 'bz_default_hidden');
+            this.hideError();
+            window.focus();
+
+        } else {
+            // show or move
+            this.rpcRefresh(comment_id, comment_no);
+            this.current_id = comment_id;
+            this.current_no = comment_no;
+            this.ctag_add.value = '';
+            tags_container.parentNode.insertBefore(this.ctag_div, tags_container);
+            Dom.removeClass(this.ctag_div, 'bz_default_hidden');
+            Dom.removeClass(tags_container, 'bz_default_hidden');
+            var comment = Dom.get('comment_text_' + comment_no);
+            if (Dom.hasClass(comment, 'collapsed')) {
+                var link = Dom.get('comment_link_' + comment_no);
+                expand_comment(link, comment, comment_no);
+            }
+            window.setTimeout(function() {
+                YAHOO.bugzilla.commentTagging.ctag_add.focus();
+            }, 50);
+        }
+    },
+
+    hideInput : function() {
+        if (this.current_id != 0) {
+            this.toggle(this.current_id, this.current_no);
+        }
+        this.hideError();
+    },
+
+    showError : function(comment_id, comment_no, error) {
+        var bz_ctag_error = Dom.get('bz_ctag_error');
+        var tags_container = Dom.get('ct_' + comment_no);
+        tags_container.parentNode.appendChild(bz_ctag_error, tags_container);
+        Dom.get('bz_ctag_error_msg').innerHTML = YAHOO.lang.escapeHTML(error);
+        Dom.removeClass(bz_ctag_error, 'bz_default_hidden');
+    },
+
+    hideError : function() {
+        Dom.addClass('bz_ctag_error', 'bz_default_hidden');
+    },
+
+    onKeyPress : function(evt) {
+        evt = evt || window.event;
+        var charCode = evt.charCode || evt.keyCode;
+        if (evt.keyCode == 27) {
+            // escape
+            YAHOO.bugzilla.commentTagging.hideInput();
+            YAHOO.util.Event.stopEvent(evt);
+
+        } else if (evt.keyCode == 13) {
+            // return
+            YAHOO.util.Event.stopEvent(evt);
+            var tags = YAHOO.bugzilla.commentTagging.ctag_add.value.split(/[ ,]/);
+            var comment_id = YAHOO.bugzilla.commentTagging.current_id;
+            var comment_no = YAHOO.bugzilla.commentTagging.current_no;
+            YAHOO.bugzilla.commentTagging.hideInput();
+            try {
+                YAHOO.bugzilla.commentTagging.add(comment_id, comment_no, tags);
+            } catch(e) {
+                YAHOO.bugzilla.commentTagging.showError(comment_id, comment_no, e.message);
+            }
+        }
+    },
+
+    showTags : function(comment_id, comment_no, tags) {
+        // remove existing tags
+        var tags_container = Dom.get('ct_' + comment_no);
+        while (tags_container.hasChildNodes()) {
+            tags_container.removeChild(tags_container.lastChild);
+        }
+        // add tags
+        if (tags != '') {
+            if (typeof(tags) == 'string') {
+                tags = tags.split(',');
+            }
+            for (var i = 0, l = tags.length; i < l; i++) {
+                tags_container.appendChild(this.buildTagHtml(comment_id, comment_no, tags[i]));
+            }
+        }
+        // update tracking array
+        this.tags_by_no['c' + comment_no] = tags;
+        this.updateCollapseControls();
+    },
+
+    updateCollapseControls : function() {
+        var container = Dom.get('comment_tags_collapse_expand_container');
+        if (!container) return;
+        // build list of tags
+        this.nos_by_tag = {};
+        for (var id in this.tags_by_no) {
+            if (this.tags_by_no.hasOwnProperty(id)) {
+                for (var i = 0, l = this.tags_by_no[id].length; i < l; i++) {
+                    var tag = this.tags_by_no[id][i].toLowerCase();
+                    if (!this.nos_by_tag.hasOwnProperty(tag)) {
+                        this.nos_by_tag[tag] = [];
+                    }
+                    this.nos_by_tag[tag].push(id);
+                }
+            }
+        }
+        var tags = [];
+        for (var tag in this.nos_by_tag) {
+            if (this.nos_by_tag.hasOwnProperty(tag)) {
+                tags.push(tag);
+            }
+        }
+        tags.sort();
+        if (tags.length) {
+            var div = document.createElement('div');
+            div.appendChild(document.createTextNode('Comment Tags:'));
+            var ul = document.createElement('ul');
+            ul.id = 'comment_tags_collapse_expand';
+            div.appendChild(ul);
+            for (var i = 0, l = tags.length; i < l; i++) {
+                var tag = tags[i];
+                var li = document.createElement('li');
+                ul.appendChild(li);
+                var a = document.createElement('a');
+                li.appendChild(a);
+                Dom.setAttribute(a, 'href', '#');
+                YAHOO.util.Event.addListener(a, 'click', function(evt, tag) {
+                    YAHOO.bugzilla.commentTagging.toggleCollapse(tag);
+                    YAHOO.util.Event.stopEvent(evt);
+                }, tag);
+                li.appendChild(document.createTextNode(' (' + this.nos_by_tag[tag].length + ')'));
+                a.innerHTML = tag;
+            }
+            while (container.hasChildNodes()) {
+                container.removeChild(container.lastChild);
+            }
+            container.appendChild(div);
+        } else {
+            while (container.hasChildNodes()) {
+                container.removeChild(container.lastChild);
+            }
+        }
+    },
+
+    toggleCollapse : function(tag) {
+        var nos = this.nos_by_tag[tag];
+        if (!nos) return;
+        toggle_all_comments('collapse');
+        for (var i = 0, l = nos.length; i < l; i++) {
+            var comment_no = nos[i].match(/\d+$/)[0];
+            var comment = Dom.get('comment_text_' + comment_no);
+            var link = Dom.get('comment_link_' + comment_no);
+            expand_comment(link, comment, comment_no);
+        }
+    },
+
+    buildTagHtml : function(comment_id, comment_no, tag) {
+        var el = document.createElement('span');
+        Dom.setAttribute(el, 'id', 'ct_' + comment_no + '_' + tag);
+        Dom.addClass(el, 'bz_comment_tag');
+        if (this.can_edit) {
+            var a = document.createElement('a');
+            Dom.setAttribute(a, 'href', '#');
+            YAHOO.util.Event.addListener(a, 'click', function(evt, args) {
+                YAHOO.bugzilla.commentTagging.remove(args[0], args[1], args[2])
+                YAHOO.util.Event.stopEvent(evt);
+            }, [comment_id, comment_no, tag]);
+            a.appendChild(document.createTextNode('x'));
+            el.appendChild(a);
+            el.appendChild(document.createTextNode("\u00a0"));
+        }
+        el.appendChild(document.createTextNode(tag));
+        return el;
+    },
+
+    add : function(comment_id, comment_no, add_tags) {
+        // build list of current tags from html
+        var tags = new Array();
+        var spans = Dom.getElementsByClassName('bz_comment_tag', undefined, 'ct_' + comment_no);
+        for (var i = 0, l = spans.length; i < l; i++) {
+            tags.push(spans[i].textContent.substr(2));
+        }
+        // add new tags
+        var new_tags = new Array();
+        for (var i = 0, l = add_tags.length; i < l; i++) {
+            var tag = YAHOO.lang.trim(add_tags[i]);
+            // validation
+            if (tag == '')
+                continue;
+            if (tag.length < YAHOO.bugzilla.commentTagging.min_len)
+                throw new Error("Comment tags must be at least " + this.min_len + " characters.");
+            if (tag.length > YAHOO.bugzilla.commentTagging.max_len)
+                throw new Error("Comment tags cannot be longer than " + this.min_len + " characters.");
+            // append new tag
+            if (bz_isValueInArrayIgnoreCase(tags, tag))
+                continue;
+            new_tags.push(tag);
+            tags.push(tag);
+        }
+        tags.sort();
+        // update
+        this.showTags(comment_id, comment_no, tags);
+        this.rpcUpdate(comment_id, comment_no, new_tags, undefined);
+    },
+
+    remove : function(comment_id, comment_no, tag) {
+        var el = Dom.get('ct_' + comment_no + '_' + tag);
+        if (el) {
+            el.parentNode.removeChild(el);
+            this.rpcUpdate(comment_id, comment_no, undefined, [ tag ]);
+        }
+    },
+
+    // If multiple updates are triggered quickly, overlapping refresh events
+    // are generated. We ignore all events except the last one.
+    incPending : function(comment_id) {
+        if (this.pending['c' + comment_id] == undefined) {
+            this.pending['c' + comment_id] = 1;
+        } else {
+            this.pending['c' + comment_id]++;
+        }
+    },
+
+    decPending : function(comment_id) {
+        if (this.pending['c' + comment_id] != undefined)
+            this.pending['c' + comment_id]--;
+    },
+
+    hasPending : function(comment_id) {
+        return this.pending['c' + comment_id] != undefined
+               && this.pending['c' + comment_id] > 0;
+    },
+
+    rpcRefresh : function(comment_id, comment_no, noRefreshOnError) {
+        this.incPending(comment_id);
+        YAHOO.util.Connect.setDefaultPostHeader('application/json', true);
+        YAHOO.util.Connect.asyncRequest('POST', 'jsonrpc.cgi',
+        {
+            success: function(res) {
+                YAHOO.bugzilla.commentTagging.decPending(comment_id);
+                data = YAHOO.lang.JSON.parse(res.responseText);
+                if (data.error) {
+                    YAHOO.bugzilla.commentTagging.handleRpcError(
+                        comment_id, comment_no, data.error.message, noRefreshOnError);
+                    return;
+                }
+
+                if (!YAHOO.bugzilla.commentTagging.hasPending(comment_id))
+                    YAHOO.bugzilla.commentTagging.showTags(
+                        comment_id, comment_no, data.result.comments[comment_id].tags);
+            },
+            failure: function(res) {
+                YAHOO.bugzilla.commentTagging.decPending(comment_id);
+                YAHOO.bugzilla.commentTagging.handleRpcError(
+                    comment_id, comment_no, res.responseText, noRefreshOnError);
+            }
+        },
+        YAHOO.lang.JSON.stringify({
+            version: "1.1",
+            method: 'Bug.comments',
+            params: {
+                comment_ids: [ comment_id ],
+                include_fields: [ 'tags' ]
+            }
+        })
+        );
+    },
+
+    rpcUpdate : function(comment_id, comment_no, add, remove) {
+        this.incPending(comment_id);
+        YAHOO.util.Connect.setDefaultPostHeader('application/json', true);
+        YAHOO.util.Connect.asyncRequest('POST', 'jsonrpc.cgi',
+        {
+            success: function(res) {
+                YAHOO.bugzilla.commentTagging.decPending(comment_id);
+                data = YAHOO.lang.JSON.parse(res.responseText);
+                if (data.error) {
+                    YAHOO.bugzilla.commentTagging.handleRpcError(comment_id, comment_no, data.error.message);
+                    return;
+                }
+
+                if (!YAHOO.bugzilla.commentTagging.hasPending(comment_id))
+                    YAHOO.bugzilla.commentTagging.showTags(comment_id, comment_no, data.result);
+            },
+            failure: function(res) {
+                YAHOO.bugzilla.commentTagging.decPending(comment_id);
+                YAHOO.bugzilla.commentTagging.handleRpcError(comment_id, comment_no, res.responseText);
+            }
+        },
+        YAHOO.lang.JSON.stringify({
+            version: "1.1",
+            method: 'Bug.update_comment_tags',
+            params: {
+                comment_id: comment_id,
+                add: add,
+                remove: remove
+            }
+        })
+        );
+    },
+
+    handleRpcError : function(comment_id, comment_no, message, noRefreshOnError) {
+        YAHOO.bugzilla.commentTagging.showError(comment_id, comment_no, message);
+        if (!noRefreshOnError) {
+            YAHOO.bugzilla.commentTagging.rpcRefresh(comment_id, comment_no, true);
+        }
+    }
+}
index acf5dbf9bbc9401691d6138842d941e68afc8a84..8d314de15eab780c9e3848eeca84fb5232c95be7 100644 (file)
@@ -25,9 +25,9 @@ function toggle_comment_display(link, comment_id) {
     var comment = document.getElementById('comment_text_' + comment_id);
     var re = new RegExp(/\bcollapsed\b/);
     if (comment.className.match(re))
-        expand_comment(link, comment);
+        expand_comment(link, comment, comment_id);
     else
-        collapse_comment(link, comment);
+        collapse_comment(link, comment, comment_id);
 }
 
 function toggle_all_comments(action) {
@@ -44,20 +44,22 @@ function toggle_all_comments(action) {
         var id = comments[i].id.match(/\d*$/);
         var link = document.getElementById('comment_link_' + id);
         if (action == 'collapse')
-            collapse_comment(link, comment);
+            collapse_comment(link, comment, id);
         else
-            expand_comment(link, comment);
+            expand_comment(link, comment, id);
     }
 }
 
-function collapse_comment(link, comment) {
+function collapse_comment(link, comment, comment_id) {
     link.innerHTML = "[+]";
     YAHOO.util.Dom.addClass(comment, 'collapsed');
+    YAHOO.util.Dom.addClass('comment_tag_' + comment_id, 'collapsed');
 }
 
-function expand_comment(link, comment) {
+function expand_comment(link, comment, comment_id) {
     link.innerHTML = "[&minus;]";
     YAHOO.util.Dom.removeClass(comment, 'collapsed');
+    YAHOO.util.Dom.removeClass('comment_tag_' + comment_id, 'collapsed');
 }
 
 function wrapReplyText(text) {
@@ -110,11 +112,12 @@ function wrapReplyText(text) {
 /* This way, we are sure that browsers which do not support JS
    * won't display this link  */
 
-function addCollapseLink(count, title) {
+function addCollapseLink(count, collapsed, title) {
     document.write(' <a href="#" class="bz_collapse_comment"' +
                    ' id="comment_link_' + count +
                    '" onclick="toggle_comment_display(this, ' +  count +
-                   '); return false;" title="' + title + '">[&minus;]<\/a> ');
+                   '); return false;" title="' + title + '">[' +
+                   (collapsed ? '+' : '&minus;') + ']<\/a> ');
 }
 
 function goto_add_comments( anchor ){
index 6d1f88938eece5380eb3c82dbd28f5e158be0a6d..d3a34477b6000b76e56b542caf0c2c1cc3153421 100644 (file)
@@ -125,10 +125,7 @@ function bz_overlayBelow(item, parent) {
  */
 function bz_isValueInArray(aArray, aValue)
 {
-  var run = 0;
-  var len = aArray.length;
-
-  for ( ; run < len; run++) {
+  for (var run = 0, len = aArray.length ; run < len; run++) {
     if (aArray[run] == aValue) {
       return true;
     }
@@ -137,6 +134,26 @@ function bz_isValueInArray(aArray, aValue)
   return false;
 }
 
+/**
+ * Checks if a specified value is in the specified array by performing a
+ * case-insensitive comparison.
+ *
+ * @param  aArray Array to search for the value.
+ * @param  aValue Value to search from the array.
+ * @return        Boolean; true if value is found in the array and false if not.
+ */
+function bz_isValueInArrayIgnoreCase(aArray, aValue)
+{
+  var re = new RegExp(aValue.replace(/([^A-Za-z0-9])/g, "\\$1"), 'i');
+  for (var run = 0, len = aArray.length ; run < len; run++) {
+    if (aArray[run].match(re)) {
+      return true;
+    }
+  }
+
+  return false;
+}
+
 /**
  * Create wanted options in a select form control.
  *
index 31181a63b569cd030cd2541b8956d5d3bfe24edf..1383dd018f6f5cb71b374474d0172fe400c9a561 100755 (executable)
@@ -38,7 +38,7 @@ my $bug = Bugzilla::Bug->check($id);
 # visible immediately due to replication lag.
 Bugzilla->switch_to_shadow_db;
 
-($vars->{'operations'}, $vars->{'incomplete_data'}) = $bug->get_activity;
+($vars->{'operations'}, $vars->{'incomplete_data'}) = $bug->get_activity(undef, undef, 1);
 
 $vars->{'bug'} = $bug;
 
index 2d5ba43095167141b58ed384cd513732fef96b05..7c002cc15f7c3af2ceb72a64e779e5c164ea7b4b 100644 (file)
@@ -121,3 +121,42 @@ table#flags {
 .bz_bug .bz_alias_short_desc_container {
     width: inherit;
 }
+
+.bz_comment_tags {
+    margin-top: 3px;
+}
+
+.bz_comment_tag {
+    border: 1px solid #c8c8ba;
+    padding: 1px 3px;
+    margin-right: 2px;
+    border-radius: 0.5em;
+    background-color: #eee;
+    color: #000;
+}
+
+#bz_ctag_div {
+    display: inline-block;
+}
+
+#bz_ctag_error {
+    border: 1px solid #ff6666;
+    padding: 0px 2px;
+    border-radius: 0.5em;
+    margin: 2px;
+    display: inline-block;
+}
+
+#comment_tags_collapse_expand_container {
+    padding-top: 1em;
+}
+
+#comment_tags_collapse_expand {
+    list-style-type: none;
+    padding-left: 1em;
+}
+
+#comment_tags_collapse_expand li {
+    margin-bottom: 0px;
+}
+
index 07b20082513c2100509996b89c7223c06c973f33..961833d89baa142ac8e85c4886eb7e4e15812d86 100644 (file)
@@ -41,5 +41,9 @@
                   "entry form.<br> " _
                   "You can leave this empty: " _
                   "$terms.Bugzilla will then use the operating system that the browser " _
-                  "reports to be running on as the default." }
+                  "reports to be running on as the default.",
+
+  collapsed_comment_tags => "A comma separated list of tags which, when applied " _
+                            "to comments, will cause them to be collapsed by default",
+  }
 %]
index 7e30f072382655abfb1aa59621f0d79b974ad56c..4f0b4919b4375928f4eec359194df13a12c36394 100644 (file)
@@ -29,6 +29,9 @@
   querysharegroup => "The name of the group of users who can share their " _
                      "saved searches with others.",
 
+  comment_taggers_group => "The name of the group of users who can tag comment." _
+                           " Setting this to empty disables comment tagging.",
+
   debug_group => "The name of the group of users who can view the actual " _
                  "SQL query generated when viewing $terms.bug lists and reports.",
 
index a2fe4e52ff5ba226c2cf476fb30d09a2d51d0a99..7aa9a34a6c58d92b2c38f38bf804e0866bf4f3c6 100644 (file)
@@ -13,7 +13,7 @@
 
 <script type="text/javascript">
 <!--
-  /* Adds the reply text to the `comment' textarea */
+  /* Adds the reply text to the 'comment' textarea */
   function replyToComment(id, real_id, name) {
       var prefix = "(In reply to " + name + " from comment #" + id + ")\n";
       var replytext = "";
 <td>
   [% IF mode == "edit" %]
     <ul class="bz_collapse_expand_comments">
-      <li><a href="#" onclick="toggle_all_comments('collapse'); 
+      <li><a href="#" onclick="toggle_all_comments('collapse');
                                return false;">Collapse All Comments</a></li>
       <li><a href="#" onclick="toggle_all_comments('expand');
                                return false;">Expand All Comments</a></li>
+      [% IF Param('comment_taggers_group') %]
+        <li><div id="comment_tags_collapse_expand_container"></div></li>
+      [% END %]
       [% IF user.settings.comment_box_position.value == "after_comments" && user.id %]
         <li class="bz_add_comment"><a href="#" 
             onclick="return goto_add_comments('bug_status_bottom');">
             Add Comment</a></li>
-      [% END %]                               
+      [% END %]
     </ul>
   [% END %]
 </td>
       [% END %]
 
       <div class="[% class_name FILTER html %]">
-
         [% IF mode == "edit" %]
           <span class="bz_comment_actions">
             [% IF bug.check_can_change_field('longdesc', 0, 1) %]
+              [% IF user.can_tag_comments %]
+                [<a href="#"
+                    onclick="YAHOO.bugzilla.commentTagging.toggle([% comment.id %], [% comment.count %]);return false">tag</a>]
+              [% END %]
               [<a class="bz_reply_link" href="#add_comment"
                 [% IF user.settings.quote_replies.value != 'off' %]
                   onclick="replyToComment('[% comment.count %]', '[% comment.id %]', '[% comment.author.name || comment.author.nick FILTER html FILTER js %]'); return false;"
                 [% END %]
               >reply</a>]
             [% END %]
-            <script type="text/javascript"><!--
-              addCollapseLink([% comment.count %], 'Toggle comment display'); // -->
+            <script type="text/javascript">
+              addCollapseLink([% comment.count %], [% comment.collapsed FILTER js %], 'Toggle comment display');
             </script>
           </span>
         [% END %]
          [% PROCESS formattimeunit time_unit=comment.work_time %]
       [% END %]
 
+      [% IF user.id && Param('comment_taggers_group') %]
+        <div id="comment_tag_[% comment.count FILTER html %]"
+             class="bz_comment_tags[% " collapsed" IF comment.collapsed %]">
+          <span id="ct_[% comment.count %]"
+                class="[% 'bz_default_hidden' UNLESS comment.tags.size %]">
+            [% IF comment.tags.size %]
+              <script>
+                YAHOO.bugzilla.commentTagging.showTags([% comment.id FILTER none %],
+                  [% comment.count FILTER none %], [
+                [% FOREACH tag = comment.tags %]
+                  [%~%]'[% tag FILTER js %]'[% "," UNLESS loop.last %]
+                [% END %]
+                [%~%]]);
+              </script>
+            [% END %]
+          </span>
+        </div>
+      [% END %]
+
 [%# Don't indent the <pre> block, since then the spaces are displayed in the
   # generated HTML
   #%]
-<pre class="bz_comment_text
+<pre class="bz_comment_text[% " collapsed" IF comment.collapsed %]"
      [% ' id="comment_text_' _ comment.count _ '"' IF mode == "edit" %]>
   [%- comment_text FILTER quoteUrls(bug, comment) -%]
 </pre>
index a6857f0d5d07d9f1c02cd497539f7e0905275920..202a981ea8d4597f3afb59ab9d5c7c6180b981f4 100644 (file)
@@ -8,6 +8,31 @@
 
 [% PROCESS bug/time.html.tmpl %]
 
+[% IF Param('comment_taggers_group') %]
+  [% IF user.can_tag_comments %]
+    <div id="bz_ctag_div" class="bz_default_hidden">
+      <a href="javascript:void(0)" onclick="YAHOO.bugzilla.commentTagging.hideInput()">x</a>
+      <div>
+        <input id="bz_ctag_add" size="10" placeholder="add tag"
+              maxlength="[% constants.MAX_COMMENT_TAG_LENGTH FILTER html %]">
+        <span id="bz_ctag_autocomp"></span>
+      </div>
+      &nbsp;
+    </div>
+    <div id="bz_ctag_error" class="bz_default_hidden">
+      <a href="javascript:void(0)" onclick="YAHOO.bugzilla.commentTagging.hideError()">x</a>
+      <span id="bz_ctag_error_msg"></span>
+    </div>
+  [% END %]
+  [% IF user.id %]
+    <script type="text/javascript">
+      YAHOO.bugzilla.commentTagging.init([% user.can_tag_comments ? 'true' : 'false' %]);
+      YAHOO.bugzilla.commentTagging.min_len = [% constants.MIN_COMMENT_TAG_LENGTH FILTER js %];
+      YAHOO.bugzilla.commentTagging.max_len = [% constants.MAX_COMMENT_TAG_LENGTH FILTER js %];
+    </script>
+  [% END %]
+[% END %]
+
 <script type="text/javascript">
 <!--
 [% IF user.is_timetracker %]
index 1aba5c35a1795fe4384d8a4d0b9521206691ea33..1061b90143bddb62fbce9a686ba5567138911d8f 100644 (file)
 [% END %]
 [% title = title _ filtered_desc %]
 [% yui = ['autocomplete', 'calendar'] %]
+[% yui.push('container') IF user.can_tag_comments %]
 [% javascript_urls = [ "js/util.js", "js/field.js" ] %]
+[% javascript_urls.push('js/comment-tagging.js')
+     IF user.id && Param('comment_taggers_group') %]
 [% IF bug.defined %]
   [% header = "$terms.Bug&nbsp;$bug.bug_id" %]
   [% header_addl_info = "Last modified: $filtered_timestamp" %]
index 7ca3461884cf24709b95d431f5fc735ba2daa162..9ba52f7336c3fdea271b03489bd9a131da83a255 100644 (file)
       classifications
     [% ELSIF object == "components" %]
       components
+    [% ELSIF object == "comment_tags" %]
+      comment tags
     [% ELSIF object == "custom_fields" %]
       custom fields
     [% ELSIF object == "field_values" %]
     Comments cannot be longer than 
     [%+ constants.MAX_COMMENT_LENGTH FILTER html %] characters.
 
+  [% ELSIF error == "comment_tag_disabled" %]
+    [% title = "Comment Tagging Disabled" %]
+    The comment tagging is not enabled.
+
+  [% ELSIF error == "comment_tag_invalid" %]
+    [% title = "Invalid Comment Tag" %]
+    The comment tag "[% tag FILTER html %]" contains invalid characters or
+    words.
+
+  [% ELSIF error == "comment_tag_too_long" %]
+    [% title = "Comment Tag Too Long" %]
+    Comment tags cannot be longer than
+    [%+ constants.MAX_COMMENT_TAG_LENGTH FILTER html %] characters.
+
+  [% ELSIF error == "comment_tag_too_short" %]
+    [% title = "Comment Tag Too Short" %]
+    Comment tags must be at least
+    [%+ constants.MIN_COMMENT_TAG_LENGTH FILTER html %] characters.
+
   [% ELSIF error == "auth_classification_not_enabled" %]
     [% title = "Classification Not Enabled" %]
     Sorry, classification is not enabled.
 
   [% ELSIF error == "tag_name_too_long" %]
     [% title = "Tag Name Too Long" %]
-    The tag name must be less than [% constants.MAX_LEN_QUERY_NAME FILTER html %]
+    The tag must be less than [% constants.MAX_LEN_QUERY_NAME FILTER html %]
     characters long.
 
+  [% ELSIF error == "tag_name_invalid" %]
+    [% title = "Invalid Tag Name" %]
+    A tag cannot contain a space or a comma.
+
   [% ELSIF error == "token_does_not_exist" %]
     [% title = "Token Does Not Exist" %]
     The token you submitted does not exist, has expired, or has