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

Revision 1431, 11.9 kB (checked in by fumiakiy, 21 months ago)

Added more extensibility to the new MT::App::Search. BugId:68481

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