| 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 | package MT::App::Search::TagSearch; |
|---|
| 8 | |
|---|
| 9 | use strict; |
|---|
| 10 | use MT::Util qw( epoch2ts offset_time_list ); |
|---|
| 11 | use HTTP::Date qw( str2time ); |
|---|
| 12 | |
|---|
| 13 | sub process { |
|---|
| 14 | my $app = shift; |
|---|
| 15 | return $app->errtrans('TagSearch works with MT::App::Search.') |
|---|
| 16 | unless $app->isa('MT::App::Search'); |
|---|
| 17 | |
|---|
| 18 | my ( $count, $out ) = $app->check_cache(); |
|---|
| 19 | if ( defined $out ) { |
|---|
| 20 | $app->run_callbacks( 'search_cache_hit', $count, $out ); |
|---|
| 21 | return $out; |
|---|
| 22 | } |
|---|
| 23 | |
|---|
| 24 | my ( $terms, $args ) = &search_terms( $app ); |
|---|
| 25 | return $app->error($app->errstr) if $app->errstr; |
|---|
| 26 | |
|---|
| 27 | $count = 0; |
|---|
| 28 | my $iter; |
|---|
| 29 | if ( $terms && $args ) { |
|---|
| 30 | ( $count, $iter ) = $app->execute( $terms, $args ); |
|---|
| 31 | return $app->error($app->errstr) unless $iter; |
|---|
| 32 | |
|---|
| 33 | $app->run_callbacks( 'search_post_execute', $app, \$count, \$iter ); |
|---|
| 34 | } |
|---|
| 35 | |
|---|
| 36 | my $format = $app->param('format') || q(); |
|---|
| 37 | my $method = "render"; |
|---|
| 38 | $method .= $format if $format && $app->can($method . $format); |
|---|
| 39 | $out = $app->$method( $count, $iter ); |
|---|
| 40 | |
|---|
| 41 | my $result; |
|---|
| 42 | if (ref($out) && ($out->isa('MT::Template'))) { |
|---|
| 43 | defined( $result = $app->build_page($out) ) |
|---|
| 44 | or return $app->error($out->errstr); |
|---|
| 45 | } |
|---|
| 46 | else { |
|---|
| 47 | $result = $out; |
|---|
| 48 | } |
|---|
| 49 | |
|---|
| 50 | $app->run_callbacks( 'search_post_render', $app, $count, $result ); |
|---|
| 51 | $result; |
|---|
| 52 | } |
|---|
| 53 | |
|---|
| 54 | sub _process_lucene_query { |
|---|
| 55 | my ($search_string) = @_; |
|---|
| 56 | |
|---|
| 57 | require Lucene::QueryParser; |
|---|
| 58 | my $lucene_struct = Lucene::QueryParser::parse_query( $search_string ); |
|---|
| 59 | |
|---|
| 60 | my @or_tags; |
|---|
| 61 | for my $term ( @$lucene_struct ) { |
|---|
| 62 | next if exists $term->{field}; |
|---|
| 63 | next if 'SUBQUERY' eq $term->{query}; |
|---|
| 64 | |
|---|
| 65 | if ( ( 'TERM' eq $term->{query} ) || ( 'PHRASE' eq $term->{query} ) ){ |
|---|
| 66 | if ( 'OR' eq $term->{conj} ) { |
|---|
| 67 | if ( my $prev = pop @or_tags ) { |
|---|
| 68 | push @or_tags, $prev . ',' . $term->{term}; |
|---|
| 69 | next; |
|---|
| 70 | } |
|---|
| 71 | } |
|---|
| 72 | push @or_tags, $term->{term}; |
|---|
| 73 | } |
|---|
| 74 | } |
|---|
| 75 | @or_tags; |
|---|
| 76 | } |
|---|
| 77 | |
|---|
| 78 | sub add_join { |
|---|
| 79 | my ( $class, $terms, $def_args, $depth, $tag_ids ) = @_; |
|---|
| 80 | |
|---|
| 81 | my $alias = $class->datasource . '_' . $depth; |
|---|
| 82 | my $j_term = "= $alias.objecttag_object_id"; |
|---|
| 83 | $depth++; |
|---|
| 84 | my $this_alias = $class->datasource . '_' . $depth; |
|---|
| 85 | my %args; |
|---|
| 86 | my $tag_id = shift @$tag_ids; |
|---|
| 87 | if ( $tag_id ) { |
|---|
| 88 | my $j_arg = &add_join( $class, $terms, $def_args, $depth, $tag_ids ); |
|---|
| 89 | $args{'join'} = $j_arg if $j_arg && @$j_arg; |
|---|
| 90 | } |
|---|
| 91 | if ( $tag_id ) { |
|---|
| 92 | return $class->join_on( undef, |
|---|
| 93 | { |
|---|
| 94 | object_id => \$j_term, |
|---|
| 95 | tag_id => $tag_id, |
|---|
| 96 | %$terms |
|---|
| 97 | }, |
|---|
| 98 | { |
|---|
| 99 | %args, |
|---|
| 100 | %$def_args, |
|---|
| 101 | 'alias' => $this_alias |
|---|
| 102 | } |
|---|
| 103 | ); |
|---|
| 104 | } |
|---|
| 105 | undef; |
|---|
| 106 | } |
|---|
| 107 | |
|---|
| 108 | sub search_terms { |
|---|
| 109 | my $app = shift; |
|---|
| 110 | my $q = $app->param; |
|---|
| 111 | |
|---|
| 112 | my $offset = $q->param('startIndex') || $q->param('offset') || 0; |
|---|
| 113 | my $limit = $q->param('count') || $q->param('limit'); |
|---|
| 114 | my $max = $app->{searchparam}{SearchMaxResults}; |
|---|
| 115 | $limit = $max if !$limit || ( $limit - $offset > $max ); |
|---|
| 116 | |
|---|
| 117 | my $tag_class = $app->model('tag'); |
|---|
| 118 | my $search_string = $q->param('tag') || $q->param('search'); |
|---|
| 119 | $app->{search_string} = $search_string; |
|---|
| 120 | |
|---|
| 121 | my @or_tag_names; |
|---|
| 122 | if ( ( $search_string =~ /\s+OR\s+/ ) |
|---|
| 123 | || ( $search_string =~ /\s+AND\s+/ ) |
|---|
| 124 | || ( $search_string =~ /\s*"\s*/ ) ) { |
|---|
| 125 | @or_tag_names = &_process_lucene_query( $search_string ); |
|---|
| 126 | } |
|---|
| 127 | else { |
|---|
| 128 | my $counter = sub { |
|---|
| 129 | my $tmp = $_[0]; |
|---|
| 130 | my $count = $tmp =~ s/,/,/g; |
|---|
| 131 | $count; |
|---|
| 132 | }; |
|---|
| 133 | |
|---|
| 134 | # plus to split intersection, comma to split addition |
|---|
| 135 | @or_tag_names = split /\+\s*/, $search_string; |
|---|
| 136 | # sort by the number of tags in each section (smallest first) |
|---|
| 137 | @or_tag_names = sort { $counter->($a) cmp $counter->($b) } @or_tag_names; |
|---|
| 138 | } |
|---|
| 139 | |
|---|
| 140 | my @or_tags; |
|---|
| 141 | my $terms = { |
|---|
| 142 | $app->config->SearchPrivateTags ? () : ( is_private => '0' ) |
|---|
| 143 | }; |
|---|
| 144 | foreach my $or_tag_name ( @or_tag_names ) { |
|---|
| 145 | my %tags = map { $_ => 1, $tag_class->normalize($_) => 1 } split(/,/, $or_tag_name); |
|---|
| 146 | $terms->{name} = [ keys %tags ]; |
|---|
| 147 | my @tags = $tag_class->load($terms); |
|---|
| 148 | my @tmp; |
|---|
| 149 | foreach my $tag (@tags) { |
|---|
| 150 | push @tmp, $tag->id; |
|---|
| 151 | my @more = $tag_class->load({ n8d_id => $tag->n8d_id ? $tag->n8d_id : $tag->id }); |
|---|
| 152 | push @tmp, $_->id foreach @more; |
|---|
| 153 | } |
|---|
| 154 | push @or_tags, \@tmp if @tmp; |
|---|
| 155 | } |
|---|
| 156 | return ( undef, undef ) unless @or_tags; |
|---|
| 157 | |
|---|
| 158 | my $ot_class = $app->model('objecttag'); |
|---|
| 159 | my $class = $app->model( $app->{searchparam}{Type} ) |
|---|
| 160 | or return $app->error($app->errstr); |
|---|
| 161 | |
|---|
| 162 | my $params = $app->registry( 'default', 'types', $app->{searchparam}{Type} ); |
|---|
| 163 | my %terms = exists( $params->{terms} ) |
|---|
| 164 | ? %{ $params->{terms} } |
|---|
| 165 | : (); |
|---|
| 166 | delete $terms{'plugin'}; #FIXME: why is this in here? |
|---|
| 167 | |
|---|
| 168 | if ( exists $app->{searchparam}{IncludeBlogs} ) { |
|---|
| 169 | $terms{blog_id} = [ keys %{ $app->{searchparam}{IncludeBlogs} } ]; |
|---|
| 170 | } |
|---|
| 171 | |
|---|
| 172 | my $depth = 1; |
|---|
| 173 | my $alias = $ot_class->datasource . '_' . $depth; |
|---|
| 174 | |
|---|
| 175 | my $or_tag = shift @or_tags; |
|---|
| 176 | my $join_on_arg; |
|---|
| 177 | if ( @or_tags ) { |
|---|
| 178 | $join_on_arg = &add_join( |
|---|
| 179 | $ot_class, |
|---|
| 180 | { object_datasource => $class->datasource }, |
|---|
| 181 | { }, |
|---|
| 182 | $depth, |
|---|
| 183 | \@or_tags |
|---|
| 184 | ); |
|---|
| 185 | } |
|---|
| 186 | #TODO: what if pk is from multiple cols? |
|---|
| 187 | my $pk = $class->datasource . '_' . $class->properties->{primary_key}; |
|---|
| 188 | my $join_on = $ot_class->join_on( undef, |
|---|
| 189 | { tag_id => $or_tag, |
|---|
| 190 | object_datasource => $class->datasource, |
|---|
| 191 | object_id => \"= $pk" |
|---|
| 192 | }, |
|---|
| 193 | { alias => $alias, |
|---|
| 194 | $join_on_arg && @$join_on_arg ? ( 'join' => $join_on_arg ) : () |
|---|
| 195 | } |
|---|
| 196 | ); |
|---|
| 197 | |
|---|
| 198 | my $desc = 'descend' eq $app->{searchparam}{SearchResultDisplay} ? 'DESC' : 'ASC'; |
|---|
| 199 | my @sort; |
|---|
| 200 | my $sort = $params->{'sort'}; |
|---|
| 201 | if ( $sort !~ /\w+\!$/ && $app->{searchparam}{SearchSortBy} ) { |
|---|
| 202 | my $sort_by = $app->{searchparam}{SearchSortBy}; |
|---|
| 203 | $sort_by =~ s/[^\w\-\.\,]+//g; |
|---|
| 204 | if ( $sort_by ) { |
|---|
| 205 | my @sort_bys = split ',', $sort_by; |
|---|
| 206 | foreach my $key ( @sort_bys ) { |
|---|
| 207 | push @sort, { |
|---|
| 208 | desc => $desc, |
|---|
| 209 | column => $key |
|---|
| 210 | }; |
|---|
| 211 | } |
|---|
| 212 | } |
|---|
| 213 | } |
|---|
| 214 | push @sort, { |
|---|
| 215 | desc => $desc, |
|---|
| 216 | column => $sort |
|---|
| 217 | }; |
|---|
| 218 | |
|---|
| 219 | my %args = ( |
|---|
| 220 | 'join' => $join_on, |
|---|
| 221 | $limit ? ( 'limit' => $limit ) : (), |
|---|
| 222 | $offset ? ( 'offset' => $offset ) : (), |
|---|
| 223 | @sort ? ( 'sort' => \@sort ) : (), |
|---|
| 224 | ); |
|---|
| 225 | |
|---|
| 226 | my $blog_class = $app->model('blog'); |
|---|
| 227 | # Override SearchCutoff if If-Modified-Since header is present |
|---|
| 228 | if ((my $mod_since = $app->get_header('If-Modified-Since')) && $app->param('Template') eq 'feed') { |
|---|
| 229 | my $tz_offset = 15; # Start with maximum possible offset to UTC |
|---|
| 230 | my $blog_selected; |
|---|
| 231 | my $iter; |
|---|
| 232 | if ($app->{searchparam}{IncludeBlogs}) { |
|---|
| 233 | $iter = $blog_class->load_iter({ id => [ keys %{ $app->{searchparam}{IncludeBlogs} }] }); |
|---|
| 234 | } else { |
|---|
| 235 | $iter = $blog_class->load_iter; |
|---|
| 236 | } |
|---|
| 237 | while (my $blog = $iter->()) { |
|---|
| 238 | my $blog_offset = $blog->server_offset ? |
|---|
| 239 | $blog->server_offset : 0; |
|---|
| 240 | if ($blog_offset < $tz_offset) { |
|---|
| 241 | $tz_offset = $blog_offset; |
|---|
| 242 | $blog_selected = $blog; |
|---|
| 243 | } |
|---|
| 244 | } |
|---|
| 245 | $mod_since = epoch2ts($blog_selected, str2time($mod_since)); |
|---|
| 246 | if ( ( 'entry' eq $app->{searchparam}{Type} ) |
|---|
| 247 | || ( 'page' eq $app->{searchparam}{Type} ) ) { |
|---|
| 248 | $terms{authored_on} = [ $mod_since ]; |
|---|
| 249 | $args{range} = { authored_on => 1 }; |
|---|
| 250 | } |
|---|
| 251 | else { |
|---|
| 252 | $terms{created_on} = [ $mod_since ]; |
|---|
| 253 | $args{range} = { created_on => 1 }; |
|---|
| 254 | } |
|---|
| 255 | } |
|---|
| 256 | |
|---|
| 257 | ( \%terms, \%args ); |
|---|
| 258 | } |
|---|
| 259 | |
|---|
| 260 | 1; |
|---|
| 261 | __END__ |
|---|
| 262 | |
|---|
| 263 | =head1 NAME |
|---|
| 264 | |
|---|
| 265 | MT::App::Search::TagSearch |
|---|
| 266 | |
|---|
| 267 | =head1 SYNTAX |
|---|
| 268 | |
|---|
| 269 | TagSearch allows the following syntax in url-encoded "tag" parameter. |
|---|
| 270 | |
|---|
| 271 | "MovableType,TypePad,Vox" matches objects which has either MovableType, |
|---|
| 272 | TypePad, or Vox tag. |
|---|
| 273 | |
|---|
| 274 | "MovableType+TypePad+Vox" matches objects which has all three tags. |
|---|
| 275 | |
|---|
| 276 | "TypePad OR Vox AND "Movable Type"" matches objects which has |
|---|
| 277 | either TypePad or Vox tag *and* "Movable Type" tag. |
|---|
| 278 | |
|---|
| 279 | =head1 AUTHOR & COPYRIGHT |
|---|
| 280 | |
|---|
| 281 | Please see L<MT/AUTHOR & COPYRIGHT>. |
|---|
| 282 | |
|---|
| 283 | =cut |
|---|