Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / rt / lib / RT / Search / Simple.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2 #
3 # COPYRIGHT:
4 #
5 # This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
6 #                                          <sales@bestpractical.com>
7 #
8 # (Except where explicitly superseded by other copyright notices)
9 #
10 #
11 # LICENSE:
12 #
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17 #
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22 #
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28 #
29 #
30 # CONTRIBUTION SUBMISSION POLICY:
31 #
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37 #
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46 #
47 # END BPS TAGGED BLOCK }}}
48
49 =head1 NAME
50
51   RT::Search::Simple
52
53 =head1 SYNOPSIS
54
55 =head1 DESCRIPTION
56
57 Use the argument passed in as a simple set of keywords
58
59 =head1 METHODS
60
61 =cut
62
63 package RT::Search::Simple;
64
65 use strict;
66 use warnings;
67 use base qw(RT::Search);
68
69 use Regexp::Common qw/delimited/;
70
71 # Only a subset of limit types AND themselves together.  "queue:foo
72 # queue:bar" is an OR, but "subject:foo subject:bar" is an AND
73 our %AND = (
74     default => 1,
75     content => 1,
76     subject => 1,
77 );
78
79 sub _Init {
80     my $self = shift;
81     my %args = @_;
82
83     $self->{'Queues'} = delete( $args{'Queues'} ) || [];
84     $self->SUPER::_Init(%args);
85 }
86
87 sub Describe {
88     my $self = shift;
89     return ( $self->loc( "Keyword and intuition-based searching", ref $self ) );
90 }
91
92 sub Prepare {
93     my $self = shift;
94     my $tql  = $self->QueryToSQL( $self->Argument );
95
96     $RT::Logger->debug($tql);
97
98     $self->TicketsObj->FromSQL($tql);
99     return (1);
100 }
101
102 sub QueryToSQL {
103     my $self = shift;
104     my $query = shift || $self->Argument;
105
106     my %limits;
107     $query =~ s/^\s*//;
108     while ($query =~ /^\S/) {
109         if ($query =~ s/^
110                         (?:
111                             (\w+)  # A straight word
112                             (?:\.  # With an optional .foo
113                                 ($RE{delimited}{-delim=>q['"]}
114                                 |[\w-]+  # Allow \w + dashes
115                                 ) # Which could be ."foo bar", too
116                             )?
117                         )
118                         :  # Followed by a colon
119                         ($RE{delimited}{-delim=>q['"]}
120                         |\S+
121                         ) # And a possibly-quoted foo:"bar baz"
122                         \s*//ix) {
123             my ($type, $extra, $value) = ($1, $2, $3);
124             ($value, my ($quoted)) = $self->Unquote($value);
125             $extra = $self->Unquote($extra) if defined $extra;
126             $self->Dispatch(\%limits, $type, $value, $quoted, $extra);
127         } elsif ($query =~ s/^($RE{delimited}{-delim=>q['"]}|\S+)\s*//) {
128             # If there's no colon, it's just a word or quoted string
129             my($val, $quoted) = $self->Unquote($1);
130             $self->Dispatch(\%limits, $self->GuessType($val, $quoted), $val, $quoted);
131         }
132     }
133     $self->Finalize(\%limits);
134
135     my @clauses;
136     for my $subclause (sort keys %limits) {
137         next unless @{$limits{$subclause}};
138
139         my $op = $AND{lc $subclause} ? "AND" : "OR";
140         push @clauses, "( ".join(" $op ", @{$limits{$subclause}})." )";
141     }
142
143     return join " AND ", @clauses;
144 }
145
146 sub Dispatch {
147     my $self = shift;
148     my ($limits, $type, $contents, $quoted, $extra) = @_;
149     $contents =~ s/(['\\])/\\$1/g;
150     $extra    =~ s/(['\\])/\\$1/g if defined $extra;
151
152     my $method = "Handle" . ucfirst(lc($type));
153     $method = "HandleDefault" unless $self->can($method);
154     my ($key, @tsql) = $self->$method($contents, $quoted, $extra);
155     push @{$limits->{$key}}, @tsql;
156 }
157
158 sub Unquote {
159     # Given a word or quoted string, unquote it if it is quoted,
160     # removing escaped quotes.
161     my $self = shift;
162     my ($token) = @_;
163     if ($token =~ /^$RE{delimited}{-delim=>q['"]}{-keep}$/) {
164         my $quote = $2 || $5;
165         my $value = $3 || $6;
166         $value =~ s/\\(\\|$quote)/$1/g;
167         return wantarray ? ($value, 1) : $value;
168     } else {
169         return wantarray ? ($token, 0) : $token;
170     }
171 }
172
173 sub Finalize {
174     my $self = shift;
175     my ($limits) = @_;
176
177     # Assume that numbers were actually "default"s if we have other limits
178     if ($limits->{id} and keys %{$limits} > 1) {
179         my $values = delete $limits->{id};
180         for my $value (@{$values}) {
181             $value =~ /(\d+)/ or next;
182             my ($key, @tsql) = $self->HandleDefault($1);
183             push @{$limits->{$key}}, @tsql;
184         }
185     }
186
187     # Apply default "active status" limit if we don't have any status
188     # limits ourselves, and we're not limited by id
189     if (not $limits->{status} and not $limits->{id}
190         and RT::Config->Get('OnlySearchActiveTicketsInSimpleSearch', $self->TicketsObj->CurrentUser)) {
191         $limits->{status} = [map {s/(['\\])/\\$1/g; "Status = '$_'"} RT::Queue->ActiveStatusArray()];
192     }
193
194     # Respect the "only search these queues" limit if we didn't
195     # specify any queues ourselves
196     if (not $limits->{queue} and not $limits->{id}) {
197         for my $queue ( @{ $self->{'Queues'} } ) {
198             my $QueueObj = RT::Queue->new( $self->TicketsObj->CurrentUser );
199             next unless $QueueObj->Load($queue);
200             my $name = $QueueObj->Name;
201             $name =~ s/(['\\])/\\$1/g;
202             push @{$limits->{queue}}, "Queue = '$name'";
203         }
204     }
205 }
206
207 our @GUESS = (
208     [ 10 => sub { return "default" if $_[1] } ],
209     [ 20 => sub { return "id" if /^#?\d+$/ } ],
210     [ 30 => sub { return "requestor" if /\w+@\w+/} ],
211     [ 35 => sub { return "domain" if /^@\w+/} ],
212     [ 40 => sub {
213           return "status" if RT::Queue->new( $_[2] )->IsValidStatus( $_ )
214       }],
215     [ 40 => sub { return "status" if /^((in)?active|any)$/i } ],
216     [ 50 => sub {
217           my $q = RT::Queue->new( $_[2] );
218           return "queue" if $q->Load($_) and $q->Id and not $q->Disabled
219       }],
220     [ 60 => sub {
221           my $u = RT::User->new( $_[2] );
222           return "owner" if $u->Load($_) and $u->Id and $u->Privileged
223       }],
224     [ 70 => sub { return "owner" if $_ eq "me" } ],
225 );
226
227 sub GuessType {
228     my $self = shift;
229     my ($val, $quoted) = @_;
230
231     my $cu = $self->TicketsObj->CurrentUser;
232     for my $sub (map $_->[1], sort {$a->[0] <=> $b->[0]} @GUESS) {
233         local $_ = $val;
234         my $ret = $sub->($val, $quoted, $cu);
235         return $ret if $ret;
236     }
237     return "default";
238 }
239
240 # $_[0] is $self
241 # $_[1] is escaped value without surrounding single quotes
242 # $_[2] is a boolean of "was quoted by the user?"
243 #       ensure this is false before you do smart matching like $_[1] eq "me"
244 # $_[3] is escaped subkey, if any (see HandleCf)
245 sub HandleDefault   {
246     my $fts = RT->Config->Get('FullTextSearch');
247     if ($fts->{Enable} and $fts->{Indexed}) {
248         return default => "Content LIKE '$_[1]'";
249     } else {
250         return default => "Subject LIKE '$_[1]'";
251     }
252 }
253 sub HandleSubject   { return subject   => "Subject LIKE '$_[1]'"; }
254 sub HandleFulltext  { return content   => "Content LIKE '$_[1]'"; }
255 sub HandleContent   { return content   => "Content LIKE '$_[1]'"; }
256 sub HandleId        { $_[1] =~ s/^#//; return id => "Id = $_[1]"; }
257 sub HandleStatus    {
258     if ($_[1] =~ /^active$/i and !$_[2]) {
259         return status => map {s/(['\\])/\\$1/g; "Status = '$_'"} RT::Queue->ActiveStatusArray();
260     } elsif ($_[1] =~ /^inactive$/i and !$_[2]) {
261         return status => map {s/(['\\])/\\$1/g; "Status = '$_'"} RT::Queue->InactiveStatusArray();
262     } elsif ($_[1] =~ /^any$/i and !$_[2]) {
263         return 'status';
264     } else {
265         return status => "Status = '$_[1]'";
266     }
267 }
268 sub HandleOwner     {
269     if (!$_[2] and $_[1] eq "me") {
270         return owner => "Owner.id = '__CurrentUser__'";
271     }
272     elsif (!$_[2] and $_[1] =~ /\w+@\w+/) {
273         return owner => "Owner.EmailAddress = '$_[1]'";
274     } else {
275         return owner => "Owner = '$_[1]'";
276     }
277 }
278 sub HandleWatcher     {
279     return watcher => (!$_[2] and $_[1] eq "me") ? "Watcher.id = '__CurrentUser__'" : "Watcher = '$_[1]'";
280 }
281 sub HandleRequestor { return requestor => "Requestor STARTSWITH '$_[1]'";  }
282 sub HandleDomain    { $_[1] =~ s/^@?/@/; return requestor => "Requestor ENDSWITH '$_[1]'";  }
283 sub HandleQueue     { return queue     => "Queue = '$_[1]'";      }
284 sub HandleQ         { return queue     => "Queue = '$_[1]'";      }
285 sub HandleCf        { return "cf.$_[3]" => "'CF.{$_[3]}' LIKE '$_[1]'"; }
286
287 RT::Base->_ImportOverlays();
288
289 1;