# 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::Search::Quicksearch; use 5.10.1; use strict; use Bugzilla::Error; use Bugzilla::Constants; use Bugzilla::Keyword; use Bugzilla::Status; use Bugzilla::Field; use Bugzilla::Util; use List::Util qw(min max); use List::MoreUtils qw(firstidx); use Text::ParseWords qw(parse_line); use parent qw(Exporter); @Bugzilla::Search::Quicksearch::EXPORT = qw(quicksearch); # Custom mappings for some fields. use constant MAPPINGS => { # Status, Resolution, Platform, OS, Priority, Severity "status" => "bug_status", "platform" => "rep_platform", "os" => "op_sys", "severity" => "bug_severity", # People: AssignedTo, Reporter, QA Contact, CC, etc. "assignee" => "assigned_to", "owner" => "assigned_to", # Product, Version, Component, Target Milestone "milestone" => "target_milestone", # Summary, Description, URL, Status whiteboard, Keywords "summary" => "short_desc", "description" => "longdesc", "comment" => "longdesc", "url" => "bug_file_loc", "whiteboard" => "status_whiteboard", "sw" => "status_whiteboard", "kw" => "keywords", "group" => "bug_group", # Flags "flag" => "flagtypes.name", "requestee" => "requestees.login_name", "setter" => "setters.login_name", # Attachments "attachment" => "attachments.description", "attachmentdesc" => "attachments.description", "attachdesc" => "attachments.description", "attachmentdata" => "attach_data.thedata", "attachdata" => "attach_data.thedata", "attachmentmimetype" => "attachments.mimetype", "attachmimetype" => "attachments.mimetype" }; sub FIELD_MAP { my $cache = Bugzilla->request_cache; return $cache->{quicksearch_fields} if $cache->{quicksearch_fields}; # Get all the fields whose names don't contain periods. (Fields that # contain periods are always handled in MAPPINGS.) my @db_fields = grep { $_->name !~ /\./ } @{ Bugzilla->fields({ obsolete => 0 }) }; my %full_map = (%{ MAPPINGS() }, map { $_->name => $_->name } @db_fields); # Eliminate the fields that start with bug_ or rep_, because those are # handled by the MAPPINGS instead, and we don't want too many names # for them. (Also, otherwise "rep" doesn't match "reporter".) # # Remove "status_whiteboard" because we have "whiteboard" for it in # the mappings, and otherwise "stat" can't match "status". # # Also, don't allow searching the _accessible stuff via quicksearch # (both because it's unnecessary and because otherwise # "reporter_accessible" and "reporter" both match "rep". delete @full_map{qw(rep_platform bug_status bug_file_loc bug_group bug_severity bug_status status_whiteboard cclist_accessible reporter_accessible)}; Bugzilla::Hook::process('quicksearch_map', {'map' => \%full_map} ); $cache->{quicksearch_fields} = \%full_map; return $cache->{quicksearch_fields}; } # Certain fields, when specified like "field:value" get an operator other # than "substring" use constant FIELD_OPERATOR => { content => 'matches', owner_idle_time => 'greaterthan', }; # Mappings for operators symbols to support operators other than "substring" use constant OPERATOR_SYMBOLS => { ':' => 'substring', '=' => 'equals', '!=' => 'notequals', '>=' => 'greaterthaneq', '<=' => 'lessthaneq', '>' => 'greaterthan', '<' => 'lessthan', }; # We might want to put this into localconfig or somewhere use constant PRODUCT_EXCEPTIONS => ( 'row', # [Browser] # ^^^ 'new', # [MailNews] # ^^^ ); use constant COMPONENT_EXCEPTIONS => ( 'hang' # [Bugzilla: Component/Keyword Changes] # ^^^^ ); # Quicksearch-wide globals for boolean charts. our ($chart, $and, $or, $fulltext, $bug_status_set); sub quicksearch { my ($searchstring) = (@_); my $cgi = Bugzilla->cgi; $chart = 0; $and = 0; $or = 0; # Remove leading and trailing commas and whitespace. $searchstring =~ s/(^[\s,]+|[\s,]+$)//g; ThrowUserError('buglist_parameters_required') unless ($searchstring); if ($searchstring =~ m/^[0-9,\s]*$/) { _bug_numbers_only($searchstring); } else { _handle_alias($searchstring); # Retain backslashes and quotes, to know which strings are quoted, # and which ones are not. my @words = _parse_line('\s+', 1, $searchstring); # If parse_line() returns no data, this means strings are badly quoted. # Rather than trying to guess what the user wanted to do, we throw an error. scalar(@words) || ThrowUserError('quicksearch_unbalanced_quotes', {string => $searchstring}); # A query cannot start with AND or OR, nor can it end with AND, OR or NOT. ThrowUserError('quicksearch_invalid_query') if ($words[0] =~ /^(?:AND|OR)$/ || $words[$#words] =~ /^(?:AND|OR|NOT)$/); my (@qswords, @or_group); while (scalar @words) { my $word = shift @words; # AND is the default word separator, similar to a whitespace, # but |a AND OR b| is not a valid combination. if ($word eq 'AND') { ThrowUserError('quicksearch_invalid_query', {operators => ['AND', 'OR']}) if $words[0] eq 'OR'; } # |a OR AND b| is not a valid combination. # |a OR OR b| is equivalent to |a OR b| and so is harmless. elsif ($word eq 'OR') { ThrowUserError('quicksearch_invalid_query', {operators => ['OR', 'AND']}) if $words[0] eq 'AND'; } # NOT negates the following word. # |NOT AND| and |NOT OR| are not valid combinations. # |NOT NOT| is fine but has no effect as they cancel themselves. elsif ($word eq 'NOT') { $word = shift @words; next if $word eq 'NOT'; if ($word eq 'AND' || $word eq 'OR') { ThrowUserError('quicksearch_invalid_query', {operators => ['NOT', $word]}); } unshift(@words, "-$word"); } else { # OR groups words together, as OR has higher precedence than AND. push(@or_group, $word); # If the next word is not OR, then we are not in a OR group, # or we are leaving it. if (!defined $words[0] || $words[0] ne 'OR') { push(@qswords, join('|', @or_group)); @or_group = (); } } } _handle_status_and_resolution($qswords[0]); shift(@qswords) if $bug_status_set; my (@unknownFields, %ambiguous_fields); $fulltext = Bugzilla->user->setting('quicksearch_fulltext') eq 'on' ? 1 : 0; # Loop over all main-level QuickSearch words. foreach my $qsword (@qswords) { my @or_operand = _parse_line('\|', 1, $qsword); foreach my $term (@or_operand) { next unless defined $term; my $negate = substr($term, 0, 1) eq '-'; if ($negate) { $term = substr($term, 1); } next if _handle_special_first_chars($term, $negate); next if _handle_field_names($term, $negate, \@unknownFields, \%ambiguous_fields); # Having ruled out the special cases, we may now split # by comma, which is another legal boolean OR indicator. # Remove quotes from quoted words, if any. @words = _parse_line(',', 0, $term); foreach my $word (@words) { if (!_special_field_syntax($word, $negate)) { _default_quicksearch_word($word, $negate); } _handle_urls($word, $negate); } } $chart++; $and = 0; $or = 0; } # If there is no mention of a bug status, we restrict the query # to open bugs by default. unless ($bug_status_set) { $cgi->param('bug_status', BUG_STATE_OPEN); } # Inform user about any unknown fields if (scalar(@unknownFields) || scalar(keys %ambiguous_fields)) { ThrowUserError("quicksearch_unknown_field", { unknown => \@unknownFields, ambiguous => \%ambiguous_fields }); } # Make sure we have some query terms left scalar($cgi->param())>0 || ThrowUserError("buglist_parameters_required"); } # List of quicksearch-specific CGI parameters to get rid of. my @params_to_strip = ('quicksearch', 'load', 'run'); my $modified_query_string = $cgi->canonicalise_query(@params_to_strip); if ($cgi->param('load')) { my $urlbase = correct_urlbase(); # Param 'load' asks us to display the query in the advanced search form. print $cgi->redirect(-uri => "${urlbase}query.cgi?format=advanced&" . $modified_query_string); } # Otherwise, pass the modified query string to the caller. # We modified $cgi->params, so the caller can choose to look at that, too, # and disregard the return value. $cgi->delete(@params_to_strip); return $modified_query_string; } ########################## # Parts of quicksearch() # ########################## sub _parse_line { my ($delim, $keep, $line) = @_; return () unless defined $line; # parse_line always treats ' as a quote character, making it impossible # to sanely search for contractions. As this behavour isn't # configurable, we replace ' with a placeholder to hide it from the # parser. # only treat ' at the start or end of words as quotes # it's easier to do this in reverse with regexes $line =~ s/(^|\s|:)'/$1\001/g; $line =~ s/'($|\s)/\001$1/g; $line =~ s/\\?'/\000/g; $line =~ tr/\001/'/; my @words = parse_line($delim, $keep, $line); foreach my $word (@words) { $word =~ tr/\000/'/ if defined $word; } return @words; } sub _bug_numbers_only { my $searchstring = shift; my $cgi = Bugzilla->cgi; # Allow separation by comma or whitespace. $searchstring =~ s/[,\s]+/,/g; if ($searchstring !~ /,/ && !i_am_webservice()) { # Single bug number; shortcut to show_bug.cgi. print $cgi->redirect( -uri => correct_urlbase() . "show_bug.cgi?id=$searchstring"); exit; } else { # List of bug numbers. $cgi->param('bug_id', $searchstring); $cgi->param('order', 'bugs.bug_id'); $cgi->param('bug_id_type', 'anyexact'); } } sub _handle_alias { my $searchstring = shift; if ($searchstring =~ /^([^,\s]+)$/) { my $alias = $1; # We use this direct SQL because we want quicksearch to be VERY fast. my $bug_id = Bugzilla->dbh->selectrow_array( q{SELECT bug_id FROM bugs WHERE alias = ?}, undef, $alias); # If the user cannot see the bug or if we are using a webservice, # do not resolve its alias. if ($bug_id && Bugzilla->user->can_see_bug($bug_id) && !i_am_webservice()) { $alias = url_quote($alias); print Bugzilla->cgi->redirect( -uri => correct_urlbase() . "show_bug.cgi?id=$alias"); exit; } } } sub _handle_status_and_resolution { my $word = shift; my $legal_statuses = get_legal_field_values('bug_status'); my (%states, %resolutions); $bug_status_set = 1; if ($word eq 'OPEN') { $states{$_} = 1 foreach BUG_STATE_OPEN; } # If we want all bugs, then there is nothing to do. elsif ($word ne 'ALL' && !matchPrefixes(\%states, \%resolutions, $word, $legal_statuses)) { $bug_status_set = 0; } # If we have wanted resolutions, allow closed states if (keys(%resolutions)) { foreach my $status (@$legal_statuses) { $states{$status} = 1 unless is_open_state($status); } } Bugzilla->cgi->param('bug_status', keys(%states)); Bugzilla->cgi->param('resolution', keys(%resolutions)); } sub _handle_special_first_chars { my ($qsword, $negate) = @_; return 0 if !defined $qsword || length($qsword) <= 1; my $firstChar = substr($qsword, 0, 1); my $baseWord = substr($qsword, 1); my @subWords = split(/,/, $baseWord); if ($firstChar eq '#') { addChart('short_desc', 'substring', $baseWord, $negate); addChart('content', 'matches', _matches_phrase($baseWord), $negate) if $fulltext; return 1; } if ($firstChar eq ':') { foreach (@subWords) { addChart('product', 'substring', $_, $negate); addChart('component', 'substring', $_, $negate); } return 1; } if ($firstChar eq '@') { addChart('assigned_to', 'substring', $_, $negate) foreach (@subWords); return 1; } if ($firstChar eq '[') { addChart('short_desc', 'substring', $baseWord, $negate); addChart('status_whiteboard', 'substring', $baseWord, $negate); return 1; } if ($firstChar eq '!') { addChart('keywords', 'anywords', $baseWord, $negate); return 1; } return 0; } sub _handle_field_names { my ($or_operand, $negate, $unknownFields, $ambiguous_fields) = @_; # Generic field1,field2,field3:value1,value2 notation. # We have to correctly ignore commas and colons in quotes. # Longer operators must be tested first as we don't want single character # operators such as <, > and = to be tested before <=, >= and !=. my @operators = sort { length($b) <=> length($a) } keys %{ OPERATOR_SYMBOLS() }; foreach my $symbol (@operators) { my @field_values = _parse_line($symbol, 1, $or_operand); next unless scalar @field_values == 2; my @fields = _parse_line(',', 1, $field_values[0]); my @values = _parse_line(',', 1, $field_values[1]); foreach my $field (@fields) { my $translated = _translate_field_name($field); # Skip and record any unknown fields if (!defined $translated) { push(@$unknownFields, $field); } # If we got back an array, that means the substring is # ambiguous and could match more than field name elsif (ref $translated) { $ambiguous_fields->{$field} = $translated; } else { if ($translated eq 'bug_status' || $translated eq 'resolution') { $bug_status_set = 1; } foreach my $value (@values) { my $operator = FIELD_OPERATOR->{$translated} || OPERATOR_SYMBOLS->{$symbol} || 'substring'; # If the string was quoted to protect some special # characters such as commas and colons, we need # to remove quotes. if ($value =~ /^(["'])(.+)\1$/) { $value = $2; $value =~ s/\\(["'])/$1/g; } # If a requestee is set, we need to handle it separately. if ($translated eq 'flagtypes.name' && $value =~ /^([^\?]+\?)([^\?]+)$/) { _handle_flags($1, $2, $negate); next; } addChart($translated, $operator, $value, $negate); } } } return 1; } # Do not look inside quoted strings. return 0 if ($or_operand =~ /^(["']).*\1$/); # Flag and requestee shortcut. if ($or_operand =~ /^([^\?]+\?)([^\?]*)$/) { _handle_flags($1, $2, $negate); return 1; } return 0; } sub _handle_flags { my ($flag, $requestee, $negate) = @_; addChart('flagtypes.name', 'substring', $flag, $negate); if ($requestee) { # FIXME - Every time a requestee is involved and you use OR somewhere # in your quick search, the logic will be wrong because boolean charts # are unable to run queries of the form (a AND b) OR c. In our case: # (flag name is foo AND requestee is bar) OR (any other criteria). # But this has never been possible, so this is not a regression. If one # needs to run such queries, they must use the Custom Search section of # the Advanced Search page. $chart++; $and = $or = 0; addChart('requestees.login_name', 'substring', $requestee, $negate); } } sub _translate_field_name { my $field = shift; $field = lc($field); my $field_map = FIELD_MAP; # If the field exactly matches a mapping, just return right now. return $field_map->{$field} if exists $field_map->{$field}; # Check if we match, as a starting substring, exactly one field. my @field_names = keys %$field_map; my @matches = grep { $_ =~ /^\Q$field\E/ } @field_names; # Eliminate duplicates that are actually the same field # (otherwise "assi" matches both "assignee" and "assigned_to", and # the lines below fail when they shouldn't.) my %match_unique = map { $field_map->{$_} => $_ } @matches; @matches = values %match_unique; if (scalar(@matches) == 1) { return $field_map->{$matches[0]}; } elsif (scalar(@matches) > 1) { return \@matches; } # Check if we match exactly one custom field, ignoring the cf_ on the # custom fields (to allow people to type things like "build" for # "cf_build"). my %cfless; foreach my $name (@field_names) { my $no_cf = $name; if ($no_cf =~ s/^cf_//) { if ($field eq $no_cf) { return $field_map->{$name}; } $cfless{$no_cf} = $name; } } # See if we match exactly one substring of any of the cf_-less fields. my @cfless_matches = grep { $_ =~ /^\Q$field\E/ } (keys %cfless); if (scalar(@cfless_matches) == 1) { my $match = $cfless_matches[0]; my $actual_field = $cfless{$match}; return $field_map->{$actual_field}; } elsif (scalar(@matches) > 1) { return \@matches; } return undef; } sub _special_field_syntax { my ($word, $negate) = @_; # P1-5 Syntax if ($word =~ m/^P(\d+)(?:-(\d+))?$/i) { my ($p_start, $p_end) = ($1, $2); my $legal_priorities = get_legal_field_values('priority'); # If Pn exists explicitly, use it. my $start = firstidx { $_ eq "P$p_start" } @$legal_priorities; my $end; $end = firstidx { $_ eq "P$p_end" } @$legal_priorities if defined $p_end; # If Pn doesn't exist explicitly, then we mean the nth priority. if ($start == -1) { $start = max(0, $p_start - 1); } my $prios = $legal_priorities->[$start]; if (defined $end) { # If Pn doesn't exist explicitly, then we mean the nth priority. if ($end == -1) { $end = min(scalar(@$legal_priorities), $p_end) - 1; $end = max(0, $end); # Just in case the user typed P0. } ($start, $end) = ($end, $start) if $end < $start; $prios = join(',', @$legal_priorities[$start..$end]) } addChart('priority', 'anyexact', $prios, $negate); return 1; } return 0; } sub _default_quicksearch_word { my ($word, $negate) = @_; if (!grep { lc($word) eq $_ } PRODUCT_EXCEPTIONS and length($word) > 2) { addChart('product', 'substring', $word, $negate); } if (!grep { lc($word) eq $_ } COMPONENT_EXCEPTIONS and length($word) > 2) { addChart('component', 'substring', $word, $negate); } my @legal_keywords = map($_->name, Bugzilla::Keyword->get_all); if (grep { lc($word) eq lc($_) } @legal_keywords) { addChart('keywords', 'substring', $word, $negate); } addChart('alias', 'substring', $word, $negate); addChart('short_desc', 'substring', $word, $negate); addChart('status_whiteboard', 'substring', $word, $negate); addChart('content', 'matches', _matches_phrase($word), $negate) if $fulltext; } sub _handle_urls { my ($word, $negate) = @_; # URL field (for IP addrs, host.names, # scheme://urls) if ($word =~ m/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ || $word =~ /^[A-Za-z]+(\.[A-Za-z]+)+/ || $word =~ /:[\\\/][\\\/]/ || $word =~ /localhost/ || $word =~ /mailto[:]?/) # || $word =~ /[A-Za-z]+[:][0-9]+/ #host:port { addChart('bug_file_loc', 'substring', $word, $negate); } } ########################################################################### # Helpers ########################################################################### # Quote and escape a phrase appropriately for a "content matches" search. sub _matches_phrase { my ($phrase) = @_; $phrase =~ s/"/\\"/g; return "\"$phrase\""; } # Expand found prefixes to states or resolutions sub matchPrefixes { my ($hr_states, $hr_resolutions, $word, $ar_check_states) = @_; return unless $word =~ /^[A-Z_]+(,[A-Z_]+)*$/; my @ar_prefixes = split(/,/, $word); my $ar_check_resolutions = get_legal_field_values('resolution'); my $foundMatch = 0; foreach my $prefix (@ar_prefixes) { foreach (@$ar_check_states) { if (/^$prefix/) { $$hr_states{$_} = 1; $foundMatch = 1; } } foreach (@$ar_check_resolutions) { if (/^$prefix/) { $$hr_resolutions{$_} = 1; $foundMatch = 1; } } } return $foundMatch; } # Negate comparison type sub negateComparisonType { my $comparisonType = shift; if ($comparisonType eq 'anywords') { return 'nowords'; } return "not$comparisonType"; } # Add a boolean chart sub addChart { my ($field, $comparisonType, $value, $negate) = @_; $negate && ($comparisonType = negateComparisonType($comparisonType)); makeChart("$chart-$and-$or", $field, $comparisonType, $value); if ($negate) { $and++; $or = 0; } else { $or++; } } # Create the CGI parameters for a boolean chart sub makeChart { my ($expr, $field, $type, $value) = @_; my $cgi = Bugzilla->cgi; $cgi->param("field$expr", $field); $cgi->param("type$expr", $type); $cgi->param("value$expr", $value); } 1; =head1 B =over =item FIELD_MAP =item quicksearch =item negateComparisonType =item makeChart =item addChart =item matchPrefixes =back