root/branches/release-30/lib/MT/App/Search.pm @ 1420

Revision 1420, 12.1 kB (checked in by fumiakiy, 21 months ago)

Initial attempt to pluggable, extensible and hopefully faster search framework. BugId:69030, BugId:69029, BugId:69031

mt-search.cgi still defaults to legacy search application for now.

  • 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 File::Spec;
13use MT::Util qw(encode_html ts2epoch epoch2ts);
14use HTTP::Date qw(str2time time2str);
15use MT::Entry qw( :constants );
16
17sub id { 'new_search' }
18
19sub init {
20    my $app = shift;
21    $app->SUPER::init(@_) or return;
22    $app->set_no_cache;
23    $app->{default_mode} = 'search';
24    $app;
25}
26
27sub core_methods {
28    my $app = shift;
29    return {
30        'search' => \&process,
31        'author' => \&process,
32    };
33}
34
35sub core_query_params {
36    my $app = shift;
37    return [
38        'searchTerms',
39        'search',
40        'count',
41        'limit',
42        'startIndex',
43        'offset',
44    ];
45}
46
47sub init_request{
48    my $app = shift;
49    $app->SUPER::init_request(@_);
50    my $q = $app->param;
51
52    my $params = $app->registry('params');
53    foreach ( @$params ) {
54        delete $app->{$_} if exists $app->{$_}
55    }
56
57    my %no_override;# = map { $_ => 1 } split /\s*,\s*/, $app->config->NoOverride;
58    my $blog_list = $app->create_blog_list( %no_override );
59    $app->{searchparam}{IncludeBlogs} = $blog_list->{IncludeBlogs}
60        if $blog_list && %$blog_list
61        && $blog_list->{IncludeBlogs}
62        && %{ $blog_list->{IncludeBlogs} };
63
64    ## Set other search params--prefer per-query setting, default to
65    ## config file.
66    for my $key (qw( ResultDisplay MaxResults SearchSortBy )) {
67        $app->{searchparam}{$key} = $no_override{$key} ?
68            $app->config->$key() : ($q->param($key) || $app->config->$key());
69    }
70}
71
72sub create_blog_list {
73    my $app = shift;
74    my ( %no_override ) = @_;
75
76    my $q = $app->param;
77    my $cfg = $app->config;
78
79    #%no_override = map { $_ => 1 } split /\s*,\s*/, $cfg->NoOverride
80    #    unless %no_override;
81
82    my %blog_list;
83    ## Combine user-selected included/excluded blogs
84    ## with config file settings.
85    for my $type (qw( IncludeBlogs ExcludeBlogs )) {
86        $blog_list{$type} = {};
87        if (my $list = $cfg->$type()) {
88            $blog_list{$type} =
89                { map { $_ => 1 } split /\s*,\s*/, $list };
90        }
91        next if exists($no_override{$type}) && $no_override{$type};
92        for my $blog_id ($q->param($type)) {
93            if ($blog_id =~ m/,/) {
94                my @ids = split /,/, $blog_id;
95                s/\D+//g for @ids; # only numeric values.
96                foreach my $id (@ids) {
97                    next unless $id;
98                    $blog_list{$type}{$id} = 1;
99                }
100            } else {
101                $blog_id =~ s/\D+//g; # only numeric values.
102                $blog_list{$type}{$blog_id} = 1;
103            }
104        }
105    }
106
107    ## If IncludeBlogs has not been set, we need to build a list of
108    ## the blogs to search. If ExcludeBlogs was set, exclude any blogs
109    ## set in that list from our final list.
110    unless ( exists $blog_list{IncludeBlogs} ) {
111        my $exclude = $blog_list{ExcludeBlogs};
112        my $iter = $app->model('blog')->load_iter;
113        while (my $blog = $iter->()) {
114            $blog_list{IncludeBlogs}{$blog->id} = 1
115                unless $exclude && $exclude->{$blog->id};
116        }
117    }
118
119    \%blog_list;
120}
121
122sub process {
123    my $app = shift;
124
125    my ( $terms, $args ) = $app->search_terms();
126    return $app->error($app->errstr) if $app->errstr;
127
128    my $count = 0;
129    my $iter;
130    if ( $terms && @$terms ) {
131        ( $count, $iter ) = $app->execute( $terms, $args );
132        return $app->error($app->errstr) unless $iter;
133
134        $iter = $app->post_search( $count, $iter );
135    }
136
137    return $app->render( $count, $iter );
138}
139
140sub count {
141    my $app = shift;
142    my ( $class, $terms, $args ) = @_;
143    #TODO: cache!
144    $class->count( $terms, $args );
145}
146
147sub execute {
148    my $app = shift;
149    my ( $terms, $args ) = @_;
150
151    my $class = $app->model( $app->mode eq 'search' ? 'entry' : $app->mode );
152    my $count = $app->count( $class, $terms, $args );
153    #TODO: cache!
154    my $iter = $class->load_iter( $terms, $args )
155        or $app->error($class->errstr);
156    ( $count, $iter );
157}
158
159sub search_terms {
160    my $app = shift;
161    my $q = $app->param;
162
163    my $search_string = $q->param('searchTerms') || $q->param('search')
164        or return ( undef, undef );
165    $app->{search_string} = $search_string;
166    my $offset = $q->param('startIndex') || $q->param('offset') || 0;
167    my $limit = $q->param('count') || $q->param('limit');
168    my $max = $app->{searchparam}{MaxResults};
169    $limit = $max if !$limit || ( $limit - $offset > $max );
170
171    my $type = $q->param('type');
172    my $entry_type = $app->mode eq 'search'
173      ? 1
174      : $type
175        ? ( 'entry' eq lc($type) || 'page' eq lc($type)
176          ? 1
177          : 0 )
178        : 0;
179
180    my $def_terms = {
181        $entry_type ? ( status => MT::Entry::RELEASE() ) : (),
182        $type ? ( class  => $type ) : (),
183        exists( $app->{searchparam}{IncludeBlogs} )
184          ? ( blog_id => [ keys %{ $app->{searchparam}{IncludeBlogs} } ] )
185          : (),
186    };
187    my @terms;
188    push @terms, $def_terms if %$def_terms;
189
190    my $columns = $app->mode eq 'search'
191      ? [ [ qw( title keywords text text_more ) ] ]
192      : $app->registry( $app->mode, 'columns' );
193    $columns = $columns->[0]; # FIXME: Why?
194    return $app->errtrans('No columns to search for was specified for [_1]', $app->mode)
195        unless $columns && @$columns;
196    my $number = scalar @$columns;
197    my @and;
198    for ( my $i = 0; $i < $number; $i++) {
199        push @and, { $columns->[$i] => { like => '%'.$search_string.'%' } };
200        unless ( $i == $number - 1 ) {
201            push @and, '-or';
202        }
203    }
204    push @terms, '-and' if @terms;
205    push @terms, \@and;
206
207    my %args = (
208      $limit ? ( 'limit' => $limit ) : (),
209      $offset ? ( 'offset' => $offset ) : (),
210      'sort' => [
211        { desc   => 'descend' eq $app->{searchparam}{ResultDisplay} ? 'DESC' : 'ASC',
212          column => $entry_type ? 'authored_on' : 'created_on' }
213      ]
214    );
215
216    if ( exists $app->{searchparam}{IncludeBlogs} ) {
217        unshift @{ $args{'sort'} },
218          { desc => 'ASC',
219            column    => 'blog_id' };
220    }
221
222    ( \@terms, \%args );
223}
224
225sub post_search {
226    my $app = shift;
227    my ( $count, $iter ) = @_;
228    # TODO: cache here?
229    $iter;
230}
231
232sub template_paths {
233    my $app = shift;
234    my @paths = $app->SUPER::template_paths;
235    ( $app->config->SearchTemplatePath, @paths );
236}
237
238sub load_search_tmpl {
239    my $app = shift;
240    my $q = $app->param;
241    my ( $count, $iter ) = @_;
242
243    my $blog_id;
244    if ( $q->param('IncludeBlogs') ) {
245        my @ids = split ',', $q->param('IncludeBlogs');
246        $blog_id = $ids[0];
247    }
248    elsif ( exists $app->{searchparam}{IncludeBlogs} ) {
249        $blog_id = @{ keys %{ $app->{searchparam}{IncludeBlogs} } }[0];
250    }
251
252    my $tmpl;
253    if ( $q->param('Template') && ( 'default' ne $q->param('Template') ) ) {
254        # load specified template
255        my $filename;
256        if (my @tmpls = ($app->config->default('AltTemplate'), $app->config->AltTemplate)) {
257            for my $tmpl (@tmpls) {
258                next unless defined $tmpl;
259                my ( $nickname, $file ) = split /\s+/, $tmpl;
260                if ( $nickname eq $q->param('Template') ) {
261                    $filename = $file;
262                    last;
263                }
264            }
265        }
266        return $app->errtrans("No alternate template is specified for the Template '[_1]'", $q->param('Template'))
267            unless $filename;
268        # template_paths method does the magic
269        $tmpl = $app->load_tmpl( $filename )
270            or return $app->errtrans( "Opening local file '[_1]' failed: [_2]", $filename, "$!" );
271    }
272    else {
273        # load default template
274        # first look for appropriate blog_id
275        if ( $blog_id ) {
276            # look for 'search_results'
277            my $tmpl_class = $app->model('template');
278            $tmpl = $tmpl_class->load(
279                { blog_id => $blog_id, type => 'search_results' }
280            );
281        }
282        unless ( $tmpl ) {
283            # load template from search_template path
284            # template_paths method does the magic
285            $tmpl = $app->load_tmpl( $app->config->DefaultTemplate );
286        }
287    }
288    return $app->error($app->errstr)
289        unless $tmpl;
290
291    ## Initialize and set up the context object.
292    require MT::Template::Context::Search;
293    my $ctx = MT::Template::Context::Search->new;
294    if ( $blog_id ) {
295        $ctx->stash('blog_id', $blog_id);
296        $ctx->stash('blog',    $app->model('blog')->load($blog_id));
297    }
298    $ctx->stash('results', $iter);
299    $ctx->stash('count',   $count);
300    $ctx->stash('stash_key', $app->mode)
301        if 'search' ne $app->mode;
302    $ctx->stash('include_blogs',
303        join ',', keys %{ $app->{searchparam}{IncludeBlogs} });
304    if ( my $str = $app->{search_string} ) {
305        $ctx->stash('search_string', encode_html($str));
306    }
307    $ctx->stash('template_id', $q->param('Template'));
308    $ctx->stash('maxresults' , $app->{searchparam}{MaxResults});
309
310    $tmpl->context($ctx);
311    $tmpl;
312}
313
314sub pre_render {
315    my $app = shift;
316    my ( $tmpl ) = @_;
317    $tmpl
318}
319
320sub render {
321    my $app = shift;
322    my ( $count, $iter ) = @_;
323
324    my $tmpl = $app->load_search_tmpl( $count, $iter );
325    $tmpl = $app->pre_render( $tmpl );
326
327    $tmpl;
328}
329
330sub query_parse {
331    my $app = shift;
332    return unless $app->{search_string};
333    use utf8;
334    #local $_ = MT::I18N::decode_utf8($app->{search_string});
335    local $_ = $app->{search_string_decoded};
336
337    s/^\s//;            # Remove leading whitespace
338    s/\s$//;            # Remove trailing whitespace
339    s/\s+AND\s+/ /g;    # Remove AND because it's implied
340    s/\s{2,}/ /g;       # Remove contiguous spaces
341
342    my @search_keys;
343    my @tokens = split;
344    while (my $atom = shift @tokens) {
345        my($type);
346        if ($atom eq 'NOT' || $atom eq 'AND') {
347            $type = $atom;
348            $atom = shift @tokens;
349            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
350        } elsif ($atom eq 'OR') {
351            $atom = shift @tokens;
352            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
353            ## OR new atom with last atom
354            $search_keys[-1]{atom} =
355                '(?:' . $search_keys[-1]{atom} .'|' . quotemeta($atom) . ')';
356            next;
357        } elsif ($atom =~ /^-(.*)/) {
358            $type = 'NOT';
359            $atom = $1;
360            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
361        } else {
362            $type = 'AND';
363            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
364        }
365        push @search_keys, { atom => quotemeta($atom),
366                             type => $type };
367    }
368
369    $app->{searchparam}{search_keys} = \@search_keys;
370    $app->query_optimize;
371}
372
373sub find_phrase {
374    my($atom, $tokenref) = @_;
375    while (my $next = shift @$tokenref) {
376        $atom = $atom . ' ' . $next;
377        last if $atom =~ /\"$/;
378    }
379    $atom =~ s/^"(.*)"$/$1/;
380    $atom;
381}
382
383sub query_optimize {
384    my $app = shift;
385
386    ## Sort keys longest to shortest for search efficiency.
387    $app->{searchparam}{search_keys} = [
388        reverse sort { length($a->{atom}) <=> length($b->{atom}) }
389        @{ $app->{searchparam}{search_keys} }
390    ];
391   
392    ## Sort keys by contents. Any ORs immediately get a lower priority.
393    my %terms;
394    for my $key (@{ $app->{searchparam}{search_keys} }) {
395        if ($key->{atom} =~ /\(.*\|.*\)/) {
396            push(@{ $terms{$key->{type}}{low} }, $key);
397        } else {
398            push(@{ $terms{$key->{type}}{high} }, $key);
399        }
400    }
401
402    ## Final priority: AND long, AND short, AND with OR (long/short),
403    ## NOT long/short
404    ##  This should give us the most efficient search in that it is
405    ##  searching for the harder-to-match keys first.
406    my %regex;
407    for my $type (qw( AND NOT )) {
408        for my $pri (qw( high low )) {
409            for my $obj (@{ $terms{$type}{$pri} }) {
410                push(@{ $regex{$type} }, $obj->{atom});
411            }
412        }
413    }
414
415    $app->{searchparam}{search_keys} = \%regex;
416}
417
4181;
419__END__
420
421=head1 NAME
422
423MT::App::Search
424
425=head1 AUTHOR & COPYRIGHT
426
427Please see L<MT/AUTHOR & COPYRIGHT>.
428
429=cut
Note: See TracBrowser for help on using the browser.