root/branches/release-41/lib/MT/App/Search/Legacy.pm @ 2739

Revision 2739, 31.8 kB (checked in by fumiakiy, 17 months ago)

Applied the fix to the latest release to the legacy code.

  • 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    $include =~ s/[^\d,]//g; 
296    if ($include) {
297        my @blog_ids = split ',', $include;
298        $blog = MT::Blog->load($blog_ids[0]);
299    } else {
300        if (@results) {
301            $blog = $results[0]{blog};
302        }
303        if (!$blog) {
304            $blog = MT::Blog->load($app->param('blog_id'));
305        }
306        $include = $blog->id if $blog; 
307    } 
308    return $app->error($app->translate('Invalid request.')) 
309        unless $blog; 
310
311    ## Initialize and set up the context object.
312    my $ctx = MT::App::Search::Context->new;
313    $ctx->stash('blog', $blog);
314    $ctx->stash('blog_id', $blog->id);
315    $ctx->stash('results', \@results);
316    $ctx->stash('user', $app->{user});
317    $ctx->stash('include_blogs', $include);
318    if (my $str = $app->{search_string}) {
319        $ctx->stash('search_string', encode_html($str));
320    }
321    $ctx->stash('template_id', $app->{searchparam}{Template});
322    my $maxresults = $app->{searchparam}{MaxResults} || ''; 
323    $maxresults =~ s/\D//g; 
324    $app->{searchparam}{MaxResults} = $maxresults; 
325    $ctx->stash('maxresults', $maxresults); 
326    $ctx->var( 'page_layout', $blog->page_layout )
327        if $blog && $blog->page_layout;
328    if (my $layout = $ctx->var('page_layout')) {
329        my $columns = {
330            'layout-wt'  => 2,
331            'layout-tw'  => 2,
332            'layout-wm'  => 2,
333            'layout-mw'  => 2,
334            'layout-wtt' => 3,
335            'layout-twt' => 3,
336        }->{$layout};
337        $ctx->var( 'page_columns', $columns ) if $columns;
338    }
339
340    my $str;
341    if ($include) {
342        if ($app->{searchparam}{Template} eq 'default') {
343            # look for a 'search_results'
344            my ($blog_id) = split ',', $include;
345            require MT::Template;
346            my $tmpl = MT::Template->load({blog_id => $blog_id, type => 'search_results'});
347            $str = $tmpl->text if $tmpl;
348        }
349    }
350
351    if (!$str) {
352        ## Load the search template.
353        my $tmpl_file = $app->{templates}{ $app->{searchparam}{Template} }
354            or return $app->error($app->translate("No alternate template is specified for the Template '[_1]'", $app->{searchparam}{Template}));
355        my $tmpl = File::Spec->catfile($app->{searchparam}{SearchTemplatePath},
356            $tmpl_file);
357        local *FH;
358        open FH, $tmpl
359            or return $app->error($app->translate(
360                "Opening local file '[_1]' failed: [_2]", $tmpl, "$!" ));
361
362        { local $/; $str = <FH> };
363        close FH;
364    }
365    $str = $app->translate_templatized($str);
366
367    my $ifmax;
368    my $max;
369    if (($app->{searchparam}{MaxResults}) && ($app->{searchparam}{MaxResults} != 9999999)) {
370        $max = $app->{searchparam}{MaxResults};
371        $ifmax = 1;
372    } else {
373        $ifmax = $max = 0;
374    }
375
376    ## Compile and build the search template with results.
377    require MT::Builder;
378    my $build = MT::Builder->new;
379    my $tokens = $build->compile($ctx, $str)
380        or return $app->error($app->translate(
381            "Publishing results failed: [_1]", $build->errstr));
382    defined(my $res = $build->build($ctx, $tokens, { 
383        NoSearch => $app->{query}->param('help') ||
384                    ($app->{searchparam}{Type} ne 'newcomments' &&
385                      (!$ctx->stash('search_string') ||
386                      $ctx->stash('search_string') !~ /\S/)) ? 1 : 0,
387        NoSearchResults => $ctx->stash('search_string') &&
388                           $ctx->stash('search_string') =~ /\S/ &&
389                           !scalar @results,
390        SearchResults => scalar @results,
391        } ))
392        or return $app->error($app->translate(
393            "Publishing results failed: [_1]", $ctx->errstr));
394    $res = $app->_set_form_elements($res);
395
396    if (defined($ctx->stash('content_type'))) {
397        $app->{no_print_body} = 1;
398        if ($app->{searchparam}{Template} eq 'feed') {
399            my $last_update;
400            for (@results) {
401                my $authored_on = ts2epoch($_->{blog}, $_->{entry}->authored_on);
402                $last_update = $authored_on if $authored_on > $last_update;
403            }
404            my $mod_since = $app->get_header('If-Modified-Since');
405
406            if ( !@results || ($last_update && $mod_since && ($last_update <= str2time($mod_since))) ) {
407                $app->response_code(304);
408                $app->response_message('Not Modified');
409                $app->send_http_header($ctx->stash('content_type'));
410            } else {
411                $app->set_header('Last-Modified', time2str($last_update)) if $last_update;
412                $app->send_http_header($ctx->stash('content_type'));
413                $app->print($res);
414            }
415        } else {
416            $app->send_http_header($ctx->stash('content_type'));
417            $app->print($res);
418        }
419    }
420    $res;
421}
422
423sub _tag_search {
424    my $app = shift;
425    return 1 unless $app->{search_string} =~ /\S/;
426
427    # since this technically isn't a search, but a dynamic view
428    # of data, suppress logging...
429    #require MT::Log;
430    #$app->log({
431    #    message => $app->translate("Search: query for '[_1]'",
432    #          $app->{search_string}),
433    #    level => MT::Log::INFO(),
434    #    class => 'search',
435    #    category => 'tag_search',
436    #});
437
438    require MT::Entry;
439    my %terms = (status => MT::Entry::RELEASE());
440    my %args = (direction => $app->{searchparam}{ResultDisplay},
441        'sort' => 'authored_on');
442
443    require MT::Tag;
444    require MT::ObjectTag;
445    my $tags = $app->{search_string};
446    my @tag_names = MT::Tag->split(',', $tags);
447    my %tags = map { $_ => 1, MT::Tag->normalize($_) => 1 } @tag_names;
448    my @tags = MT::Tag->load({ name => [ keys %tags ] });
449    my @tag_ids;
450    foreach (@tags) {
451        push @tag_ids, $_->id;
452        my @more = MT::Tag->load({ n8d_id => $_->n8d_id ? $_->n8d_id : $_->id });
453        push @tag_ids, $_->id foreach @more;
454    }
455    @tag_ids = ( 0 ) unless @tags;
456    $args{'join'} = ['MT::ObjectTag', 'object_id',
457        { tag_id => \@tag_ids, object_datasource => MT::Entry->datasource }, { unique => 1 } ];
458
459    ## Load available blogs and iterate through entries looking for search term
460    require MT::Util;
461    require MT::Blog;
462    require MT::Entry;
463
464    # Override SearchCutoff if If-Modified-Since header is present
465    if ((my $mod_since = $app->get_header('If-Modified-Since')) && $app->{searchparam}{Template} eq 'feed') {
466        my $tz_offset = 15;  # Start with maximum possible offset to UTC
467        my $blog_selected;
468        my $iter;
469        if ($app->{searchparam}{IncludeBlogs}) {
470            $iter = MT::Blog->load_iter({ id => [ keys %{ $app->{searchparam}{IncludeBlogs} }] });
471        } else {
472            $iter = MT::Blog->load_iter;
473        }
474        while (my $blog = $iter->()) {
475            my $blog_offset = $blog->server_offset ?
476                $blog->server_offset : 0;
477            if ($blog_offset < $tz_offset) {
478                $tz_offset = $blog_offset;
479                $blog_selected = $blog;
480            }
481        }
482        $mod_since = epoch2ts($blog_selected, str2time($mod_since));
483        $terms{authored_on} = [ $mod_since ];
484        $args{range} = { authored_on => 1 };
485    } else {
486        if ($app->{searchparam}{SearchCutoff} &&
487            $app->{searchparam}{SearchCutoff} != 9999999) {
488            my @ago = MT::Util::offset_time_list(time - 3600 * 24 *
489                $app->{searchparam}{SearchCutoff});
490            my $ago = sprintf "%04d%02d%02d%02d%02d%02d",
491                $ago[5]+1900, $ago[4]+1, @ago[3,2,1,0];
492            $terms{authored_on} = [ $ago ];
493            $args{range} = { authored_on => 1 };
494        }
495    }
496
497    if (keys %{ $app->{searchparam}{IncludeBlogs} }) {
498        $terms{blog_id} = [ keys %{ $app->{searchparam}{IncludeBlogs} } ];
499    }
500    $terms{class} = $app->{searchparam}{entry_type} || '*';
501    my $iter = MT::Entry->load_iter(\%terms, \%args);
502    my(%blogs, %hits);
503    my $max = $app->{searchparam}{MaxResults}; 
504    while (my $entry = $iter->()) {
505        my $blog_id = $entry->blog_id;
506        if ($hits{$blog_id} && $hits{$blog_id} >= $max) {
507            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id)
508                or next;
509            my @res = @{ $app->{results}{$blog->name} };
510            my $count = $#res;
511            $res[$count]{maxresults} = $max;
512            next;
513        }
514        if ($app->_search_hit($entry)) {
515            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
516            $app->_store_hit_data($blog, $entry, $hits{$blog_id}++) if $blog;
517        }
518    }
519    1;
520}
521
522sub _straight_search {
523    my $app = shift;
524    return 1 unless $app->{search_string} =~ /\S/;
525
526    ## Parse, tokenize and optimize the search query.
527    $app->query_parse;
528
529    ## Load available blogs and iterate through entries looking for search term
530    require MT::Util;
531    require MT::Blog;
532    require MT::Entry;
533
534    my %terms = (status => MT::Entry::RELEASE());
535    my %args = (direction => $app->{searchparam}{ResultDisplay},
536                'sort' => 'authored_on');
537
538    # Override SearchCutoff if If-Modified-Since header is present
539    if ((my $mod_since = $app->get_header('If-Modified-Since')) && $app->{searchparam}{Template} eq 'feed') {
540        my $tz_offset = 15;  # Start with maximum possible offset to UTC
541        my $blog_selected;
542        my $iter;
543        if ($app->{searchparam}{IncludeBlogs}) {
544            $iter = MT::Blog->load_iter({ id => [ keys %{ $app->{searchparam}{IncludeBlogs} }] });
545        } else {
546            $iter = MT::Blog->load_iter;
547        }
548        while (my $blog = $iter->()) {
549            my $blog_offset = $blog->server_offset ?
550                $blog->server_offset : 0;
551            if ($blog_offset < $tz_offset) {
552                $tz_offset = $blog_offset;
553                $blog_selected = $blog;
554            }
555        }
556        $mod_since = epoch2ts($blog_selected, str2time($mod_since));
557        $terms{authored_on} = [ $mod_since ];
558        $args{range} = { authored_on => 1 };
559    } else {
560        if ($app->{searchparam}{SearchCutoff} &&
561            $app->{searchparam}{SearchCutoff} != 9999999) {
562            my @ago = MT::Util::offset_time_list(time - 3600 * 24 *
563                      $app->{searchparam}{SearchCutoff});
564            my $ago = sprintf "%04d%02d%02d%02d%02d%02d",
565                $ago[5]+1900, $ago[4]+1, @ago[3,2,1,0];
566            $terms{authored_on} = [ $ago ];
567            $args{range} = { authored_on => 1 };
568        }
569    }
570
571    if (keys %{ $app->{searchparam}{IncludeBlogs} }) {
572        $terms{blog_id} = [ keys %{ $app->{searchparam}{IncludeBlogs} } ];
573    }
574
575    my $blog_id;
576    if ($terms{blog_id} && (scalar(@{ $terms{blog_id} }) == 1)) {
577        $blog_id = $terms{blog_id}[0];
578    }
579
580    #FIXME: template name may not be 'feed' for search feed
581    unless ($app->{searchparam}{Template} eq 'feed') {
582        require MT::Log;
583        $app->log({
584            message => $app->translate("Search: query for '[_1]'",
585                  $app->{search_string}),
586            level => MT::Log::INFO(),
587            class => 'search',
588            category => 'straight_search',
589            $blog_id ? (blog_id => $blog_id) : ()
590        });
591    }
592
593    $terms{class} = $app->{searchparam}{entry_type} || '*';
594
595    my $iter = MT::Entry->load_iter(\%terms, \%args);
596    my(%blogs, %hits);
597    my $max = $app->{searchparam}{MaxResults}; 
598    while (my $entry = $iter->()) {
599        my $blog_id = $entry->blog_id;
600        if ($hits{$blog_id} && $hits{$blog_id} >= $max) {
601            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
602            my @res = @{ $app->{results}{$blog->name} };
603            my $count = $#res;
604            $res[$count]{maxresults} = $max;
605            next;
606        }
607        if ($app->_search_hit($entry)) {
608            my $blog = $blogs{$blog_id} || MT::Blog->load($blog_id);
609            $app->_store_hit_data($blog, $entry, $hits{$blog_id}++);
610        }
611    }
612    1;
613}
614
615sub _new_comments {
616    my $app = shift;
617    return 1 if $app->param('help');
618
619    require MT::Log;
620    $app->log({
621        message => $app->translate("Search: new comment search"),
622        level => MT::Log::INFO(),
623        class => 'search',
624        category => 'comment_search'
625    });
626    require MT::Entry;
627    require MT::Blog;
628    require MT::Util;
629    my %args = ('join' => [
630                    'MT::Comment', 'entry_id', { visible => 1 },
631                    { 'sort' => 'created_on',
632                       direction => 'descend',
633                       unique => 1, }
634               ]);
635    if ($app->{searchparam}{CommentSearchCutoff} &&
636        $app->{searchparam}{CommentSearchCutoff} != 9999999) {
637        my @ago = MT::Util::offset_time_list(time - 3600 * 24 *
638                  $app->{searchparam}{CommentSearchCutoff});
639        my $ago = sprintf "%04d%02d%02d%02d%02d%02d",
640            $ago[5]+1900, $ago[4]+1, @ago[3,2,1,0];
641        $args{'join'}->[2]{created_on} = [ $ago ];
642        $args{'join'}->[3]{range} = { created_on => 1 };
643    } elsif ($app->{searchparam}{MaxResults} &&
644             $app->{searchparam}{MaxResults} != 9999999) {
645        $args{limit} = $app->{searchparam}{MaxResults};
646    }
647    my %terms = { status => MT::Entry::RELEASE() };
648    $terms{class} = $app->{searchparam}{entry_type} || '*';
649    my $iter = MT::Entry->load_iter(\%terms, \%args);
650    my %blogs;
651    my $include = $app->{searchparam}{IncludeBlogs};
652    while (my $entry = $iter->()) {
653        next unless $include->{ $entry->blog_id };
654        my $blog = $blogs{ $entry->blog_id } || MT::Blog->load($entry->blog_id);
655        $app->_store_hit_data($blog, $entry);
656    }
657    1;
658}
659
660sub _set_form_elements {
661    my($app, $tmpl) = @_;
662    ## Fill in user-defined template with proper form settings.
663    if ($app->{searchparam}{Type} eq 'newcomments') {
664        if ($app->{searchparam}{CommentSearchCutoff}) {
665            $tmpl =~ s/(<select name="CommentSearchCutoff">.*<option value="$app->{searchparam}{CommentSearchCutoff}")/$1 selected="selected"/si;
666        } else {
667            $tmpl =~ s/(<select name="CommentSearchCutoff">.*<option value="9999999")/$1 selected="selected"/si;
668        }
669    } else {
670        if ($app->{searchparam}{SearchCutoff}) {
671            $tmpl =~ s/(<select name="SearchCutoff">.*<option value="$app->{searchparam}{SearchCutoff}")/$1 selected="selected"/si;
672        } else {
673            $tmpl =~ s/(<select name="SearchCutoff">.*<option value="9999999")/$1 selected="selected"/si;
674        }
675
676        if ($app->{searchparam}{CaseSearch}) {
677            $tmpl =~ s/(<input type="checkbox"[^>]+name="CaseSearch")/$1 checked="checked"/g;
678        }
679        if ($app->{searchparam}{RegexSearch}) {
680            $tmpl =~ s/(<input type="checkbox"[^>]+name="RegexSearch")/$1 checked="checked"/g;
681        }
682        $tmpl =~ s/(<input type="radio"[^>]+?$app->{searchparam}{SearchElement}\")/$1 checked="checked"/g;
683        for my $type (qw( IncludeBlogs ExcludeBlogs )) {
684            for my $blog_id (keys %{ $app->{searchparam}{$type} }) {
685                $tmpl =~ s/(<input type="checkbox"[^>]+?$type" value="$blog_id")/$1 checked="checked"/g;    #"
686            }
687        }
688    }
689    if ($app->{searchparam}{MaxResults}) {
690        $tmpl =~ s/(<select name="MaxResults">.*<option value="$app->{searchparam}{MaxResults}")/$1 selected="selected"/si;
691    } else {
692        $tmpl =~ s/(<select name="MaxResults">.*<option value="9999999")/$1 selected="selected"/si;
693    }
694    $tmpl;
695}
696
697my $decoder_ring;
698sub is_a_match { 
699    my($app, $txt) = @_;
700    use utf8;
701    unless ($decoder_ring) {
702        my $enc = $app->config->PublishCharset;
703        eval { 
704            require Encode;
705            $decoder_ring = sub { Encode::decode($enc, shift) };
706        };
707        if ($@) {
708            $decoder_ring = sub { MT::I18N::decode($enc, shift) };
709        }
710    }
711    $txt = $decoder_ring->($txt);
712
713    if ($app->{searchparam}{RegexSearch}) {
714        my $keyword = $app->{searchparam}{search_string_decoded};
715        if ($app->{searchparam}{CaseSearch}) {
716            return unless $txt =~ m/$keyword/;
717        } else {
718            return unless $txt =~ m/$keyword/i;
719        }
720    } else {
721        my $casemod = $app->{searchparam}{CaseSearch} ? '' : '(?i)';
722        for (@{$app->{searchparam}{search_keys}{AND}}) {
723            return unless $txt =~ /$casemod$_/;
724    }
725    for (@{$app->{searchparam}{search_keys}{NOT}}) {
726            return if $txt =~ /$casemod$_/;
727        }
728    }
729    1;
730}       
731
732sub query_parse {
733    my $app = shift;
734    return unless $app->{search_string};
735    use utf8;
736    #local $_ = MT::I18N::decode_utf8($app->{search_string});
737    local $_ = $app->{search_string_decoded};
738
739    s/^\s//;            # Remove leading whitespace
740    s/\s$//;            # Remove trailing whitespace
741    s/\s+AND\s+/ /g;    # Remove AND because it's implied
742    s/\s{2,}/ /g;       # Remove contiguous spaces
743
744    my @search_keys;
745    my @tokens = split;
746    while (my $atom = shift @tokens) {
747        my($type);
748        if ($atom eq 'NOT' || $atom eq 'AND') {
749            $type = $atom;
750            $atom = shift @tokens;
751            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
752        } elsif ($atom eq 'OR') {
753            $atom = shift @tokens;
754            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
755            ## OR new atom with last atom
756            $search_keys[-1]{atom} =
757                '(?:' . $search_keys[-1]{atom} .'|' . quotemeta($atom) . ')';
758            next;
759        } elsif ($atom =~ /^-(.*)/) {
760            $type = 'NOT';
761            $atom = $1;
762            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
763        } else {
764            $type = 'AND';
765            $atom = find_phrase($atom, \@tokens) if $atom =~ /^\"/;
766        }
767        push @search_keys, { atom => quotemeta($atom),
768                             type => $type };
769    }
770
771    $app->{searchparam}{search_keys} = \@search_keys;
772    $app->query_optimize;
773}
774
775sub find_phrase {
776    my($atom, $tokenref) = @_;
777    while (my $next = shift @$tokenref) {
778        $atom = $atom . ' ' . $next;
779        last if $atom =~ /\"$/;
780    }
781    $atom =~ s/^"(.*)"$/$1/;
782    $atom;
783}
784
785sub query_optimize {
786    my $app = shift;
787
788    ## Sort keys longest to shortest for search efficiency.
789    $app->{searchparam}{search_keys} = [
790        reverse sort { length($a->{atom}) <=> length($b->{atom}) }
791        @{ $app->{searchparam}{search_keys} }
792    ];
793   
794    ## Sort keys by contents. Any ORs immediately get a lower priority.
795    my %terms;
796    for my $key (@{ $app->{searchparam}{search_keys} }) {
797        if ($key->{atom} =~ /\(.*\|.*\)/) {
798            push(@{ $terms{$key->{type}}{low} }, $key);
799        } else {
800            push(@{ $terms{$key->{type}}{high} }, $key);
801        }
802    }
803
804    ## Final priority: AND long, AND short, AND with OR (long/short),
805    ## NOT long/short
806    ##  This should give us the most efficient search in that it is
807    ##  searching for the harder-to-match keys first.
808    my %regex;
809    for my $type (qw( AND NOT )) {
810        for my $pri (qw( high low )) {
811            for my $obj (@{ $terms{$type}{$pri} }) {
812                push(@{ $regex{$type} }, $obj->{atom});
813            }
814        }
815    }
816
817    $app->{searchparam}{search_keys} = \%regex;
818}
819
820
821sub _search_hit {
822    my($app, $entry) = @_;
823    my @text_elements;
824    if ($app->{searchparam}{SearchElement} ne 'comments') {
825        @text_elements = ($entry->title, $entry->text, $entry->text_more,
826                          $entry->keywords);
827    }
828    if ($app->{searchparam}{SearchElement} ne 'entries') {
829        my $comments = $entry->comments;
830        for my $comment (@$comments) {
831            push @text_elements, $comment->text, $comment->author,
832                                 $comment->url;
833        }
834    }
835    return 1 if $app->is_a_match(join("\n", map $_ || '', @text_elements));
836}
837
838sub _store_hit_data {
839    my $app = shift;
840    my($blog, $entry, $banner_seen) = @_;
841    my %result_data = (blog => $blog);
842    ## Need to create entry excerpt here, because we can't rely on
843    ## the user's per-blog setting.
844    unless ($entry->excerpt) {
845        $entry->excerpt($entry->get_excerpt($app->{searchparam}{ExcerptWords}));
846    }
847    $result_data{entry} = $entry;
848    if ($app->{searchparam}{Type} eq 'newcomments') {
849        push @{ $app->{results} }, \%result_data;
850    } else {
851        push(@{ $app->{results}{ $blog->name } }, \%result_data);
852    }
853}
854
855
856package MT::App::Search::Context;
857
858use strict;
859use base qw( MT::Template::Context );
860
861sub _hdlr_include_blogs { $_[0]->stash('include_blogs') || '' }
862sub _hdlr_search_string { $_[0]->stash('search_string') || '' }
863sub _hdlr_template_id { $_[0]->stash('template_id') || '' }
864sub _hdlr_max_results { $_[0]->stash('maxresults') || '' }
865
866sub _hdlr_result_count {
867    my $results = $_[0]->stash('results');
868    $results && ref($results) eq 'ARRAY' ? scalar @$results : 0;
869}
870
871sub _hdlr_results {
872    my($ctx, $args, $cond) = @_;
873
874    ## If there are no results, return the empty string, knowing
875    ## that the handler for <MTNoSearchResults> will fill in the
876    ## no results message.
877    my $results = $ctx->stash('results') or return '';
878
879    my $output = '';
880    my $build = $ctx->stash('builder');
881    my $tokens = $ctx->stash('tokens');
882    for my $res (@$results) {
883        $ctx->stash('entry', $res->{entry});
884        local $ctx->{__stash}{blog} = $res->{blog};
885        $ctx->stash('result', $res);
886        local $ctx->{current_timestamp} = $res->{entry}->created_on;
887        defined(my $out = $build->build($ctx, $tokens,
888            { %$cond, 
889                BlogResultHeader => $res->{blogheader} ? 1 : 0, 
890                BlogResultFooter => $res->{blogfooter} ? 1 : 0,
891                IfMaxResultsCutoff => $res->{maxresults} ? 1 : 0,
892            }
893            )) or return $ctx->error( $build->errstr );
894        $output .= $out;
895    }
896    $output;
897}
898
8991;
900__END__
901
902=head1 NAME
903
904MT::App::Search
905
906=head1 AUTHOR & COPYRIGHT
907
908Please see L<MT/AUTHOR & COPYRIGHT>.
909
910=cut
Note: See TracBrowser for help on using the browser.