summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Bugzilla/Bug.pm33
-rw-r--r--Bugzilla/Comment.pm195
-rw-r--r--Bugzilla/Comment/TagWeights.pm74
-rw-r--r--Bugzilla/Config/BugFields.pm8
-rw-r--r--Bugzilla/Config/Common.pm14
-rw-r--r--Bugzilla/Config/GroupSecurity.pm11
-rw-r--r--Bugzilla/Constants.pm7
-rw-r--r--Bugzilla/DB/Schema.pm48
-rw-r--r--Bugzilla/Field.pm1
-rw-r--r--Bugzilla/User.pm17
-rw-r--r--Bugzilla/WebService/Bug.pm211
-rw-r--r--Bugzilla/WebService/Constants.pm7
-rw-r--r--Bugzilla/WebService/Server/REST/Resources/Bug.pm16
-rw-r--r--js/comment-tagging.js376
-rw-r--r--js/comments.js19
-rw-r--r--js/util.js25
-rwxr-xr-xshow_activity.cgi2
-rw-r--r--skins/standard/show_bug.css39
-rw-r--r--template/en/default/admin/params/bugfields.html.tmpl6
-rw-r--r--template/en/default/admin/params/groupsecurity.html.tmpl3
-rw-r--r--template/en/default/bug/comments.html.tmpl39
-rw-r--r--template/en/default/bug/edit.html.tmpl25
-rw-r--r--template/en/default/bug/show-header.html.tmpl3
-rw-r--r--template/en/default/global/user-error.html.tmpl27
24 files changed, 1168 insertions, 38 deletions
diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm
index 1e82da30d..9669352cd 100644
--- a/Bugzilla/Bug.pm
+++ b/Bugzilla/Bug.pm
@@ -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);
diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm
index d4e3383ea..d1e1e2530 100644
--- a/Bugzilla/Comment.pm
+++ b/Bugzilla/Comment.pm
@@ -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
index 000000000..5835efbc4
--- /dev/null
+++ b/Bugzilla/Comment/TagWeights.pm
@@ -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
diff --git a/Bugzilla/Config/BugFields.pm b/Bugzilla/Config/BugFields.pm
index e24f75661..39c43cb92 100644
--- a/Bugzilla/Config/BugFields.pm
+++ b/Bugzilla/Config/BugFields.pm
@@ -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;
}
diff --git a/Bugzilla/Config/Common.pm b/Bugzilla/Config/Common.pm
index 5872ae1e1..96d7c1c89 100644
--- a/Bugzilla/Config/Common.pm
+++ b/Bugzilla/Config/Common.pm
@@ -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>
diff --git a/Bugzilla/Config/GroupSecurity.pm b/Bugzilla/Config/GroupSecurity.pm
index d57573de3..1d3878415 100644
--- a/Bugzilla/Config/GroupSecurity.pm
+++ b/Bugzilla/Config/GroupSecurity.pm
@@ -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;
diff --git a/Bugzilla/Constants.pm b/Bugzilla/Constants.pm
index 60c7a34e0..de045273b 100644
--- a/Bugzilla/Constants.pm
+++ b/Bugzilla/Constants.pm
@@ -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;
diff --git a/Bugzilla/DB/Schema.pm b/Bugzilla/DB/Schema.pm
index c619a5780..30ed55b9a 100644
--- a/Bugzilla/DB/Schema.pm
+++ b/Bugzilla/DB/Schema.pm
@@ -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,
diff --git a/Bugzilla/Field.pm b/Bugzilla/Field.pm
index cda252542..82c82161a 100644
--- a/Bugzilla/Field.pm
+++ b/Bugzilla/Field.pm
@@ -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'},
);
################
diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm
index 84487dbbe..7a067fce0 100644
--- a/Bugzilla/User.pm
+++ b/Bugzilla/User.pm
@@ -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
diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm
index 598316c59..7f012160c 100644
--- a/Bugzilla/WebService/Bug.pm
+++ b/Bugzilla/WebService/Bug.pm
@@ -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
diff --git a/Bugzilla/WebService/Constants.pm b/Bugzilla/WebService/Constants.pm
index 1c3929e53..2c5bc31dd 100644
--- a/Bugzilla/WebService/Constants.pm
+++ b/Bugzilla/WebService/Constants.pm
@@ -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,
diff --git a/Bugzilla/WebService/Server/REST/Resources/Bug.pm b/Bugzilla/WebService/Server/REST/Resources/Bug.pm
index 98ae6049c..ea420b4ed 100644
--- a/Bugzilla/WebService/Server/REST/Resources/Bug.pm
+++ b/Bugzilla/WebService/Server/REST/Resources/Bug.pm
@@ -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
index 000000000..b700fe11d
--- /dev/null
+++ b/js/comment-tagging.js
@@ -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);
+ }
+ }
+}
diff --git a/js/comments.js b/js/comments.js
index acf5dbf9b..8d314de15 100644
--- a/js/comments.js
+++ b/js/comments.js
@@ -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 ){
diff --git a/js/util.js b/js/util.js
index 6d1f88938..d3a34477b 100644
--- a/js/util.js
+++ b/js/util.js
@@ -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;
}
@@ -138,6 +135,26 @@ function bz_isValueInArray(aArray, aValue)
}
/**
+ * 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.
*
* @param aSelect Select form control to manipulate.
diff --git a/show_activity.cgi b/show_activity.cgi
index 31181a63b..1383dd018 100755
--- a/show_activity.cgi
+++ b/show_activity.cgi
@@ -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;
diff --git a/skins/standard/show_bug.css b/skins/standard/show_bug.css
index 2d5ba4309..7c002cc15 100644
--- a/skins/standard/show_bug.css
+++ b/skins/standard/show_bug.css
@@ -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;
+}
+
diff --git a/template/en/default/admin/params/bugfields.html.tmpl b/template/en/default/admin/params/bugfields.html.tmpl
index 07b200825..961833d89 100644
--- a/template/en/default/admin/params/bugfields.html.tmpl
+++ b/template/en/default/admin/params/bugfields.html.tmpl
@@ -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",
+ }
%]
diff --git a/template/en/default/admin/params/groupsecurity.html.tmpl b/template/en/default/admin/params/groupsecurity.html.tmpl
index 7e30f0723..4f0b4919b 100644
--- a/template/en/default/admin/params/groupsecurity.html.tmpl
+++ b/template/en/default/admin/params/groupsecurity.html.tmpl
@@ -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.",
diff --git a/template/en/default/bug/comments.html.tmpl b/template/en/default/bug/comments.html.tmpl
index a2fe4e52f..7aa9a34a6 100644
--- a/template/en/default/bug/comments.html.tmpl
+++ b/template/en/default/bug/comments.html.tmpl
@@ -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 = "";
@@ -74,15 +74,18 @@
<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>
@@ -109,18 +112,21 @@
[% 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 %]
@@ -175,10 +181,29 @@
[% 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>
diff --git a/template/en/default/bug/edit.html.tmpl b/template/en/default/bug/edit.html.tmpl
index a6857f0d5..202a981ea 100644
--- a/template/en/default/bug/edit.html.tmpl
+++ b/template/en/default/bug/edit.html.tmpl
@@ -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 %]
diff --git a/template/en/default/bug/show-header.html.tmpl b/template/en/default/bug/show-header.html.tmpl
index 1aba5c35a..1061b9014 100644
--- a/template/en/default/bug/show-header.html.tmpl
+++ b/template/en/default/bug/show-header.html.tmpl
@@ -24,7 +24,10 @@
[% 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" %]
diff --git a/template/en/default/global/user-error.html.tmpl b/template/en/default/global/user-error.html.tmpl
index 7ca346188..9ba52f733 100644
--- a/template/en/default/global/user-error.html.tmpl
+++ b/template/en/default/global/user-error.html.tmpl
@@ -170,6 +170,8 @@
classifications
[% ELSIF object == "components" %]
components
+ [% ELSIF object == "comment_tags" %]
+ comment tags
[% ELSIF object == "custom_fields" %]
custom fields
[% ELSIF object == "field_values" %]
@@ -318,6 +320,25 @@
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.
@@ -1720,9 +1741,13 @@
[% 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