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

Revision 1420, 31.5 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 Id Date Author 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
7# Original Copyright 2001-2002 Jay Allen.
8# Modifications and integration Copyright 2001-2008 Six Apart.
9
10package MT::App::Search::Legacy;
11
12use strict;
13use base qw( MT::App );
14
15use File::Spec;
16use MT::Util qw(encode_html ts2epoch epoch2ts);
17use HTTP::Date qw(str2time time2str);
18
19sub id { 'search' }
20
21sub init {
22    my $app = shift;
23    $app->SUPER::init(@_) or return;
24    $app->set_no_cache;
25    $app->add_methods( search => \&execute );
26    $app->{default_mode} = 'search';
27    $app;
28}
29
30sub load_core_tags {
31    require MT::Template::Context;
32    return {
33        function => {
34            SearchString => \&MT::App::Search::Context::_hdlr_search_string,
35            SearchResultCount => \&MT::App::Search::Context::_hdlr_result_count,
36            MaxResults => \&MT::App::Search::Context::_hdlr_max_results,
37            SearchIncludeBlogs => \&MT::App::Search::Context::_hdlr_include_blogs,
38            SearchTemplateID => \&MT::App::Search::Context::_hdlr_template_id,
39        },
40        block => {
41            SearchResults => \&MT::App::Search::Context::_hdlr_results,
42
43            'IfTagSearch?' => sub { MT->instance->{searchparam}{Type} eq 'tag' },
44            'IfStraightSearch?' => sub { MT->instance->{searchparam}{Type} eq 'straight' },
45            NoSearchResults => \&MT::Template::Context::_hdlr_pass_tokens,
46            NoSearch => \&MT::Template::Context::_hdlr_pass_tokens,
47            BlogResultHeader => \&MT::Template::Context::_hdlr_pass_tokens,
48            BlogResultFooter => \&MT::Template::Context::_hdlr_pass_tokens,
49            IfMaxResultsCutoff => \&MT::Template::Context::_hdlr_pass_tokens,
50        },
51    };
52}
53
54sub init_request{
55    my $app = shift;
56    $app->SUPER::init_request(@_);
57
58    foreach (qw(searchparam templates search_string results
59                user __have_throttle)) {
60        delete $app->{$_} if exists $app->{$_}
61    }
62
63    my $q = $app->param;
64    my $cfg = $app->config;
65
66    my $tag = $q->param('tag') || '';
67    $app->param('Type', 'tag') if $tag;
68    $app->param('search', $tag) if $tag;
69    my $blog_id = $q->param('blog_id') || '';
70    my $include_blog_id = $q->param('IncludeBlogs') || '';
71
72    unless ($include_blog_id){
73        $app->param('IncludeBlogs', $blog_id) if $blog_id;
74    }
75
76    ## Get login information if user is logged in (via cookie).
77    ## If no login cookie, this fails silently, and that's fine.
78    ($app->{user}) = $app->login;
79
80    ## Check whether IP address has searched in the last
81    ## minute which is still progressing. If so, block it.
82    return $app->throttle_response() unless $app->throttle_control();
83
84    my %no_override = map { $_ => 1 } split /\s*,\s*/, $cfg->NoOverride;
85
86    ## Combine user-selected included/excluded blogs
87    ## with config file settings.
88    for my $type (qw( IncludeBlogs ExcludeBlogs )) {
89        $app->{searchparam}{$type} = { };
90        if (my $list = $cfg->$type()) {
91            $app->{searchparam}{$type} =
92                { map { $_ => 1 } split /\s*,\s*/, $list };
93        }
94        next if $no_override{$type};
95        for my $blog_id ($q->param($type)) {
96            if ($blog_id =~ m/,/) {
97                my @ids = split /,/, $blog_id;
98                s/\D+//g for @ids; # only numeric values.
99                foreach my $id (@ids) {
100                    next unless $id;
101                    $app->{searchparam}{$type}{$id} = 1;
102                }
103            } else {
104                $blog_id =~ s/\D+//g; # only numeric values.
105                $app->{searchparam}{$type}{$blog_id} = 1;
106            }
107        }
108    }
109
110    ## If IncludeBlogs has not been set, we need to build a list of
111    ## the blogs to search. If ExcludeBlogs was set, exclude any blogs
112    ## set in that list from our final list.
113    if (!keys %{ $app->{searchparam}{IncludeBlogs} }) {
114        my $exclude = $app->{searchparam}{ExcludeBlogs};
115        require MT::Blog;
116        my $iter = MT::Blog->load_iter;
117        while (my $blog = $iter->()) {
118            $app->{searchparam}{IncludeBlogs}{$blog->id} = 1
119                unless $exclude && $exclude->{$blog->id};
120        }
121    }
122
123    ## Set other search params--prefer per-query setting, default to
124    ## config file.
125    for my $key (qw( RegexSearch CaseSearch ResultDisplay SearchCutoff
126                     CommentSearchCutoff ExcerptWords SearchElement
127                     Type MaxResults SearchSortBy )) {
128        $app->{searchparam}{$key} = $no_override{$key} ?
129            $cfg->$key() : ($q->param($key) || $cfg->$key());
130    }
131    $app->{searchparam}{entry_type} = $q->param('type');
132    $app->{searchparam}{Template} = $q->param('Template') ||
133        ($app->{searchparam}{Type} eq 'newcomments' ? 'comments' : 'default');
134
135    ## Define alternate user templates from config file
136    if (my @tmpls = ($cfg->default('AltTemplate'), $cfg->AltTemplate)) {
137        for my $tmpl (@tmpls) {
138            next unless defined $tmpl;
139            my($nickname, $file) = split /\s+/, $tmpl;
140            $app->{templates}{$nickname} = $file;
141        }
142    }
143
144    $app->{templates}{default} = $cfg->DefaultTemplate;
145    $app->{searchparam}{SearchTemplatePath} = $cfg->SearchTemplatePath;
146
147    ## Set search_string (for display only)
148    if ( ( $app->{searchparam}{Type} eq 'straight' )
149        || ( $app->{searchparam}{Type} eq 'tag' ) ) {
150        if ($q->param('search')) {
151            $app->{search_string} = $q->param('search');
152            $app->{search_string_decoded} = MT::I18N::decode(
153                $app->config->PublishCharset,
154                $app->{search_string}
155            );
156        } else {
157            $app->{search_string} = $app->{search_string_decoded} = q();
158        }
159    }
160}
161
162sub throttle_response {
163    my $app = shift;
164    my $tmpl = $app->param('Template') || '';
165    my $msg = $app->translate(
166        "You are currently performing a search. Please wait " .
167        "until your search is completed.");
168    if ($tmpl eq 'feed') {
169        $app->response_code(503);
170        $app->set_header('Retry-After' => $app->config('ThrottleSeconds'));
171        $app->send_http_header("text/plain");
172        $app->{no_print_body} = 1;
173    }
174    return $app->error($msg);
175}
176
177sub throttle_control {
178    my $app = shift;
179
180    # Don't throttle MT registered users
181    return 1 if $app->{user} && $app->{user}->type == MT::Author::AUTHOR();
182
183    my $type = $app->param('Type') || '';
184
185    # Don't throttle tag listings
186    return 1 if $type eq 'tag';
187
188    my $ip = $app->remote_ip;
189    my $whitelist = $app->config('SearchThrottleIPWhitelist');
190    if ($whitelist) {
191        # check for $ip in $whitelist
192        my @list = split /(\s*[,;]\s*|\s+)/, $whitelist;
193        foreach (@list) {
194            next unless $_ =~ m/^\d{1,3}(\.\d{0,3}){0,3}$/;
195            if (($ip eq $_) || ($ip =~ m/^\Q$_\E/)) {
196                return 1;
197            }
198        }
199    }
200
201    if (eval { require DB_File; 1 }) {
202        my $file = File::Spec->catfile($app->config('TempDir'), 'mt-throttle.db');
203        my $DB = tie my %db, 'DB_File', $file;
204        if ($DB) {
205            if (my $time = $db{$ip}) {
206                if ($time > time - $app->config('ThrottleSeconds')) {
207                    return 0;
208                }
209            }
210            $db{$ip} = time;
211            undef $DB;
212            untie %db;
213            $app->{__have_throttle} = 1;
214        }
215    }
216
217    1;
218}
219
220sub takedown {
221    my $app = shift;
222    if ($app->{__have_throttle}) {
223        my $file = File::Spec->catfile($app->config('TempDir'),
224                                       'mt-throttle.db');
225        if (tie my %db, 'DB_File', $file) {
226            my $time = $db{$app->remote_ip};
227            delete $db{$app->remote_ip} if ($time && $time < (time - $app->config('ThrottleSeconds')));
228            untie %db;
229        }
230    }
231    $app->SUPER::takedown(@_);
232}
233
234sub execute {
235    my $app = shift;
236    return $app->error($app->errstr) if $app->errstr;
237
238    my @results;
239    if ($app->{searchparam}{RegexSearch}) {
240        eval { m/$app->{search_string}/ };
241        return $app->error($app->translate("Search failed. Invalid pattern given: [_1]", $@))
242            if $@;
243    }
244    if ($app->{searchparam}{Type} eq 'newcomments') {
245        $app->_new_comments
246            or return $app->error($app->translate(
247                "Search failed: [_1]", $app->errstr));
248        @results = $app->{results} ? @{ $app->{results} } : ();
249    } elsif ($app->{searchparam}{Type} eq 'tag') {
250        $app->_tag_search
251            or return $app->error($app->translate(
252                "Search failed: [_1]", $app->errstr));
253        my $col = $app->{searchparam}{SearchSortBy};
254        my $order = $app->{searchparam}{ResultDisplay} || 'ascend';
255        for my $blog (sort keys %{ $app->{results} }) {
256            my @res = @{ $app->{results}{$blog} };
257            if ($col) {
258                @res = $order eq 'ascend' ?
259                  sort { $a->{entry}->$col() cmp $b->{entry}->$col() } @res :
260                  sort { $b->{entry}->$col() cmp $a->{entry}->$col() } @res;
261            }
262            $res[0]{blogheader} = 1;
263            my $max = $#res;
264            $res[$max]{blogfooter} = 1;
265            push @results, @res;
266        }
267    } else {
268        $app->_straight_search
269            or return $app->error($app->translate(
270                "Search failed: [_1]", $app->errstr));
271        ## Results are stored per-blog, so we sort the list of blogs by name,
272        ## then add in the results to the final list.
273        my $col = $app->{searchparam}{SearchSortBy};
274        my $order = $app->{searchparam}{ResultDisplay} || 'ascend';
275        for my $blog (sort keys %{ $app->{results} }) {
276            my @res = @{ $app->{results}{$blog} };
277            if ($col) {
278                @res = $order eq 'ascend' ?
279                  sort { $a->{entry}->$col() cmp $b->{entry}->$col() } @res :
280                  sort { $b->{entry}->$col() cmp $a->{entry}->$col() } @res;
281            }
282            $res[0]{blogheader} = 1;
283            my $max = $#res;
284            $res[$max]{blogfooter} = 1;
285            push @results, @res;
286        }
287    }
288
289    ## We need to put a blog in context so that includes and <$MTBlog*$>
290    ## tags will work, if they are used. So we choose the first blog in
291    ## the result list. If there is no result list, just load the first
292    ## blog from the database.
293    my($blog);
294    my $include = $app->param('IncludeBlogs');
295    if ($include) {
296        my @blog_ids = split ',', $include;
297        $blog = MT::Blog->load($blog_ids[0]);
298    } else {
299        if (@results) {
300            $blog = $results[0]{blog};
301        }
302        if (!$blog) {
303            $blog = MT::Blog->load($app->param('blog_id'));
304        }
305        $include = $blog->id;
306    }
307
308    ## Initialize and set up the context object.
309    my $ctx = MT::App::Search::Context->new;
310    $ctx->stash('blog', $blog);
311    $ctx->stash('blog_id', $blog->id);
312    $ctx->stash('results', \@results);
313    $ctx->stash('user', $app->{user});
314    $ctx->stash('include_blogs', $include);
315    if (my $str = $app->{search_string}) {
316        $ctx->stash('search_string', encode_html($str));
317    }
318    $ctx->stash('template_id', $app->{searchparam}{Template});
319    $ctx->stash('maxresults', $app->{searchparam}{MaxResults});
320    $ctx->var( 'page_layout', $blog->page_layout )
321        if $blog && $blog->page_layout;
322    if (my $layout = $ctx->var('page_layout')) {
323        my $columns = {
324            'layout-wt'  => 2,
325            'layout-tw'  => 2,
326            'layout-wm'  => 2,
327            'layout-mw'  => 2,
328            'layout-wtt' => 3,
329            'layout-twt' => 3,
330        }->{$layout};
331        $ctx->var( 'page_columns', $columns ) if $columns;
332    }
333
334    my $str;
335    if ($include) {
336        if ($app->{searchparam}{Template} eq 'default') {
337            # look for a 'search_results'
338            my ($blog_id) = split ',', $include;
339            require MT::Template;
340            my $tmpl = MT::Template->load({blog_id => $blog_id, type => 'search_results'});
341            $str = $tmpl->text if $tmpl;
342        }
343    }
344
345    if (!$str) {
346        ## Load the search template.
347        my $tmpl_file = $app->{templates}{ $app->{searchparam}{Template} }
348            or return $app->error($app->translate("No alternate template is specified for the Template '[_1]'", $app->{searchparam}{Template}));
349        my $tmpl = File::Spec->catfile($app->{searchparam}{SearchTemplatePath},
350            $tmpl_file);
351        local *FH;
352        open FH, $tmpl
353            or return $app->error($app->translate(
354                "Opening local file '[_1]' failed: [_2]", $tmpl, "$!" ));
355
356        { local $/; $str = <FH> };
357        close FH;
358    }
359    $str = $app->translate_templatized($str);
360
361    my $ifmax;
362    my $max;
363    if (($app->{searchparam}{MaxResults}) && ($app->{searchparam}{MaxResults} != 9999999)) {
364        $max = $app->{searchparam}{MaxResults};
365        $ifmax = 1;
366    } else {
367        $ifmax = $max = 0;
368    }
369
370    ## Compile and build the search template with results.
371    require MT::Builder;
372    my $build = MT::Builder->new;
373    my $tokens = $build->compile($ctx, $str)
374        or return $app->error($app->translate(
375            "Publishing results failed: [_1]", $build->errstr));
376    defined(my $res = $build->build($ctx, $tokens, { 
377        NoSearch => $app->{query}->param('help') ||
378                    ($app->{searchparam}{Type} ne 'newcomments' &&
379                      (!$ctx->stash('search_string') ||
380                      $ctx->stash('search_string') !~ /\S/)) ? 1 : 0,
381        NoSearchResults => $ctx->stash('search_string') &&
382                           $ctx->stash('search_string') =~ /\S/ &&
383                           !scalar @results,
384        SearchResults => scalar @results,
385        } ))
386        or return $app->error($app->translate(
387            "Publishing results failed: [_1]", $ctx->errstr));
388    $res = $app->_set_form_elements($res);
389
390    if (defined($ctx->stash('content_type'))) {
391        $app->{no_print_body} = 1;
392        if ($app->{searchparam}{Template} eq 'feed') {
393            my $last_update;
394            for (@results) {
395                my $authored_on = ts2epoch($_->{blog}, $_->{entry}->authored_on);
396                $last_update = $authored_on if $authored_on > $last_update;
397            }
398            my $mod_since = $app->get_header('If-Modified-Since');
399
400            if ( !@results || ($last_update && $mod_since && ($last_update <= str2time($mod_since))) ) {
401                $app->response_code(304);
402                $app->response_message('Not Modified');
403                $app->send_http_header($ctx->stash('content_type'));
404            } else {
405                $app->set_header('Last-Modified', time2str($last_update)) if $last_update;
406                $app->send_http_header($ctx->stash('content_type'));
407                $app->print($res);
408            }
409        } else {
410            $app->send_http_header($ctx->stash('content_type'));
411            $app->print($res);
412        }
413    }
414    $res;
415}
416
417sub _tag_search {
418    my $app = shift;
419    return 1 unless $app->{search_string} =~ /\S/;
420
421    # since this technically isn't a search, but a dynamic view
422    # of data, suppress logging...
423    #require MT::Log;
424    #$app->log({
425    #    message => $app->translate("Search: query for '[_1]'",
426    #          $app->{search_string}),
427    #    level => MT::Log::INFO(),
428    #    class => 'search',
429    #    category => 'tag_search',
430    #});
431
432    require MT::Entry;
433    my %terms = (status => MT::Entry::RELEASE());
434    my %args = (direction => $app->{searchparam}{ResultDisplay},
435        'sort' => 'authored_on');
436
437    require MT::Tag;
438    require MT::ObjectTag;
439    my $tags = $app->{search_string};
440    my @tag_names = MT::Tag->split(',', $tags);
441    my %tags = map { $_ => 1, MT::Tag->normalize($_) => 1 } @tag_names;
442    my @tags = MT::Tag->load({ name => [ keys %tags ] });
443    my @tag_ids;
444    foreach (@tags) {
445        push @tag_ids, $_->id;
446        my @more = MT::Tag->load({ n8d_id => $_->n8d_id ? $_->n8d_id : $_->id });
447        push @tag_ids, $_->id foreach @more;
448    }
449    @tag_ids = ( 0 ) unless @tags;
450    $args{'join'} = ['MT::ObjectTag', 'object_id',
451        { tag_id => \@tag_ids, object_datasource => MT::Entry->datasource }, { unique => 1 } ];
452
453    ## Load available blogs and iterate through entries looking for search term
454    require MT::Util;
455    require MT::Blog;
456    require MT::Entry;
457
458    # Override SearchCutoff if If-Modified-Since header is present
459    if ((my $mod_since = $app->get_header('If-Modified-Since')) && $app->{searchparam}{Template} eq 'feed') {
460        my $tz_offset = 15;  # Start with maximum possible offset to UTC
461        my $blog_selected;
462        my $iter;
463        if ($app->{searchparam}{IncludeBlogs}) {
464            $iter = MT::Blog->load_iter({ id => [ keys %{ $app->{searchparam}{IncludeBlogs} }] });
465        } else {
466            $iter = MT::Blog->load_iter;
467        }
468        while (my $blog = $iter->()) {
469            my $blog_offset = $blog->server_offset ?
470                $blog->server_offset : 0;
471            if ($blog_offset < $tz_offset) {
472                $tz_offset = $blog_offset;
473                $blog_selected = $blog;
474            }
475        }
476        $mod_since = epoch2ts($blog_selected, str2time($mod_since));
477        $terms{authored_on} = [ $mod_since ];
478        $args{range} = { authored_on => 1 };
479    } else {
480        if ($app->{searchparam}{SearchCutoff} &&
481            $app->{searchparam}{SearchCutoff} != 9999999) {
482            my @ago = MT::Util::offset_time_list(time - 3600 * 24 *
483                $app->{searchparam}{SearchCutoff});
484            my $ago = sprintf "%04d%02d%02d%02d%02d%02d",
485                $ago[5]+1900, $ago[4]+1, @ago[3,2,1,0];
486            $terms{authored_on} = [ $ago ];
487            $args{range} = { authored_on => 1 };
488        }
489    }
490
491    if (keys %{ $app->{searchparam}{IncludeBlogs} }) {
492        $terms{blog_id} = [ keys %{ $app->{searchparam}{IncludeBlogs} } ];
493    }
494    $terms{class} = $app->{searchparam}{entry_type} || '*';
495    my $iter = MT::Entry->load_iter(\%terms, \%args);
496    my(%blogs, %hits);
497    my $max = $app->{searchparam}{MaxResults}; 
498    while (my $entry = $iter->()) {
499        my $blog_id = $entry->blog_id;
500        if ($hits{$blog_id} && $hits{$blog_id} >= $max) {
501            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
502            my @res = @{ $app->{results}{$blog->name} };
503            my $count = $#res;
504            $res[$count]{maxresults} = $max;
505            next;
506        }
507        if ($app->_search_hit($entry)) {
508            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
509            $app->_store_hit_data($blog, $entry, $hits{$blog_id}++);
510        }
511    }
512    1;
513}
514
515sub _straight_search {
516    my $app = shift;
517    return 1 unless $app->{search_string} =~ /\S/;
518
519    ## Parse, tokenize and optimize the search query.
520    $app->query_parse;
521
522    ## Load available blogs and iterate through entries looking for search term
523    require MT::Util;
524    require MT::Blog;
525    require MT::Entry;
526
527    my %terms = (status => MT::Entry::RELEASE());
528    my %args = (direction => $app->{searchparam}{ResultDisplay},
529                'sort' => 'authored_on');
530
531    # Override SearchCutoff if If-Modified-Since header is present
532    if ((my $mod_since = $app->get_header('If-Modified-Since')) && $app->{searchparam}{Template} eq 'feed') {
533        my $tz_offset = 15;  # Start with maximum possible offset to UTC
534        my $blog_selected;
535        my $iter;
536        if ($app->{searchparam}{IncludeBlogs}) {
537            $iter = MT::Blog->load_iter({ id => [ keys %{ $app->{searchparam}{IncludeBlogs} }] });
538        } else {
539            $iter = MT::Blog->load_iter;
540        }
541        while (my $blog = $iter->()) {
542            my $blog_offset = $blog->server_offset ?
543                $blog->server_offset : 0;
544            if ($blog_offset < $tz_offset) {
545                $tz_offset = $blog_offset;
546                $blog_selected = $blog;
547            }
548        }
549        $mod_since = epoch2ts($blog_selected, str2time($mod_since));
550        $terms{authored_on} = [ $mod_since ];
551        $args{range} = { authored_on => 1 };
552    } else {
553        if ($app->{searchparam}{SearchCutoff} &&
554            $app->{searchparam}{SearchCutoff} != 9999999) {
555            my @ago = MT::Util::offset_time_list(time - 3600 * 24 *
556                      $app->{searchparam}{SearchCutoff});
557            my $ago = sprintf "%04d%02d%02d%02d%02d%02d",
558                $ago[5]+1900, $ago[4]+1, @ago[3,2,1,0];
559            $terms{authored_on} = [ $ago ];
560            $args{range} = { authored_on => 1 };
561        }
562    }
563
564    if (keys %{ $app->{searchparam}{IncludeBlogs} }) {
565        $terms{blog_id} = [ keys %{ $app->{searchparam}{IncludeBlogs} } ];
566    }
567
568    my $blog_id;
569    if ($terms{blog_id} && (scalar(@{ $terms{blog_id} }) == 1)) {
570        $blog_id = $terms{blog_id}[0];
571    }
572
573    #FIXME: template name may not be 'feed' for search feed
574    unless ($app->{searchparam}{Template} eq 'feed') {
575        require MT::Log;
576        $app->log({
577            message => $app->translate("Search: query for '[_1]'",
578                  $app->{search_string}),
579            level => MT::Log::INFO(),
580            class => 'search',
581            category => 'straight_search',
582            $blog_id ? (blog_id => $blog_id) : ()
583        });
584    }
585
586    $terms{class} = $app->{searchparam}{entry_type} || '*';
587
588    my $iter = MT::Entry->load_iter(\%terms, \%args);
589    my(%blogs, %hits);
590    my $max = $app->{searchparam}{MaxResults}; 
591    while (my $entry = $iter->()) {
592        my $blog_id = $entry->blog_id;
593        if ($hits{$blog_id} && $hits{$blog_id} >= $max) {
594            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
595            my @res = @{ $app->{results}{$blog->name} };
596            my $count = $#res;
597            $res[$count]{maxresults} = $max;
598            next;
599        }
600        if ($app->_search_hit($entry)) {
601            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
602            $app->_store_hit_data($blog, $entry, $hits{$blog_id}++);
603        }
604    }
605    1;
606}
607
608sub _new_comments {
609    my $app = shift;
610    return 1 if $app->param('help');
611
612    require MT::Log;
613    $app->log({
614        message => $app->translate("Search: new comment search"),
615        level => MT::Log::INFO(),
616        class => 'search',
617        category => 'comment_search'
618    });
619    require MT::Entry;
620    require MT::Blog;
621    require MT::Util;
622    my %args = ('join' => [
623                    'MT::Comment', 'entry_id', { visible => 1 },
624                    { 'sort' => 'created_on',
625                       direction => 'descend',
626                       unique => 1, }
627               ]);
628    if ($app->{searchparam}{CommentSearchCutoff} &&
629        $app->{searchparam}{CommentSearchCutoff} != 9999999) {
630        my @ago = MT::Util::offset_time_list(time - 3600 * 24 *
631                  $app->{searchparam}{CommentSearchCutoff});
632        my $ago = sprintf "%04d%02d%02d%02d%02d%02d",
633            $ago[5]+1900, $ago[4]+1, @ago[3,2,1,0];
634        $args{'join'}->[2]{created_on} = [ $ago ];
635        $args{'join'}->[3]{range} = { created_on => 1 };
636    } elsif ($app->{searchparam}{MaxResults} &&
637             $app->{searchparam}{MaxResults} != 9999999) {
638        $args{limit} = $app->{searchparam}{MaxResults};
639    }
640    my %terms = { status => MT::Entry::RELEASE() };
641    $terms{class} = $app->{searchparam}{entry_type} || '*';
642    my $iter = MT::Entry->load_iter(\%terms, \%args);
643    my %blogs;
644    my $include = $app->{searchparam}{IncludeBlogs};
645    while (my $entry = $iter->()) {
646        next unless $include->{ $entry->blog_id };
647        my $blog = $blogs{ $entry->blog_id } || MT::Blog->load($entry->blog_id);
648        $app->_store_hit_data($blog, $entry);
649    }
650    1;
651}
652
653sub _set_form_elements {
654    my($app, $tmpl) = @_;
655    ## Fill in user-defined template with proper form settings.
656    if ($app->{searchparam}{Type} eq 'newcomments') {
657        if ($app->{searchparam}{CommentSearchCutoff}) {
658            $tmpl =~ s/(<select name="CommentSearchCutoff">.*<option value="$app->{searchparam}{CommentSearchCutoff}")/$1 selected="selected"/si;
659        } else {
660            $tmpl =~ s/(<select name="CommentSearchCutoff">.*<option value="9999999")/$1 selected="selected"/si;
661        }
662    } else {
663        if ($app->{searchparam}{SearchCutoff}) {
664            $tmpl =~ s/(<select name="SearchCutoff">.*<option value="$app->{searchparam}{SearchCutoff}")/$1 selected="selected"/si;
665        } else {
666            $tmpl =~ s/(<select name="SearchCutoff">.*<option value="9999999")/$1 selected="selected"/si;
667        }
668
669        if ($app->{searchparam}{CaseSearch}) {
670            $tmpl =~ s/(<input type="checkbox"[^>]+name="CaseSearch")/$1 checked="checked"/g;
671        }
672        if ($app->{searchparam}{RegexSearch}) {
673            $tmpl =~ s/(<input type="checkbox"[^>]+name="RegexSearch")/$1 checked="checked"/g;
674        }
675        $tmpl =~ s/(<input type="radio"[^>]+?$app->{searchparam}{SearchElement}\")/$1 checked="checked"/g;
676        for my $type (qw( IncludeBlogs ExcludeBlogs )) {
677            for my $blog_id (keys %{ $app->{searchparam}{$type} }) {
678                $tmpl =~ s/(<input type="checkbox"[^>]+?$type" value="$blog_id")/$1 checked="checked"/g;    #"
679            }
680        }
681    }
682    if ($app->{searchparam}{MaxResults}) {
683        $tmpl =~ s/(<select name="MaxResults">.*<option value="$app->{searchparam}{MaxResults}")/$1 selected="selected"/si;
684    } else {
685        $tmpl =~ s/(<select name="MaxResults">.*<option value="9999999")/$1 selected="selected"/si;
686    }
687    $tmpl;
688}
689
690my $decoder_ring;
691sub is_a_match { 
692    my($app, $txt) = @_;
693    use utf8;
694    unless ($decoder_ring) {
695        my $enc = $app->config->PublishCharset;
696        eval { 
697            require Encode;
698            $decoder_ring = sub { Encode::decode($enc, shift) };
699        };
700        if ($@) {
701            $decoder_ring = sub { MT::I18N::decode($enc, shift) };
702        }
703    }
704    $txt = $decoder_ring->($txt);
705
706    if ($app->{searchparam}{RegexSearch}) {
707        my $keyword = $app->{searchparam}{search_string_decoded};
708        if ($app->{searchparam}{CaseSearch}) {
709            return unless $txt =~ m/$keyword/;
710        } else {
711            return unless $txt =~ m/$keyword/i;
712        }
713    } else {
714        my $casemod = $app->{searchparam}{CaseSearch} ? '' : '(?i)';
715        for (@{$app->{searchparam}{search_keys}{AND}}) {
716            return unless $txt =~ /$casemod$_/;
717    }
718    for (@{$app->{searchparam}{search_keys}{NOT}}) {
719            return if $txt =~ /$casemod$_/;
720        }
721    }
722    1;
723}       
724
725sub query_parse {
726    my $app = shift;
727    return unless $app->{search_string};
728    use utf8;
729    #local $_ = MT::I18N::decode_utf8($app->{search_string});
730    local $_ = $app->{search_string_decoded};
731
732    s/^\s//;            # Remove leading whitespace
733    s/\s$//;            # Remove trailing whitespace
734    s/\s+AND\s+/ /g;    # Remove AND because it's implied
735    s/\s{2,}/ /g;       # Remove contiguous spaces
736
737    my @search_keys;
738    my @tokens = split;
739    while (my $atom = shift @tokens) {
740        my($type);
741        if ($atom eq 'NOT' || $atom eq 'AND') {
742            $type = $atom;
743            $atom = shift @tokens;
744            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
745        } elsif ($atom eq 'OR') {
746            $atom = shift @tokens;
747            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
748            ## OR new atom with last atom
749            $search_keys[-1]{atom} =
750                '(?:' . $search_keys[-1]{atom} .'|' . quotemeta($atom) . ')';
751            next;
752        } elsif ($atom =~ /^-(.*)/) {
753            $type = 'NOT';
754            $atom = $1;
755            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
756        } else {
757            $type = 'AND';
758            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
759        }
760        push @search_keys, { atom => quotemeta($atom),
761                             type => $type };
762    }
763
764    $app->{searchparam}{search_keys} = \@search_keys;
765    $app->query_optimize;
766}
767
768sub find_phrase {
769    my($atom, $tokenref) = @_;
770    while (my $next = shift @$tokenref) {
771        $atom = $atom . ' ' . $next;
772        last if $atom =~ /\"$/;
773    }
774    $atom =~ s/^"(.*)"$/$1/;
775    $atom;
776}
777
778sub query_optimize {
779    my $app = shift;
780
781    ## Sort keys longest to shortest for search efficiency.
782    $app->{searchparam}{search_keys} = [
783        reverse sort { length($a->{atom}) <=> length($b->{atom}) }
784        @{ $app->{searchparam}{search_keys} }
785    ];
786   
787    ## Sort keys by contents. Any ORs immediately get a lower priority.
788    my %terms;
789    for my $key (@{ $app->{searchparam}{search_keys} }) {
790        if ($key->{atom} =~ /\(.*\|.*\)/) {
791            push(@{ $terms{$key->{type}}{low} }, $key);
792        } else {
793            push(@{ $terms{$key->{type}}{high} }, $key);
794        }
795    }
796
797    ## Final priority: AND long, AND short, AND with OR (long/short),
798    ## NOT long/short
799    ##  This should give us the most efficient search in that it is
800    ##  searching for the harder-to-match keys first.
801    my %regex;
802    for my $type (qw( AND NOT )) {
803        for my $pri (qw( high low )) {
804            for my $obj (@{ $terms{$type}{$pri} }) {
805                push(@{ $regex{$type} }, $obj->{atom});
806            }
807        }
808    }
809
810    $app->{searchparam}{search_keys} = \%regex;
811}
812
813
814sub _search_hit {
815    my($app, $entry) = @_;
816    my @text_elements;
817    if ($app->{searchparam}{SearchElement} ne 'comments') {
818        @text_elements = ($entry->title, $entry->text, $entry->text_more,
819                          $entry->keywords);
820    }
821    if ($app->{searchparam}{SearchElement} ne 'entries') {
822        my $comments = $entry->comments;
823        for my $comment (@$comments) {
824            push @text_elements, $comment->text, $comment->author,
825                                 $comment->url;
826        }
827    }
828    return 1 if $app->is_a_match(join("\n", map $_ || '', @text_elements));
829}
830
831sub _store_hit_data {
832    my $app = shift;
833    my($blog, $entry, $banner_seen) = @_;
834    my %result_data = (blog => $blog);
835    ## Need to create entry excerpt here, because we can't rely on
836    ## the user's per-blog setting.
837    unless ($entry->excerpt) {
838        $entry->excerpt($entry->get_excerpt($app->{searchparam}{ExcerptWords}));
839    }
840    $result_data{entry} = $entry;
841    if ($app->{searchparam}{Type} eq 'newcomments') {
842        push @{ $app->{results} }, \%result_data;
843    } else {
844        push(@{ $app->{results}{ $blog->name } }, \%result_data);
845    }
846}
847
848
849package MT::App::Search::Context;
850
851use strict;
852use base qw( MT::Template::Context );
853
854sub _hdlr_include_blogs { $_[0]->stash('include_blogs') || '' }
855sub _hdlr_search_string { $_[0]->stash('search_string') || '' }
856sub _hdlr_template_id { $_[0]->stash('template_id') || '' }
857sub _hdlr_max_results { $_[0]->stash('maxresults') || '' }
858
859sub _hdlr_result_count {
860    my $results = $_[0]->stash('results');
861    $results && ref($results) eq 'ARRAY' ? scalar @$results : 0;
862}
863
864sub _hdlr_results {
865    my($ctx, $args, $cond) = @_;
866
867    ## If there are no results, return the empty string, knowing
868    ## that the handler for <MTNoSearchResults> will fill in the
869    ## no results message.
870    my $results = $ctx->stash('results') or return '';
871
872    my $output = '';
873    my $build = $ctx->stash('builder');
874    my $tokens = $ctx->stash('tokens');
875    for my $res (@$results) {
876        $ctx->stash('entry', $res->{entry});
877        local $ctx->{__stash}{blog} = $res->{blog};
878        $ctx->stash('result', $res);
879        local $ctx->{current_timestamp} = $res->{entry}->created_on;
880        defined(my $out = $build->build($ctx, $tokens,
881            { %$cond, 
882                BlogResultHeader => $res->{blogheader} ? 1 : 0, 
883                BlogResultFooter => $res->{blogfooter} ? 1 : 0,
884                IfMaxResultsCutoff => $res->{maxresults} ? 1 : 0,
885            }
886            )) or return $ctx->error( $build->errstr );
887        $output .= $out;
888    }
889    $output;
890}
891
8921;
893__END__
894
895=head1 NAME
896
897MT::App::Search
898
899=head1 AUTHOR & COPYRIGHT
900
901Please see L<MT/AUTHOR & COPYRIGHT>.
902
903=cut
Note: See TracBrowser for help on using the browser.