RT 4.0.22
[freeside.git] / rt / sbin / rt-fulltext-indexer
1 #!/usr/bin/perl
2 # BEGIN BPS TAGGED BLOCK {{{
3 #
4 # COPYRIGHT:
5 #
6 # This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
7 #                                          <sales@bestpractical.com>
8 #
9 # (Except where explicitly superseded by other copyright notices)
10 #
11 #
12 # LICENSE:
13 #
14 # This work is made available to you under the terms of Version 2 of
15 # the GNU General Public License. A copy of that license should have
16 # been provided with this software, but in any event can be snarfed
17 # from www.gnu.org.
18 #
19 # This work is distributed in the hope that it will be useful, but
20 # WITHOUT ANY WARRANTY; without even the implied warranty of
21 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
22 # General Public License for more details.
23 #
24 # You should have received a copy of the GNU General Public License
25 # along with this program; if not, write to the Free Software
26 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
27 # 02110-1301 or visit their web page on the internet at
28 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
29 #
30 #
31 # CONTRIBUTION SUBMISSION POLICY:
32 #
33 # (The following paragraph is not intended to limit the rights granted
34 # to you to modify and distribute this software under the terms of
35 # the GNU General Public License and is only of importance to you if
36 # you choose to contribute your changes and enhancements to the
37 # community by submitting them to Best Practical Solutions, LLC.)
38 #
39 # By intentionally submitting any modifications, corrections or
40 # derivatives to this work, or any other work intended for use with
41 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
42 # you are the copyright holder for those contributions and you grant
43 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
44 # royalty-free, perpetual, license to use, copy, create derivative
45 # works based on those contributions, and sublicense and distribute
46 # those contributions and any derivatives thereof.
47 #
48 # END BPS TAGGED BLOCK }}}
49 use strict;
50 use warnings;
51 no warnings 'once';
52
53 # fix lib paths, some may be relative
54 BEGIN {
55     require File::Spec;
56     my @libs = ("/opt/rt3/lib", "/opt/rt3/local/lib");
57     my $bin_path;
58
59     for my $lib (@libs) {
60         unless ( File::Spec->file_name_is_absolute($lib) ) {
61             unless ($bin_path) {
62                 if ( File::Spec->file_name_is_absolute(__FILE__) ) {
63                     $bin_path = ( File::Spec->splitpath(__FILE__) )[1];
64                 }
65                 else {
66                     require FindBin;
67                     no warnings "once";
68                     $bin_path = $FindBin::Bin;
69                 }
70             }
71             $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
72         }
73         unshift @INC, $lib;
74     }
75 }
76
77 BEGIN {
78     use RT;
79     RT::LoadConfig();
80     RT::Init();
81 };
82 use RT::Interface::CLI ();
83
84 my %OPT = (
85     help        => 0,
86     debug       => 0,
87     quiet       => 0,
88 );
89 my @OPT_LIST = qw(help|h! debug! quiet);
90
91 my $db_type = RT->Config->Get('DatabaseType');
92 if ( $db_type eq 'Pg' ) {
93     %OPT = (
94         %OPT,
95         limit  => 0,
96         all    => 0,
97     );
98     push @OPT_LIST, 'limit=i', 'all!';
99 }
100 elsif ( $db_type eq 'mysql' ) {
101     %OPT = (
102         %OPT,
103         limit    => 0,
104         all      => 0,
105         xmlpipe2 => 0,
106     );
107     push @OPT_LIST, 'limit=i', 'all!', 'xmlpipe2!';
108 }
109 elsif ( $db_type eq 'Oracle' ) {
110     %OPT = (
111         %OPT,
112         memory => '2M',
113     );
114     push @OPT_LIST, qw(memory=s);
115 }
116
117 use Getopt::Long qw(GetOptions);
118 GetOptions( \%OPT, @OPT_LIST );
119
120 if ( $OPT{'help'} ) {
121     RT::Interface::CLI->ShowHelp(
122         Sections => 'NAME|DESCRIPTION|'. uc($db_type),
123     );
124 }
125
126 use Fcntl ':flock';
127 if ( !flock main::DATA, LOCK_EX | LOCK_NB ) {
128     if ( $OPT{quiet} ) {
129         RT::Logger->info("$0 is already running; aborting silently, as requested");
130         exit;
131     }
132     else {
133         print STDERR "$0 is already running\n";
134         exit 1;
135     }
136 }
137
138 my $fts_config = RT->Config->Get('FullTextSearch') || {};
139 unless ( $fts_config->{'Enable'} ) {
140     print STDERR <<EOT;
141
142 Full text search is disabled in your RT configuration.  Run
143 /opt/rt3/sbin/rt-setup-fulltext-index to configure and enable it.
144
145 EOT
146     exit 1;
147 }
148 unless ( $fts_config->{'Indexed'} ) {
149     print STDERR <<EOT;
150
151 Full text search is enabled in your RT configuration, but not with any
152 full-text database indexing -- hence this tool is not required.  Read
153 the documentation for %FullTextSearch in your RT_Config for more details.
154
155 EOT
156     exit 1;
157 }
158
159 if ( $db_type eq 'Oracle' ) {
160     my $index = $fts_config->{'IndexName'} || 'rt_fts_index';
161     $RT::Handle->dbh->do(
162         "begin ctx_ddl.sync_index(?, ?); end;", undef,
163         $index, $OPT{'memory'}
164     );
165     exit;
166 } elsif ( $db_type eq 'mysql' ) {
167     unless ($OPT{'xmlpipe2'}) {
168         print STDERR <<EOT;
169
170 Updates to the external Sphinx index are done via running the sphinx
171 `indexer` tool:
172
173     indexer rt
174
175 EOT
176         exit 1;
177     }
178 }
179
180 my @types = qw(text html);
181 foreach my $type ( @types ) {
182   REDO:
183     my $attachments = attachments($type);
184     $attachments->Limit(
185         FIELD => 'id',
186         OPERATOR => '>',
187         VALUE => last_indexed($type)
188     );
189     $attachments->OrderBy( FIELD => 'id', ORDER => 'asc' );
190     $attachments->RowsPerPage( $OPT{'limit'} || 100 );
191
192     my $found = 0;
193     while ( my $a = $attachments->Next ) {
194         next if filter( $type, $a );
195         debug("Found attachment #". $a->id );
196         my $txt = extract($type, $a) or next;
197         $found++;
198         process( $type, $a, $txt );
199         debug("Processed attachment #". $a->id );
200     }
201     finalize( $type, $attachments ) if $found;
202     clean( $type );
203     goto REDO if $OPT{'all'} and $attachments->Count == ($OPT{'limit'} || 100)
204 }
205
206 sub attachments {
207     my $type = shift;
208     my $res = RT::Attachments->new( RT->SystemUser );
209     my $txn_alias = $res->Join(
210         ALIAS1 => 'main',
211         FIELD1 => 'TransactionId',
212         TABLE2 => 'Transactions',
213         FIELD2 => 'id',
214     );
215     $res->Limit(
216         ALIAS => $txn_alias,
217         FIELD => 'ObjectType',
218         VALUE => 'RT::Ticket',
219     );
220     my $ticket_alias = $res->Join(
221         ALIAS1 => $txn_alias,
222         FIELD1 => 'ObjectId',
223         TABLE2 => 'Tickets',
224         FIELD2 => 'id',
225     );
226     $res->Limit(
227         ALIAS => $ticket_alias,
228         FIELD => 'Status',
229         OPERATOR => '!=',
230         VALUE => 'deleted'
231     );
232
233     # On newer DBIx::SearchBuilder's, indicate that making the query DISTINCT
234     # is unnecessary because the joins won't produce duplicates.  This
235     # drastically improves performance when fetching attachments.
236     $res->{joins_are_distinct} = 1;
237
238     return goto_specific(
239         suffix => $type,
240         error => "Don't know how to find $type attachments",
241         arguments => [$res],
242     );
243 }
244
245 sub last_indexed {
246     my ($type) = (@_);
247     return goto_specific(
248         suffix => $db_type,
249         error => "Don't know how to find last indexed $type attachment for $db_type DB",
250         arguments => \@_,
251     );
252 }
253
254 sub filter {
255     my $type = shift;
256     return goto_specific(
257         suffix    => $type,
258         arguments => \@_,
259     );
260 }
261
262 sub extract {
263     my $type = shift;
264     return goto_specific(
265         suffix    => $type,
266         error     => "No way to convert $type attachment into text",
267         arguments => \@_,
268     );
269 }
270
271 sub process {
272     return goto_specific(
273         suffix    => $db_type,
274         error     => "No processer for $db_type DB",
275         arguments => \@_,
276     );
277 }
278
279 sub finalize {
280     return goto_specific(
281         suffix    => $db_type,
282         arguments => \@_,
283     );
284 }
285
286 sub clean {
287     return goto_specific(
288         suffix    => $db_type,
289         arguments => \@_,
290     );
291 }
292
293 {
294 sub last_indexed_mysql {
295     my $type = shift;
296     my $attr = $RT::System->FirstAttribute('LastIndexedAttachments');
297     return 0 unless $attr;
298     return 0 unless exists $attr->{ $type };
299     return $attr->{ $type } || 0;
300 }
301
302 sub process_mysql {
303     my ($type, $attachment, $text) = (@_);
304
305     my $doc = sphinx_template();
306
307     my $element = $doc->createElement('sphinx:document');
308     $element->setAttribute( id => $attachment->id );
309     $element->appendTextChild( content => $$text );
310
311     $doc->documentElement->appendChild( $element );
312 }
313
314 my $doc = undef;
315 sub sphinx_template {
316     return $doc if $doc;
317
318     require XML::LibXML;
319     $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
320     my $root = $doc->createElement('sphinx:docset');
321     $doc->setDocumentElement( $root );
322
323     my $schema = $doc->createElement('sphinx:schema');
324     $root->appendChild( $schema );
325     foreach ( qw(content) ) {
326         my $field = $doc->createElement('sphinx:field');
327         $field->setAttribute( name => $_ );
328         $schema->appendChild( $field );
329     }
330
331     return $doc;
332 }
333
334 sub finalize_mysql {
335     my ($type, $attachments) = @_;
336     sphinx_template()->toFH(*STDOUT, 1);
337 }
338
339 sub clean_mysql {
340     $doc = undef;
341 }
342
343 }
344
345 sub last_indexed_pg {
346     my $type = shift;
347     my $attachments = attachments( $type );
348     my $alias = 'main';
349     if ( $fts_config->{'Table'} && $fts_config->{'Table'} ne 'Attachments' ) {
350         $alias = $attachments->Join(
351             TYPE    => 'left',
352             FIELD1 => 'id',
353             TABLE2  => $fts_config->{'Table'},
354             FIELD2 => 'id',
355         );
356     }
357     $attachments->Limit(
358         ALIAS => $alias,
359         FIELD => $fts_config->{'Column'},
360         OPERATOR => 'IS NOT',
361         VALUE => 'NULL',
362     );
363     $attachments->OrderBy( FIELD => 'id', ORDER => 'desc' );
364     $attachments->RowsPerPage( 1 );
365     my $res = $attachments->First;
366     return 0 unless $res;
367     return $res->id;
368 }
369
370 sub process_pg {
371     my ($type, $attachment, $text) = (@_);
372
373     my $dbh = $RT::Handle->dbh;
374     my $table = $fts_config->{'Table'};
375     my $column = $fts_config->{'Column'};
376
377     my $query;
378     if ( $table ) {
379         if ( my ($id) = $dbh->selectrow_array("SELECT id FROM $table WHERE id = ?", undef, $attachment->id) ) {
380             $query = "UPDATE $table SET $column = to_tsvector(?) WHERE id = ?";
381         } else {
382             $query = "INSERT INTO $table($column, id) VALUES(to_tsvector(?), ?)";
383         }
384     } else {
385         $query = "UPDATE Attachments SET $column = to_tsvector(?) WHERE id = ?";
386     }
387
388     my $status = eval { $dbh->do( $query, undef, $$text, $attachment->id ) };
389     unless ( $status ) {
390         if ( $dbh->err == 7  && $dbh->state eq '54000' ) {
391             warn "Attachment @{[$attachment->id]} cannot be indexed. Most probably it contains too many unique words. Error: ". $dbh->errstr;
392         } elsif ( $dbh->err == 7 && $dbh->state eq '22021' ) {
393             warn "Attachment @{[$attachment->id]} cannot be indexed. Most probably it contains invalid UTF8 bytes. Error: ". $dbh->errstr;
394         } else {
395             die "error: ". $dbh->errstr;
396         }
397
398         # Insert an empty tsvector, so we count this row as "indexed"
399         # for purposes of knowing where to pick up
400         eval { $dbh->do( $query, undef, "", $attachment->id ) }
401             or die "Failed to insert empty tsvector: " . $dbh->errstr;
402     }
403 }
404
405 sub attachments_text {
406     my $res = shift;
407     $res->Limit( FIELD => 'ContentType', VALUE => 'text/plain' );
408     return $res;
409 }
410
411 sub extract_text {
412     my $attachment = shift;
413     my $text = $attachment->Content;
414     return undef unless defined $text && length($text);
415     return \$text;
416 }
417
418 sub attachments_html {
419     my $res = shift;
420     $res->Limit( FIELD => 'ContentType', VALUE => 'text/html' );
421     return $res;
422 }
423
424 sub filter_html {
425     my $attachment = shift;
426     if ( my $parent = $attachment->ParentObj ) {
427 # skip html parts that are alternatives
428         return 1 if $parent->id
429             && $parent->ContentType eq 'mulitpart/alternative';
430     }
431     return 0;
432 }
433
434 sub extract_html {
435     my $attachment = shift;
436     my $text = $attachment->Content;
437     return undef unless defined $text && length($text);
438 # TODO: html -> text
439     return \$text;
440 }
441
442 sub goto_specific {
443     my %args = (@_);
444
445     my $func = (caller(1))[3];
446     $func =~ s/.*:://;
447     my $call = $func ."_". lc $args{'suffix'};
448     unless ( defined &$call ) {
449         return undef unless $args{'error'};
450         require Carp; Carp::croak( $args{'error'} );
451     }
452     @_ = @{ $args{'arguments'} };
453     goto &$call;
454 }
455
456
457 # helper functions
458 sub debug    { print @_, "\n" if $OPT{debug}; 1 }
459 sub error    { $RT::Logger->error(_(@_)); 1 }
460 sub warning  { $RT::Logger->warn(_(@_)); 1 }
461
462 =head1 NAME
463
464 rt-fulltext-indexer - Indexer for full text search
465
466 =head1 DESCRIPTION
467
468 This is a helper script to keep full text indexes in sync with data.
469 Read F<docs/full_text_indexing.pod> for complete details on how and when
470 to run it.
471
472 =head1 AUTHOR
473
474 Ruslan Zakirov E<lt>ruz@bestpractical.comE<gt>,
475 Alex Vandiver E<lt>alexmv@bestpractical.comE<gt>
476
477 =cut
478
479 __DATA__