root/branches/release-32/lib/MT/App/Search.pm @ 1647

Revision 1647, 19.9 kB (checked in by fumiakiy, 20 months ago)

Implemented callbacks and the default processing of include/exclude blogs. BugId:69747

  • Property svn:keywords set to Author Date Id Revision
Line 
1# Movable Type (r) Open Source (C) 2001-2008 Six Apart, Ltd.
2# This program is distributed under the terms of the
3# GNU General Public License, version 2.
4#
5# $Id$
6
7package MT::App::Search;
8
9use strict;
10use base qw( MT::App );
11
12use MT::Util qw( encode_html encode_url );
13
14sub id { 'new_search' }
15
16sub init {
17    my $app = shift;
18    $app->SUPER::init(@_) or return;
19    $app->set_no_cache;
20    $app->{default_mode} = 'default';
21
22    $app->mode('tag') if $app->param('tag');
23    ## process pathinfo
24    #if ( my $pi = $app->path_info ) {
25    #    $pi =~ s!^/!!;
26    #    my ($mode, $tag, @args) = split /\//, $pi;
27    #    $app->mode($mode);
28    #    $app->param($mode, $tag);
29    #    for my $arg (@args) {
30    #        my ($k, $v) = split /=/, $arg, 2;
31    #        $app->param($k, $v);
32    #    }
33    #}
34    $app->_register_core_callbacks({
35        'search_post_execute' => \&log_search,
36        'search_post_render'  => \&cache_out,
37    });
38    $app;
39}
40
41sub core_methods {
42    my $app = shift;
43    return {
44        'default' => \&process,
45        'tag'     => '$Core::MT::App::Search::TagSearch::process',
46    };
47}
48
49sub core_parameters {
50    my $app = shift;
51    return {
52        params => [ qw( searchTerms search count limit startIndex offset ) ],
53        types  => {
54            #author => {
55            #    columns => [ qw( name nickname email url ) ],
56            #    'sort' => 'created_on',
57            #    terms   => { status => 1 }, #MT::Author::ACTIVE()
58            #},
59            entry => {
60                columns => [ qw( title keywords text text_more ) ],
61                'sort'  => 'authored_on',
62                terms   => { status => 2 }, #MT::Entry::RELEASE()
63            },
64        },
65        cache_driver => {
66            'package' => 'MT::Cache::Negotiate',
67        },
68    };
69}
70
71sub init_request{
72    my $app = shift;
73    $app->SUPER::init_request(@_);
74    my $q = $app->param;
75
76    my $params = $app->registry( $app->mode, 'params' );
77    foreach ( @$params ) {
78        delete $app->{$_} if exists $app->{$_}
79    }
80
81    my %no_override;
82    foreach my $no ( split /\s*,\s*/, $app->config->SearchNoOverride ) {
83        $no_override{ $no } = 1;
84        $no_override{ "Search$no" } = 1
85            if $no !~ /^Search.+/;
86    }
87
88    ## Set other search params--prefer per-query setting, default to
89    ## config file.
90    for my $key (qw( SearchResultDisplay SearchMaxResults SearchSortBy )) {
91        $app->{searchparam}{$key} = $no_override{$key} ?
92            $app->config->$key() : ($q->param($key) || $app->config->$key());
93    }
94
95    $app->{searchparam}{Type} = 'entry';
96    if ( my $type = $q->param('type') ) {
97        return $app->errtrans('Invalid type: [_1]', encode_html($type) )
98            if $type !~ /[\w\.]+/;
99        $app->{searchparam}{Type} = $type;
100    }
101
102    $app->generate_cache_keys();
103    $app->init_cache_driver();
104
105    my $processed = 0;
106    my $list      = {};
107    if ( MT->run_callbacks( 'search_blog_list', $app, $list, \$processed ) ) {
108        if ( $processed ) {
109            $app->{searchparam}{IncludeBlogs} = $list;
110        }
111        else {
112            my $blog_list = $app->create_blog_list( %no_override );
113            $app->{searchparam}{IncludeBlogs} = $blog_list->{IncludeBlogs}
114                if $blog_list && %$blog_list
115                && $blog_list->{IncludeBlogs}
116                && %{ $blog_list->{IncludeBlogs} };
117            if ( !exists($app->{searchparam}{IncludeBlogs})
118              && ( my $blog_id = $q->param('blog_id') ) ) {
119                $blog_id =~ s/\D//g;
120                $app->{searchparam}{IncludeBlogs}{$blog_id} = 1
121                    if $blog_id;
122            }
123        }
124    }
125    else {
126        return $app->error( $app->translate('Invalid request.') );
127    }
128}
129
130sub generate_cache_keys {
131    my $app = shift;
132
133    my $q = $app->param;
134    my @p = sort { $a cmp $b } $q->param;
135    my ( $key, $count_key );
136    $key .= lc($_) . encode_url($q->param($_))
137        foreach @p;
138    $count_key .= lc($_) . encode_url($q->param($_))
139        foreach grep { ('limit' ne lc($_)) && ('offset' ne lc($_)) } @p;
140    $app->{cache_keys} = { result => $key, count => $count_key };
141}
142
143sub init_cache_driver {
144    my $app = shift;
145
146    my $registry = $app->registry( $app->mode, 'cache_driver' );
147    my $cache_driver = $registry->{'package'} || 'MT::Cache::Negotiate';
148    eval "require $cache_driver;";
149    if ( my $e = $@ ) {
150        require MT::Log;
151        $app->log({
152            message =>
153                $app->translate("Search: failed storing results in cache.  [_1] is not available: [_2]",
154                    $cache_driver, $e ),
155            level => MT::Log::INFO(),
156            class => 'search',
157        });
158        return;
159    }
160    $app->{cache_driver} = $cache_driver->new( ttl => $app->config->ThrottleSeconds );
161}
162
163sub create_blog_list {
164    my $app = shift;
165    my ( %no_override ) = @_;
166
167    my $q = $app->param;
168    my $cfg = $app->config;
169
170    unless ( %no_override ) {
171        my %no_override;
172        foreach my $no ( split /\s*,\s*/, $app->config->SearchNoOverride ) {
173            $no_override{ $no } = 1;
174            $no_override{ "Search$no" } = 1
175                if $no !~ /^Search.+/;
176        }
177    }
178
179    my %blog_list;
180    ## Combine user-selected included/excluded blogs
181    ## with config file settings.
182    for my $type (qw( IncludeBlogs ExcludeBlogs )) {
183        $blog_list{$type} = {};
184        if (my $list = $cfg->$type()) {
185            $blog_list{$type} =
186                { map { $_ => 1 } split /\s*,\s*/, $list };
187        }
188        next if exists($no_override{$type}) && $no_override{$type};
189        for my $blog_id ($q->param($type)) {
190            if ($blog_id =~ m/,/) {
191                my @ids = split /,/, $blog_id;
192                s/\D+//g for @ids; # only numeric values.
193                foreach my $id (@ids) {
194                    next unless $id;
195                    $blog_list{$type}{$id} = 1;
196                }
197            } else {
198                $blog_id =~ s/\D+//g; # only numeric values.
199                $blog_list{$type}{$blog_id} = 1;
200            }
201        }
202    }
203
204    ## If IncludeBlogs has not been set, we need to build a list of
205    ## the blogs to search. If ExcludeBlogs was set, exclude any blogs
206    ## set in that list from our final list.
207    unless ( exists $blog_list{IncludeBlogs} ) {
208        my $exclude = $blog_list{ExcludeBlogs};
209        my $iter = $app->model('blog')->load_iter;
210        while (my $blog = $iter->()) {
211            $blog_list{IncludeBlogs}{$blog->id} = 1
212                unless $exclude && $exclude->{$blog->id};
213        }
214    }
215
216    \%blog_list;
217}
218
219sub check_cache {
220    my $app = shift;
221
222    my $cache = $app->{cache_driver}->get_multi(
223        values %{ $app->{cache_keys} } );
224
225    my $count = $cache->{ $app->{cache_keys}{count} }
226        if exists $cache->{ $app->{cache_keys}{count} };
227    my $result = $cache->{ $app->{cache_keys}{result} }
228        if exists $cache->{ $app->{cache_keys}{result} };
229
230    ( $count, $result );
231}
232
233sub process {
234    my $app = shift;
235
236    my ( $count, $out ) = $app->check_cache();
237    if ( defined $out ) {
238        $app->run_callbacks( 'search_cache_hit', $count, $out );
239        return $out;
240    }
241
242    my @arguments = $app->search_terms();
243    return $app->error($app->errstr) if $app->errstr;
244
245    $count = 0;
246    my $iter;
247    if ( @arguments ) {
248        ( $count, $iter ) = $app->execute( @arguments );
249        return $app->error($app->errstr) unless $iter;
250
251        $app->run_callbacks( 'search_post_execute', $app, \$count, \$iter );
252    }
253
254    my $format = q();
255    if ( $format = $app->param('format') ) {
256        return $app->errtrans('Invalid format: [_1]', encode_html($format))
257            if $format !~ /\w+/;
258    }
259    my $method = "render$format";
260    $method = 'render' unless $app->can($method);
261    $out = $app->$method( $count, $iter );
262
263    my $result;
264    if (ref($out) && ($out->isa('MT::Template'))) {
265        defined( $result = $app->build_page($out) )
266            or return $app->error($out->errstr);
267    }
268    else {
269        $result = $out;
270    }
271
272    $app->run_callbacks( 'search_post_render', $app, $count, $result );
273    $result;
274}
275
276sub count {
277    my $app = shift;
278    my ( $class, $terms, $args ) = @_;
279    my $count = $app->{cache_driver}->get($app->{cache_keys}{count});
280    return $count if defined $count;
281
282    $count = $class->count( $terms, $args );
283
284    my $cache_driver = $app->{cache_driver};
285    $cache_driver->set( $app->{cache_keys}{count}, $count, $app->config->ThrottleSeconds );
286
287    $count;
288}
289
290sub execute {
291    my $app = shift;
292    my ( $terms, $args ) = @_;
293
294    my $class = $app->model( $app->{searchparam}{Type} )
295        or return $app->errtrans('Unsupported type: [_1]', encode_html($app->{searchparam}{Type}));
296
297    my $count = $app->count( $class, $terms, $args );
298    return $app->error($class->errstr) unless defined $count;
299
300    my $iter = $class->load_iter( $terms, $args )
301        or $app->error($class->errstr);
302    ( $count, $iter );
303}
304
305sub search_terms {
306    my $app = shift;
307    my $q = $app->param;
308
309    my $search_string = $q->param('searchTerms') || $q->param('search')
310        or return ( undef, undef );
311    $app->{search_string} = $search_string;
312    my $offset = $q->param('startIndex') || $q->param('offset') || 0;
313    return $app->errtrans('Invalid value: [_1]', encode_html($offset))
314        if $offset && $offset !~ /^\d+$/;
315    my $limit = $q->param('count') || $q->param('limit');
316    return $app->errtrans('Invalid value: [_1]', encode_html($limit))
317        if $limit && $limit !~ /^\d+$/;
318    my $max = $app->{searchparam}{SearchMaxResults};
319    $max =~ s/\D//g if defined $max;
320    $limit = $max if !$limit || ( $limit - $offset > $max );
321
322    my $params = $app->registry( $app->mode, 'types', $app->{searchparam}{Type} );
323    my %def_terms = exists( $params->{terms} )
324          ? %{ $params->{terms} }
325          : ();
326    delete $def_terms{'plugin'}; #FIXME: why is this in here?
327
328    if ( exists $app->{searchparam}{IncludeBlogs} ) {
329        $def_terms{blog_id} = [ keys %{ $app->{searchparam}{IncludeBlogs} } ];
330    }
331
332    my @terms;
333    push @terms, \%def_terms if %def_terms;
334
335    my $columns = $params->{columns};
336    return $app->errtrans('No column was specified to search for [_1].', $app->{searchparam}{Type})
337        unless $columns && @$columns;
338
339    my $parsed = $app->query_parse( $columns );
340    return $app->errtrans('Parse error: [1]', $app->errstr)
341        unless $parsed && %$parsed;
342
343    push @terms, $parsed->{terms} if exists $parsed->{terms};
344
345    my $sort = $params->{'sort'};
346    if ( $sort !~ /\w+\!$/ && $app->{searchparam}{SearchSortBy} ) {
347        my $sort_by = $app->{searchparam}{SearchSortBy};
348        $sort_by =~ s/[\w\-\.]+//g;
349        $sort = $sort_by;
350    }
351
352    my %args = (
353      exists( $parsed->{args} ) ? %{ $parsed->{args} } : (),
354      $limit  ? ( 'limit' => $limit ) : (),
355      $offset ? ( 'offset' => $offset ) : (),
356      $sort   ? ( 'sort' => [
357            { desc   => 'descend' eq $app->{searchparam}{SearchResultDisplay} ? 'DESC' : 'ASC',
358              column => $sort }
359        ] ) : (),
360    );
361
362    if ( exists $app->{searchparam}{IncludeBlogs} ) {
363        unshift @{ $args{'sort'} },
364          { desc => 'ASC',
365            column    => 'blog_id' };
366    }
367
368    ( \@terms, \%args );
369}
370
371sub cache_out {
372    my ( $cb, $app, $count, $out ) = @_;
373
374    my $result;
375    if (ref($out) && ($out->isa('MT::Template'))) {
376        defined($result = $app->build_page($out))
377            or die $out->errstr;
378    }
379    else {
380        $result = $out;
381    }
382
383    my $cache_driver = $app->{cache_driver};
384    $cache_driver->set( $app->{cache_keys}{result}, $out, $app->config->ThrottleSeconds );
385}
386
387sub log_search {
388    my ( $cb, $app, $count_ref, $iter_ref ) = @_;
389
390    #FIXME: template name may not be 'feed' for search feed
391    unless ( $app->param('template') && ( 'feed' eq $app->param('template') ) ) {
392        my $blog_id = $app->first_blog_id();
393        require MT::Log;
394        $app->log({
395            message => $app->translate("Search: query for '[_1]'",
396                  $app->{search_string}),
397            level => MT::Log::INFO(),
398            class => 'search',
399            category => 'straight_search',
400            $blog_id ? (blog_id => $blog_id) : ()
401        });
402    }
403}
404
405sub template_paths {
406    my $app = shift;
407    my @paths = $app->SUPER::template_paths;
408    ( $app->config->SearchTemplatePath, @paths );
409}
410
411sub first_blog_id {
412    my $app = shift;
413    my $q = $app->param;
414
415    my $blog_id;
416    if ( $q->param('IncludeBlogs') ) {
417        my @ids = split ',', $q->param('IncludeBlogs');
418        $blog_id = $ids[0];
419    }
420    elsif ( exists($app->{searchparam}{IncludeBlogs})
421      && keys(%{ $app->{searchparam}{IncludeBlogs} }) ) {
422        my @blog_ids = keys %{ $app->{searchparam}{IncludeBlogs} };
423        $blog_id = $blog_ids[0] if @blog_ids;
424    }
425    $blog_id;
426}
427
428sub prepare_context {
429    my $app = shift;
430    my $q = $app->param;
431    my ( $count, $iter ) = @_;
432
433    ## Initialize and set up the context object.
434    require MT::Template::Context::Search;
435    my $ctx = MT::Template::Context::Search->new;
436    if ( my $str = $app->{search_string} ) {
437        $ctx->stash('search_string', encode_html($str));
438    }
439    if ( $q->param('type') ) {
440        $ctx->stash('type', $app->{searchparam}{Type});
441    }
442    if ( $app->{default_mode} ne $app->mode ) {
443        $ctx->stash('mode', $app->mode);
444    }
445    if ( my $template = $q->param('Template') ) {
446        $template =~ s/[\w\-\.]//g;
447        $ctx->stash('template_id', $template);
448    }
449    $ctx->stash('stash_key'  , $app->{searchparam}{Type} );
450    $ctx->stash('maxresults' , $app->{searchparam}{SearchMaxResults});
451    $ctx->stash('include_blogs',
452        join ',', keys %{ $app->{searchparam}{IncludeBlogs} });
453    $ctx->stash('results'    , $iter);
454    $ctx->stash('count'      , $count);
455    $ctx->stash('offset'     , $q->param('startIndex') || $q->param('offset') || 0);
456    $ctx->stash('limit'      , $q->param('count') || $q->param('limit'));
457    $ctx->stash('format'     , $q->param('format')) if $q->param('format');
458
459    my $blog_id = $app->first_blog_id();
460    if ( $blog_id ) {
461        $ctx->stash('blog_id', $blog_id);
462        $ctx->stash('blog',    $app->model('blog')->load($blog_id));
463    }
464    $ctx;
465}
466
467sub load_search_tmpl {
468    my $app = shift;
469    my $q = $app->param;
470    my ( $ctx ) = @_;
471
472    my $tmpl;
473    if ( $q->param('Template') && ( 'default' ne $q->param('Template') ) ) {
474        # load specified template
475        my $filename;
476        if (my @tmpls = ($app->config->default('SearchAltTemplate'), $app->config->SearchAltTemplate)) {
477            for my $tmpl (@tmpls) {
478                next unless defined $tmpl;
479                my ( $nickname, $file ) = split /\s+/, $tmpl;
480                if ( $nickname eq $q->param('Template') ) {
481                    $filename = $file;
482                    last;
483                }
484            }
485        }
486        return $app->errtrans( "No alternate template is specified for the Template '[_1]'",
487          encode_html( $q->param('Template') ) )
488            unless $filename;
489        # template_paths method does the magic
490        $tmpl = $app->load_tmpl( $filename )
491            or return $app->errtrans( "Opening local file '[_1]' failed: [_2]", $filename, "$!" );
492    }
493    else {
494        # load default template
495        # first look for appropriate blog_id
496        if ( my $blog_id = $ctx->stash('blog_id') ) {
497            # look for 'search_results'
498            my $tmpl_class = $app->model('template');
499            $tmpl = $tmpl_class->load(
500                { blog_id => $blog_id, type => 'search_results' }
501            );
502        }
503        unless ( $tmpl ) {
504            # load template from search_template path
505            # template_paths method does the magic
506            $tmpl = $app->load_tmpl( $app->config->SearchDefaultTemplate );
507        }
508    }
509    return $app->error($app->errstr)
510        unless $tmpl;
511
512    $tmpl->context($ctx);
513    $tmpl;
514}
515
516sub render {
517    my $app = shift;
518    my ( $count, $iter ) = @_;
519
520    my @arguments = $app->prepare_context( $count, $iter )
521        or return $app->error($app->errstr);
522    my $tmpl = $app->load_search_tmpl( @arguments )
523        or return $app->error($app->errstr);
524    $tmpl;
525}
526
527sub renderjs {
528    my $app = shift;
529    my ( $count, $iter ) = @_;
530
531    my ( $ctx ) = $app->prepare_context( $count, $iter )
532        or return $app->json_error($app->errstr);
533    my $search_tmpl = $app->load_search_tmpl( $ctx )
534        or return $app->json_error($app->errstr);
535    my $result_node = $search_tmpl->getElementById('search_results')
536        or return $app->json_error('Search template does not have markup for search results.');
537    my $t = $result_node->innerHTML();
538
539    require MT::Template;
540    my $tmpl = MT::Template->new( type => 'scalarref', source => \$t );
541    $ctx->stash('format', q()); # don't propagate "js" format
542    $tmpl->context( $ctx );
543    my $content = $tmpl->output
544        or return $app->json_error($tmpl->errstr);
545
546    my $next_link = $ctx->_hdlr_next_link();
547    return $app->json_result({ content => $content, next_url => $next_link });
548}
549
550sub query_parse {
551    my $app = shift;
552    my ( $columns ) = @_;
553
554    require Lucene::QueryParser;
555    my $lucene_struct = Lucene::QueryParser::parse_query( $app->{search_string} );
556    my %columns = map { $_ => 1 } @$columns;
557    my $structure = $app->_query_parse_core( $lucene_struct, \%columns );
558    { terms => $structure };
559}
560
561sub _query_parse_core {
562    my $app = shift;
563    my ( $lucene_struct, $columns ) = @_;
564
565    my @structure;
566    for my $term ( @$lucene_struct ) {
567        next if exists( $term->{field} )
568            && !exists( $columns->{ $term->{field} } );
569
570        my $test;
571        if ( ( 'TERM' eq $term->{query} ) || ( 'PHRASE' eq $term->{query} ) ){
572            if ( 'PROHIBITED' eq $term->{type} ) {
573                $test = { not_like => '%'.$term->{term}.'%' };
574            }
575            else {
576                $test = { like => '%'.$term->{term}.'%' };
577            }
578        }
579        elsif ( 'SUBQUERY' eq $term->{query} ) {
580            $test = $app->_query_parse_core( $term->{subquery}, $columns );
581            next unless $test && @$test;
582            if ( @structure ) {
583                push @structure, 'PROHIBITED' eq $term->{type}
584                  ? '-and_not'
585                  : '-and';
586            }
587            push @structure, $test->[0];
588            next;
589        }
590
591        my @tmp;
592        if ( exists( $term->{field} ) ) {
593            push @tmp, { $term->{field} => $test };
594        }
595        else {
596            my @columns = keys %$columns;
597            my $number = scalar @columns;
598            for ( my $i = 0; $i < $number; $i++) {
599                push @tmp, { $columns[$i] => $test };
600                unless ( $i == $number - 1 ) {
601                    push @tmp, '-or';
602                }
603            }
604        }
605        if ( exists($term->{conj}) && ( 'OR' eq $term->{conj} ) ) {
606            if ( my $prev = pop @structure ) {
607                push @structure, [ $prev, -or => \@tmp ];
608            }
609        }
610        else {
611            if ( @structure ) {
612                push @structure, '-and';
613            }
614            push @structure, \@tmp;
615        }
616    }
617    \@structure;
618}
619
6201;
621__END__
622
623=head1 NAME
624
625MT::App::Search
626
627=head1 Callbacks
628
629Callbacks called by the package are as follows:
630
631=over 4
632
633=item search_post_execute
634
635    callback($cb, $app, \$count, \$iter)
636
637Called immediately after the search from the database (or however
638search executed depending on the algorithm).
639
640=item search_post_render
641
642    callback($cb, $app, $count, $out_html)
643
644Called immediately after the search template was loaded and its
645context populated.
646
647=item search_cache_hit
648
649    callback($cb, $app, $count, $out_html)
650
651Called immediately after cached results was retrieved.
652
653=item search_blog_list
654
655    callback($cb, $app, \%list, \$processed)
656
657Called during init_request in which a plugin can fill %list.
658The list must has the following data structure.
659
660    %list = ( 1 => 1, 2 => 1, 3 => 1 );
661
662where the hash keys (1, 2, and 3) are the IDs of the blogs to search for.
663
664Plugins must also set $processed = 1 in order to specify the app that
665the app must not overwrite the blog list created by the plugin.
666
667=head1 AUTHOR & COPYRIGHT
668
669Please see L<MT/AUTHOR & COPYRIGHT>.
670
671=cut
Note: See TracBrowser for help on using the browser.