rt 4.0.23
[freeside.git] / rt / lib / RT / Search / Googleish.pm
index 4c14c47..a688f58 100644 (file)
@@ -1,41 +1,40 @@
-
 # BEGIN BPS TAGGED BLOCK {{{
-# 
+#
 # COPYRIGHT:
-# 
-# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC
-#                                          <jesse@bestpractical.com>
-# 
+#
+# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
+#                                          <sales@bestpractical.com>
+#
 # (Except where explicitly superseded by other copyright notices)
-# 
-# 
+#
+#
 # LICENSE:
-# 
+#
 # This work is made available to you under the terms of Version 2 of
 # the GNU General Public License. A copy of that license should have
 # been provided with this software, but in any event can be snarfed
 # from www.gnu.org.
-# 
+#
 # This work is distributed in the hope that it will be useful, but
 # WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 # General Public License for more details.
-# 
+#
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 # 02110-1301 or visit their web page on the internet at
 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-# 
-# 
+#
+#
 # CONTRIBUTION SUBMISSION POLICY:
-# 
+#
 # (The following paragraph is not intended to limit the rights granted
 # to you to modify and distribute this software under the terms of
 # the GNU General Public License and is only of importance to you if
 # you choose to contribute your changes and enhancements to the
 # community by submitting them to Best Practical Solutions, LLC.)
-# 
+#
 # By intentionally submitting any modifications, corrections or
 # derivatives to this work, or any other work intended for use with
 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
 # royalty-free, perpetual, license to use, copy, create derivative
 # works based on those contributions, and sublicense and distribute
 # those contributions and any derivatives thereof.
-# 
+#
 # END BPS TAGGED BLOCK }}}
 
 =head1 NAME
 
-  RT::Search::Googlish
+  RT::Search::Googleish
 
 =head1 SYNOPSIS
 
@@ -59,9 +58,6 @@ Use the argument passed in as a "Google-style" set of keywords
 
 =head1 METHODS
 
-
-
-
 =cut
 
 package RT::Search::Googleish;
@@ -71,139 +67,205 @@ use warnings;
 use base qw(RT::Search);
 
 use Regexp::Common qw/delimited/;
-my $re_delim = qr[$RE{delimited}{-delim=>qq{\'\"}}];
 
-# sub _Init {{{
+# Only a subset of limit types AND themselves together.  "queue:foo
+# queue:bar" is an OR, but "subject:foo subject:bar" is an AND
+our %AND = (
+    content => 1,
+    subject => 1,
+);
+
 sub _Init {
     my $self = shift;
     my %args = @_;
 
-    $self->{'Queues'} = delete($args{'Queues'}) || [];
+    $self->{'Queues'} = delete( $args{'Queues'} ) || [];
     $self->SUPER::_Init(%args);
 }
-# }}}
 
-# {{{ sub Describe 
-sub Describe  {
-  my $self = shift;
-  return ($self->loc("No description for [_1]", ref $self));
+sub Describe {
+    my $self = shift;
+    return ( $self->loc( "Keyword and intuition-based searching", ref $self ) );
 }
-# }}}
 
-# {{{ sub QueryToSQL
-sub QueryToSQL {
-    my $self     = shift;
-    my $query    = shift || $self->Argument;
-
-    my @keywords = grep length, map { s/^\s+//; s/\s+$//; $_ }
-        split /((?:fulltext:)?$re_delim|\s+)/o, $query;
-
-    my (
-        @tql_clauses,  @owner_clauses, @queue_clauses,
-        @user_clauses, @id_clauses,    @status_clauses
-    );
-    my ( $Queue, $User );
-    for my $key (@keywords) {
-
-        # Is this a ticket number? If so, go to it.
-        # But look into subject as well
-        if ( $key =~ m/^\d+$/ ) {
-            push @id_clauses, "id = '$key'", "Subject LIKE '$key'";
-        }
+sub Prepare {
+    my $self = shift;
+    my $tql  = $self->QueryToSQL( $self->Argument );
 
-        # if it's quoted string then search it "as is" in subject or fulltext
-        elsif ( $key =~ /^(fulltext:)?($re_delim)$/io ) {
-            if ( $1 ) {
-                push @tql_clauses, "Content LIKE $2";
-            } else {
-                push @tql_clauses, "Subject LIKE $2";
-            }
-        }
+    $RT::Logger->debug($tql);
 
-        elsif ( $key =~ /^fulltext:(.*?)$/i ) {
-            $key = $1;
-            $key =~ s/['\\].*//g;
-            push @tql_clauses, "Content LIKE '$key'";
+    $self->TicketsObj->FromSQL($tql);
+    return (1);
+}
 
+sub QueryToSQL {
+    my $self = shift;
+    my $query = shift || $self->Argument;
+
+    my %limits;
+    $query =~ s/^\s*//;
+    while ($query =~ /^\S/) {
+        if ($query =~ s/^
+                        (?:
+                            (\w+)  # A straight word
+                            (?:\.  # With an optional .foo
+                                ($RE{delimited}{-delim=>q['"]}
+                                |[\w-]+  # Allow \w + dashes
+                                ) # Which could be ."foo bar", too
+                            )?
+                        )
+                        :  # Followed by a colon
+                        ($RE{delimited}{-delim=>q['"]}
+                        |\S+
+                        ) # And a possibly-quoted foo:"bar baz"
+                        \s*//ix) {
+            my ($type, $extra, $value) = ($1, $2, $3);
+            ($value, my ($quoted)) = $self->Unquote($value);
+            $extra = $self->Unquote($extra) if defined $extra;
+            $self->Dispatch(\%limits, $type, $value, $quoted, $extra);
+        } elsif ($query =~ s/^($RE{delimited}{-delim=>q['"]}|\S+)\s*//) {
+            # If there's no colon, it's just a word or quoted string
+            my($val, $quoted) = $self->Unquote($1);
+            $self->Dispatch(\%limits, $self->GuessType($val, $quoted), $val, $quoted);
         }
+    }
+    $self->Finalize(\%limits);
 
-        elsif ( $key =~ /\w+\@\w+/ ) {
-            push @user_clauses, "Requestor LIKE '$key'";
-        }
+    my @clauses;
+    for my $subclause (sort keys %limits) {
+        next unless @{$limits{$subclause}};
 
-        # Is there a status with this name?
-        elsif (
-            $Queue = RT::Queue->new( $self->TicketsObj->CurrentUser )
-            and $Queue->IsValidStatus($key)
-          )
-        {
-            push @status_clauses, "Status = '" . $key . "'";
-        }
+        my $op = $AND{lc $subclause} ? "AND" : "OR";
+        push @clauses, "( ".join(" $op ", @{$limits{$subclause}})." )";
+    }
 
-        # Is there a queue named $key?
-        elsif ( $Queue = RT::Queue->new( $self->TicketsObj->CurrentUser )
-            and $Queue->Load($key)
-            and $Queue->id )
-        {
-            my $quoted_queue = $Queue->Name;
-            $quoted_queue =~ s/'/\\'/g;
-            push @queue_clauses, "Queue = '$quoted_queue'";
-        }
+    return join " AND ", @clauses;
+}
 
-        # Is there a owner named $key?
-        elsif ( $User = RT::User->new( $self->TicketsObj->CurrentUser )
-            and $User->Load($key)
-            and $User->id
-            and $User->Privileged )
-        {
-            push @owner_clauses, "Owner = '" . $User->Name . "'";
-        }
+sub Dispatch {
+    my $self = shift;
+    my ($limits, $type, $contents, $quoted, $extra) = @_;
+    $contents =~ s/(['\\])/\\$1/g;
+    $extra    =~ s/(['\\])/\\$1/g if defined $extra;
+
+    my $method = "Handle" . ucfirst(lc($type));
+    $method = "HandleDefault" unless $self->can($method);
+    my ($key, @tsql) = $self->$method($contents, $quoted, $extra);
+    push @{$limits->{$key}}, @tsql;
+}
 
-        # Else, subject must contain $key
-        else {
-            $key =~ s/['\\].*//g;
-            push @tql_clauses, "Subject LIKE '$key'";
-        }
+sub Unquote {
+    # Given a word or quoted string, unquote it if it is quoted,
+    # removing escaped quotes.
+    my $self = shift;
+    my ($token) = @_;
+    if ($token =~ /^$RE{delimited}{-delim=>q['"]}{-keep}$/) {
+        my $quote = $2 || $5;
+        my $value = $3 || $6;
+        $value =~ s/\\(\\|$quote)/$1/g;
+        return wantarray ? ($value, 1) : $value;
+    } else {
+        return wantarray ? ($token, 0) : $token;
     }
+}
+
+sub Finalize {
+    my $self = shift;
+    my ($limits) = @_;
 
-    # restrict to any queues requested by the caller
-    for my $queue (@{ $self->{'Queues'} }) {
-        my $QueueObj = RT::Queue->new($self->TicketsObj->CurrentUser);
-        $QueueObj->Load($queue) or next;
-        my $quoted_queue = $QueueObj->Name;
-        $quoted_queue =~ s/'/\\'/g;
-        push @queue_clauses, "Queue = '$quoted_queue'";
+    # Apply default "active status" limit if we don't have any status
+    # limits ourselves, and we're not limited by id
+    if (not $limits->{status} and not $limits->{id}
+        and RT::Config->Get('OnlySearchActiveTicketsInSimpleSearch', $self->TicketsObj->CurrentUser)) {
+        $limits->{status} = [map {s/(['\\])/\\$1/g; "Status = '$_'"} RT::Queue->ActiveStatusArray()];
     }
 
-    push @tql_clauses, join( " OR ", sort @id_clauses );
-    push @tql_clauses, join( " OR ", sort @owner_clauses );
-    if ( ! @status_clauses ) {
-        push @tql_clauses, join( " OR ", map "Status = '$_'", RT::Queue->ActiveStatusArray());
-    } else {
-        push @tql_clauses, join( " OR ", sort @status_clauses );
+    # Respect the "only search these queues" limit if we didn't
+    # specify any queues ourselves
+    if (not $limits->{queue} and not $limits->{id}) {
+        for my $queue ( @{ $self->{'Queues'} } ) {
+            my $QueueObj = RT::Queue->new( $self->TicketsObj->CurrentUser );
+            next unless $QueueObj->Load($queue);
+            my $name = $QueueObj->Name;
+            $name =~ s/(['\\])/\\$1/g;
+            push @{$limits->{queue}}, "Queue = '$name'";
+        }
     }
-    push @tql_clauses, join( " OR ", sort @user_clauses );
-    push @tql_clauses, join( " OR ", sort @queue_clauses );
-    @tql_clauses = grep { $_ ? $_ = "( $_ )" : undef } @tql_clauses;
-    return join " AND ", sort @tql_clauses;
 }
-# }}}
 
-# {{{ sub Prepare
-sub Prepare  {
-  my $self = shift;
-  my $tql = $self->QueryToSQL($self->Argument);
+our @GUESS = (
+    [ 10 => sub { return "subject" if $_[1] } ],
+    [ 20 => sub { return "id" if /^#?\d+$/ } ],
+    [ 30 => sub { return "requestor" if /\w+@\w+/} ],
+    [ 35 => sub { return "domain" if /^@\w+/} ],
+    [ 40 => sub {
+          return "status" if RT::Queue->new( $_[2] )->IsValidStatus( $_ )
+      }],
+    [ 40 => sub { return "status" if /^((in)?active|any)$/i } ],
+    [ 50 => sub {
+          my $q = RT::Queue->new( $_[2] );
+          return "queue" if $q->Load($_) and $q->Id and not $q->Disabled
+      }],
+    [ 60 => sub {
+          my $u = RT::User->new( $_[2] );
+          return "owner" if $u->Load($_) and $u->Id and $u->Privileged
+      }],
+    [ 70 => sub { return "owner" if $_ eq "me" } ],
+);
+
+sub GuessType {
+    my $self = shift;
+    my ($val, $quoted) = @_;
 
-  $RT::Logger->debug($tql);
+    my $cu = $self->TicketsObj->CurrentUser;
+    for my $sub (map $_->[1], sort {$a->[0] <=> $b->[0]} @GUESS) {
+        local $_ = $val;
+        my $ret = $sub->($val, $quoted, $cu);
+        return $ret if $ret;
+    }
+    return "default";
+}
 
-  $self->TicketsObj->FromSQL($tql);
-  return(1);
+# $_[0] is $self
+# $_[1] is escaped value without surrounding single quotes
+# $_[2] is a boolean of "was quoted by the user?"
+#       ensure this is false before you do smart matching like $_[1] eq "me"
+# $_[3] is escaped subkey, if any (see HandleCf)
+sub HandleDefault   { return subject   => "Subject LIKE '$_[1]'"; }
+sub HandleSubject   { return subject   => "Subject LIKE '$_[1]'"; }
+sub HandleFulltext  { return content   => "Content LIKE '$_[1]'"; }
+sub HandleContent   { return content   => "Content LIKE '$_[1]'"; }
+sub HandleId        { $_[1] =~ s/^#//; return id => "Id = $_[1]"; }
+sub HandleStatus    {
+    if ($_[1] =~ /^active$/i and !$_[2]) {
+        return status => map {s/(['\\])/\\$1/g; "Status = '$_'"} RT::Queue->ActiveStatusArray();
+    } elsif ($_[1] =~ /^inactive$/i and !$_[2]) {
+        return status => map {s/(['\\])/\\$1/g; "Status = '$_'"} RT::Queue->InactiveStatusArray();
+    } elsif ($_[1] =~ /^any$/i and !$_[2]) {
+        return 'status';
+    } else {
+        return status => "Status = '$_[1]'";
+    }
+}
+sub HandleOwner     {
+    if (!$_[2] and $_[1] eq "me") {
+        return owner => "Owner.id = '__CurrentUser__'";
+    }
+    elsif (!$_[2] and $_[1] =~ /\w+@\w+/) {
+        return owner => "Owner.EmailAddress = '$_[1]'";
+    } else {
+        return owner => "Owner = '$_[1]'";
+    }
+}
+sub HandleWatcher     {
+    return watcher => (!$_[2] and $_[1] eq "me") ? "Watcher.id = '__CurrentUser__'" : "Watcher = '$_[1]'";
 }
-# }}}
+sub HandleRequestor { return requestor => "Requestor STARTSWITH '$_[1]'";  }
+sub HandleDomain    { $_[1] =~ s/^@?/@/; return requestor => "Requestor ENDSWITH '$_[1]'";  }
+sub HandleQueue     { return queue     => "Queue = '$_[1]'";      }
+sub HandleQ         { return queue     => "Queue = '$_[1]'";      }
+sub HandleCf        { return "cf.$_[3]" => "'CF.{$_[3]}' LIKE '$_[1]'"; }
 
-eval "require RT::Search::Googleish_Vendor";
-die $@ if ($@ && $@ !~ qr{^Can't locate RT/Search/Googleish_Vendor.pm});
-eval "require RT::Search::Googleish_Local";
-die $@ if ($@ && $@ !~ qr{^Can't locate RT/Search/Googleish_Local.pm});
+RT::Base->_ImportOverlays();
 
 1;