| 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 | | |
|---|
| 162 | | sub 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 | | |
|---|
| 177 | | sub 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; |
|---|
| | 119 | \%blog_list; |
|---|
| | 120 | } |
|---|
| | 121 | |
|---|
| | 122 | sub process { |
|---|
| | 123 | my $app = shift; |
|---|
| | 124 | |
|---|
| | 125 | my ( $terms, $args ) = $app->search_terms(); |
|---|
| | 126 | return $app->error($app->errstr) if $app->errstr; |
|---|
| | 127 | |
|---|
| | 128 | my $count = 0; |
|---|
| | 129 | my $iter; |
|---|
| | 130 | if ( $terms && @$terms ) { |
|---|
| | 131 | ( $count, $iter ) = $app->execute( $terms, $args ); |
|---|
| | 132 | return $app->error($app->errstr) unless $iter; |
|---|
| | 133 | |
|---|
| | 134 | $iter = $app->post_search( $count, $iter ); |
|---|
| | 135 | } |
|---|
| | 136 | |
|---|
| | 137 | return $app->render( $count, $iter ); |
|---|
| | 138 | } |
|---|
| | 139 | |
|---|
| | 140 | sub count { |
|---|
| | 141 | my $app = shift; |
|---|
| | 142 | my ( $class, $terms, $args ) = @_; |
|---|
| | 143 | #TODO: cache! |
|---|
| | 144 | $class->count( $terms, $args ); |
|---|
| | 145 | } |
|---|
| | 146 | |
|---|
| | 147 | sub execute { |
|---|
| | 148 | my $app = shift; |
|---|
| | 149 | my ( $terms, $args ) = @_; |
|---|
| | 150 | |
|---|
| | 151 | my $class = $app->model( $app->mode eq 'search' ? 'entry' : $app->mode ); |
|---|
| | 152 | my $count = $app->count( $class, $terms, $args ); |
|---|
| | 153 | #TODO: cache! |
|---|
| | 154 | my $iter = $class->load_iter( $terms, $args ) |
|---|
| | 155 | or $app->error($class->errstr); |
|---|
| | 156 | ( $count, $iter ); |
|---|
| | 157 | } |
|---|
| | 158 | |
|---|
| | 159 | sub search_terms { |
|---|
| | 160 | my $app = shift; |
|---|
| | 161 | my $q = $app->param; |
|---|
| | 162 | |
|---|
| | 163 | my $search_string = $q->param('searchTerms') || $q->param('search') |
|---|
| | 164 | or return ( undef, undef ); |
|---|
| | 165 | $app->{search_string} = $search_string; |
|---|
| | 166 | my $offset = $q->param('startIndex') || $q->param('offset') || 0; |
|---|
| | 167 | my $limit = $q->param('count') || $q->param('limit'); |
|---|
| | 168 | my $max = $app->{searchparam}{MaxResults}; |
|---|
| | 169 | $limit = $max if !$limit || ( $limit - $offset > $max ); |
|---|
| | 170 | |
|---|
| | 171 | my $type = $q->param('type'); |
|---|
| | 172 | my $entry_type = $app->mode eq 'search' |
|---|
| | 173 | ? 1 |
|---|
| | 174 | : $type |
|---|
| | 175 | ? ( 'entry' eq lc($type) || 'page' eq lc($type) |
|---|
| | 176 | ? 1 |
|---|
| | 177 | : 0 ) |
|---|
| | 178 | : 0; |
|---|
| | 179 | |
|---|
| | 180 | my $def_terms = { |
|---|
| | 181 | $entry_type ? ( status => MT::Entry::RELEASE() ) : (), |
|---|
| | 182 | $type ? ( class => $type ) : (), |
|---|
| | 183 | exists( $app->{searchparam}{IncludeBlogs} ) |
|---|
| | 184 | ? ( blog_id => [ keys %{ $app->{searchparam}{IncludeBlogs} } ] ) |
|---|
| | 185 | : (), |
|---|
| | 186 | }; |
|---|
| | 187 | my @terms; |
|---|
| | 188 | push @terms, $def_terms if %$def_terms; |
|---|
| | 189 | |
|---|
| | 190 | my $columns = $app->mode eq 'search' |
|---|
| | 191 | ? [ [ qw( title keywords text text_more ) ] ] |
|---|
| | 192 | : $app->registry( $app->mode, 'columns' ); |
|---|
| | 193 | $columns = $columns->[0]; # FIXME: Why? |
|---|
| | 194 | return $app->errtrans('No columns to search for was specified for [_1]', $app->mode) |
|---|
| | 195 | unless $columns && @$columns; |
|---|
| | 196 | my $number = scalar @$columns; |
|---|
| | 197 | my @and; |
|---|
| | 198 | for ( my $i = 0; $i < $number; $i++) { |
|---|
| | 199 | push @and, { $columns->[$i] => { like => '%'.$search_string.'%' } }; |
|---|
| | 200 | unless ( $i == $number - 1 ) { |
|---|
| | 201 | push @and, '-or'; |
|---|
| | 202 | } |
|---|
| | 203 | } |
|---|
| | 204 | push @terms, '-and' if @terms; |
|---|
| | 205 | push @terms, \@and; |
|---|
| | 206 | |
|---|
| | 207 | my %args = ( |
|---|
| | 208 | $limit ? ( 'limit' => $limit ) : (), |
|---|
| | 209 | $offset ? ( 'offset' => $offset ) : (), |
|---|
| | 210 | 'sort' => [ |
|---|
| | 211 | { desc => 'descend' eq $app->{searchparam}{ResultDisplay} ? 'DESC' : 'ASC', |
|---|
| | 212 | column => $entry_type ? 'authored_on' : 'created_on' } |
|---|
| | 213 | ] |
|---|
| | 214 | ); |
|---|
| | 215 | |
|---|
| | 216 | if ( exists $app->{searchparam}{IncludeBlogs} ) { |
|---|
| | 217 | unshift @{ $args{'sort'} }, |
|---|
| | 218 | { desc => 'ASC', |
|---|
| | 219 | column => 'blog_id' }; |
|---|
| | 220 | } |
|---|
| | 221 | |
|---|
| | 222 | ( \@terms, \%args ); |
|---|
| | 223 | } |
|---|
| | 224 | |
|---|
| | 225 | sub post_search { |
|---|
| | 226 | my $app = shift; |
|---|
| | 227 | my ( $count, $iter ) = @_; |
|---|
| | 228 | # TODO: cache here? |
|---|
| | 229 | $iter; |
|---|
| | 230 | } |
|---|
| | 231 | |
|---|
| | 232 | sub template_paths { |
|---|
| | 233 | my $app = shift; |
|---|
| | 234 | my @paths = $app->SUPER::template_paths; |
|---|
| | 235 | ( $app->config->SearchTemplatePath, @paths ); |
|---|
| | 236 | } |
|---|
| | 237 | |
|---|
| | 238 | sub load_search_tmpl { |
|---|
| | 239 | my $app = shift; |
|---|
| | 240 | my $q = $app->param; |
|---|
| | 241 | my ( $count, $iter ) = @_; |
|---|
| | 242 | |
|---|
| | 243 | my $blog_id; |
|---|
| | 244 | if ( $q->param('IncludeBlogs') ) { |
|---|
| | 245 | my @ids = split ',', $q->param('IncludeBlogs'); |
|---|
| | 246 | $blog_id = $ids[0]; |
|---|
| | 247 | } |
|---|
| | 248 | elsif ( exists $app->{searchparam}{IncludeBlogs} ) { |
|---|
| | 249 | $blog_id = @{ keys %{ $app->{searchparam}{IncludeBlogs} } }[0]; |
|---|
| | 250 | } |
|---|
| | 251 | |
|---|
| | 252 | my $tmpl; |
|---|
| | 253 | if ( $q->param('Template') && ( 'default' ne $q->param('Template') ) ) { |
|---|
| | 254 | # load specified template |
|---|
| | 255 | my $filename; |
|---|
| | 256 | if (my @tmpls = ($app->config->default('AltTemplate'), $app->config->AltTemplate)) { |
|---|
| | 257 | for my $tmpl (@tmpls) { |
|---|
| | 258 | next unless defined $tmpl; |
|---|
| | 259 | my ( $nickname, $file ) = split /\s+/, $tmpl; |
|---|
| | 260 | if ( $nickname eq $q->param('Template') ) { |
|---|
| | 261 | $filename = $file; |
|---|
| | 262 | last; |
|---|
| 210 | | $db{$ip} = time; |
|---|
| 211 | | undef $DB; |
|---|
| 212 | | untie %db; |
|---|
| 213 | | $app->{__have_throttle} = 1; |
|---|
| 214 | | } |
|---|
| 215 | | } |
|---|
| 216 | | |
|---|
| 217 | | 1; |
|---|
| 218 | | } |
|---|
| 219 | | |
|---|
| 220 | | sub 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 | | |
|---|
| 234 | | sub 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 | | } |
|---|
| | 265 | } |
|---|
| | 266 | return $app->errtrans("No alternate template is specified for the Template '[_1]'", $q->param('Template')) |
|---|
| | 267 | unless $filename; |
|---|
| | 268 | # template_paths method does the magic |
|---|
| | 269 | $tmpl = $app->load_tmpl( $filename ) |
|---|
| | 270 | or return $app->errtrans( "Opening local file '[_1]' failed: [_2]", $filename, "$!" ); |
|---|
| | 271 | } |
|---|
| | 272 | else { |
|---|
| | 273 | # load default template |
|---|
| | 274 | # first look for appropriate blog_id |
|---|
| | 275 | if ( $blog_id ) { |
|---|
| | 276 | # look for 'search_results' |
|---|
| | 277 | my $tmpl_class = $app->model('template'); |
|---|
| | 278 | $tmpl = $tmpl_class->load( |
|---|
| | 279 | { blog_id => $blog_id, type => 'search_results' } |
|---|
| | 280 | ); |
|---|
| | 281 | } |
|---|
| | 282 | unless ( $tmpl ) { |
|---|
| | 283 | # load template from search_template path |
|---|
| | 284 | # template_paths method does the magic |
|---|
| | 285 | $tmpl = $app->load_tmpl( $app->config->DefaultTemplate ); |
|---|
| | 286 | } |
|---|
| | 287 | } |
|---|
| | 288 | return $app->error($app->errstr) |
|---|
| | 289 | unless $tmpl; |
|---|
| 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 | | |
|---|
| 417 | | sub _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 | | |
|---|
| 515 | | sub _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 | | |
|---|
| 608 | | sub _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 | | |
|---|
| 653 | | sub _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 | | } |
|---|
| | 307 | $ctx->stash('template_id', $q->param('Template')); |
|---|
| | 308 | $ctx->stash('maxresults' , $app->{searchparam}{MaxResults}); |
|---|
| | 309 | |
|---|
| | 310 | $tmpl->context($ctx); |
|---|
| 813 | | |
|---|
| 814 | | sub _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 | | |
|---|
| 831 | | sub _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 | | |
|---|
| 849 | | package MT::App::Search::Context; |
|---|
| 850 | | |
|---|
| 851 | | use strict; |
|---|
| 852 | | use base qw( MT::Template::Context ); |
|---|
| 853 | | |
|---|
| 854 | | sub _hdlr_include_blogs { $_[0]->stash('include_blogs') || '' } |
|---|
| 855 | | sub _hdlr_search_string { $_[0]->stash('search_string') || '' } |
|---|
| 856 | | sub _hdlr_template_id { $_[0]->stash('template_id') || '' } |
|---|
| 857 | | sub _hdlr_max_results { $_[0]->stash('maxresults') || '' } |
|---|
| 858 | | |
|---|
| 859 | | sub _hdlr_result_count { |
|---|
| 860 | | my $results = $_[0]->stash('results'); |
|---|
| 861 | | $results && ref($results) eq 'ARRAY' ? scalar @$results : 0; |
|---|
| 862 | | } |
|---|
| 863 | | |
|---|
| 864 | | sub _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; |
|---|
|