]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/SearchView.pm
a8b66dda9004ad6cca983bff668f9ab06b7ef181
[public-inbox.git] / lib / PublicInbox / SearchView.pm
1 # Copyright (C) 2015-2018 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 #
4 # Displays search results for the web interface
5 package PublicInbox::SearchView;
6 use strict;
7 use warnings;
8 use URI::Escape qw(uri_unescape uri_escape);
9 use PublicInbox::SearchMsg;
10 use PublicInbox::Hval qw/ascii_html obfuscate_addrs/;
11 use PublicInbox::View;
12 use PublicInbox::WwwAtomStream;
13 use PublicInbox::MID qw(MID_ESC);
14 use PublicInbox::MIME;
15 require PublicInbox::Git;
16 require PublicInbox::SearchThread;
17 our $LIM = 200;
18 my %rmap_inc;
19
20 sub noop {}
21
22 sub mbox_results {
23         my ($ctx) = @_;
24         my $q = PublicInbox::SearchQuery->new($ctx->{qp});
25         my $x = $q->{x};
26         require PublicInbox::Mbox;
27         return PublicInbox::Mbox::mbox_all($ctx, $q->{'q'}) if $x eq 'm';
28         sres_top_html($ctx);
29 }
30
31 sub sres_top_html {
32         my ($ctx) = @_;
33         my $srch = $ctx->{-inbox}->search or
34                 return PublicInbox::WWW::need($ctx, 'Search');
35         my $q = PublicInbox::SearchQuery->new($ctx->{qp});
36         my $x = $q->{x};
37         my $query = $q->{'q'};
38         my $o = $q->{o};
39         my $asc;
40         if ($o < 0) {
41                 $asc = 1;
42                 $o = -($o + 1); # so [-1] is the last element, like Perl lists
43         }
44
45         my $code = 200;
46         # double the limit for expanded views:
47         my $opts = {
48                 limit => $q->{l},
49                 offset => $o,
50                 mset => 1,
51                 relevance => $q->{r},
52                 asc => $asc,
53         };
54         my ($mset, $total, $err, $cb);
55 retry:
56         eval {
57                 $mset = $srch->query($query, $opts);
58                 $total = $mset->get_matches_estimated;
59         };
60         $err = $@;
61         ctx_prepare($q, $ctx);
62         if ($err) {
63                 $code = 400;
64                 $ctx->{-html_tip} = '<pre>'.err_txt($ctx, $err).'</pre><hr>';
65                 $cb = *noop;
66         } elsif ($total == 0) {
67                 if (defined($ctx->{-uxs_retried})) {
68                         # undo retry damage:
69                         $q->{'q'} = $ctx->{-uxs_retried};
70                 } elsif (index($q->{'q'}, '%') >= 0) {
71                         $ctx->{-uxs_retried} = $q->{'q'};
72                         $q->{'q'} = uri_unescape($q->{'q'});
73                         goto retry;
74                 }
75                 $code = 404;
76                 $ctx->{-html_tip} = "<pre>\n[No results found]</pre><hr>";
77                 $cb = *noop;
78         } else {
79                 return adump($_[0], $mset, $q, $ctx) if $x eq 'A';
80
81                 $ctx->{-html_tip} = search_nav_top($mset, $q, $ctx);
82                 if ($x eq 't') {
83                         $cb = mset_thread($ctx, $mset, $q);
84                 } else {
85                         $cb = mset_summary($ctx, $mset, $q);
86                 }
87         }
88         PublicInbox::WwwStream->response($ctx, $code, $cb);
89 }
90
91 # allow undef for individual doc loads...
92 sub load_doc_retry {
93         my ($srch, $mitem) = @_;
94
95         eval {
96                 $srch->retry_reopen(sub {
97                         PublicInbox::SearchMsg->load_doc($mitem->get_document)
98                 });
99         }
100 }
101
102 # display non-nested search results similar to what users expect from
103 # regular WWW search engines:
104 sub mset_summary {
105         my ($ctx, $mset, $q) = @_;
106
107         my $total = $mset->get_matches_estimated;
108         my $pad = length("$total");
109         my $pfx = ' ' x $pad;
110         my $res = \($ctx->{-html_tip});
111         my $ibx = $ctx->{-inbox};
112         my $srch = $ibx->search;
113         my $obfs_ibx = $ibx->{obfuscate} ? $ibx : undef;
114         foreach my $m ($mset->items) {
115                 my $rank = sprintf("%${pad}d", $m->get_rank + 1);
116                 my $pct = $m->get_percent;
117                 my $smsg = load_doc_retry($srch, $m);
118                 unless ($smsg) {
119                         eval {
120                                 $m = "$m ".$m->get_docid . " expired\n";
121                                 $ctx->{env}->{'psgi.errors'}->print($m);
122                         };
123                         next;
124                 }
125                 my $s = ascii_html($smsg->subject);
126                 my $f = ascii_html($smsg->from_name);
127                 if ($obfs_ibx) {
128                         obfuscate_addrs($obfs_ibx, $s);
129                         obfuscate_addrs($obfs_ibx, $f);
130                 }
131                 my $date = PublicInbox::View::fmt_ts($smsg->ds);
132                 my $mid = PublicInbox::Hval->new_msgid($smsg->mid)->{href};
133                 $s = '(no subject)' if $s eq '';
134                 $$res .= qq{$rank. <b><a\nhref="$mid/">}.
135                         $s . "</a></b>\n";
136                 $$res .= "$pfx  - by $f @ $date UTC [$pct%]\n\n";
137         }
138         $$res .= search_nav_bot($mset, $q);
139         *noop;
140 }
141
142 # shorten "/full/path/to/Foo/Bar.pm" to "Foo/Bar.pm" so error
143 # messages don't reveal FS layout info in case people use non-standard
144 # installation paths
145 sub path2inc ($) {
146         my $full = $_[0];
147         if (my $short = $rmap_inc{$full}) {
148                 return $short;
149         } elsif (!scalar(keys %rmap_inc) && -e $full) {
150                 %rmap_inc = map {; "$INC{$_}" => $_ } keys %INC;
151                 # fall back to basename as last resort
152                 $rmap_inc{$full} // (split('/', $full))[-1];
153         } else {
154                 $full;
155         }
156 }
157
158 sub err_txt {
159         my ($ctx, $err) = @_;
160         my $u = $ctx->{-inbox}->base_url($ctx->{env}) . '_/text/help/';
161         $err =~ s/^\s*Exception:\s*//; # bad word to show users :P
162         $err =~ s!(\S+)!path2inc($1)!sge;
163         $err = ascii_html($err);
164         "\nBad query: <b>$err</b>\n" .
165                 qq{See <a\nhref="$u">$u</a> for help on using search};
166 }
167
168 sub search_nav_top {
169         my ($mset, $q, $ctx) = @_;
170         my $m = $q->qs_html(x => 'm', r => undef);
171         my $rv = qq{<form\naction="?$m"\nmethod="post"><pre>};
172         my $initial_q = $ctx->{-uxs_retried};
173         if (defined $initial_q) {
174                 my $rewritten = $q->{'q'};
175                 utf8::decode($initial_q);
176                 utf8::decode($rewritten);
177                 $initial_q = ascii_html($initial_q);
178                 $rewritten = ascii_html($rewritten);
179                 $rv .= " Warning: Initial query:\n <b>$initial_q</b>\n";
180                 $rv .= " returned no results, used:\n";
181                 $rv .= " <b>$rewritten</b>\n instead\n\n";
182         }
183
184         $rv .= 'Search results ordered by [';
185         if ($q->{r}) {
186                 my $d = $q->qs_html(r => 0);
187                 $rv .= qq{<a\nhref="?$d">date</a>|<b>relevance</b>};
188         } else {
189                 my $d = $q->qs_html(r => 1);
190                 $rv .= qq{<b>date</b>|<a\nhref="?$d">relevance</a>};
191         }
192
193         $rv .= ']  view[';
194
195         my $x = $q->{x};
196         if ($x eq '') {
197                 my $t = $q->qs_html(x => 't');
198                 $rv .= qq{<b>summary</b>|<a\nhref="?$t">nested</a>}
199         } elsif ($q->{x} eq 't') {
200                 my $s = $q->qs_html(x => '');
201                 $rv .= qq{<a\nhref="?$s">summary</a>|<b>nested</b>};
202         }
203         my $A = $q->qs_html(x => 'A', r => undef);
204         $rv .= qq{|<a\nhref="?$A">Atom feed</a>]};
205         $rv .= qq{\n\t\t\t\t\t\tdownload: };
206         $rv .= qq{<input\ntype=submit\nvalue="mbox.gz"/></pre></form><pre>};
207 }
208
209 sub search_nav_bot {
210         my ($mset, $q) = @_;
211         my $total = $mset->get_matches_estimated;
212         my $l = $q->{l};
213         my $rv = '</pre><hr><pre id=t>';
214         my $o = $q->{o};
215         my $off = $o < 0 ? -($o + 1) : $o;
216         my $end = $off + $mset->size;
217         my $beg = $off + 1;
218
219         if ($beg <= $end) {
220                 $rv .= "Results $beg-$end of $total";
221                 $rv .= ' (estimated)' if $end != $total;
222         } else {
223                 $rv .= "No more results, only $total";
224         }
225         my ($next, $join, $prev);
226
227         if ($o >= 0) { # sort descending
228                 my $n = $o + $l;
229                 if ($n < $total) {
230                         $next = $q->qs_html(o => $n, l => $l);
231                 }
232                 if ($o > 0) {
233                         $join = $n < $total ? '/' : '       ';
234                         my $p = $o - $l;
235                         $prev = $q->qs_html(o => ($p > 0 ? $p : 0));
236                 }
237         } else { # o < 0, sort ascending
238                 my $n = $o - $l;
239
240                 if (-$n < $total) {
241                         $next = $q->qs_html(o => $n, l => $l);
242                 }
243                 if ($o < -1) {
244                         $join = -$n < $total ? '/' : '       ';
245                         my $p = $o + $l;
246                         $prev = $q->qs_html(o => ($p < 0 ? $p : 0));
247                 }
248         }
249
250         $rv .= qq{  <a\nhref="?$next"\nrel=next>next</a>} if $next;
251         $rv .= $join if $join;
252         $rv .= qq{<a\nhref="?$prev"\nrel=prev>prev</a>} if $prev;
253
254         my $rev = $q->qs_html(o => $o < 0 ? 0 : -1);
255         $rv .= qq{ | <a\nhref="?$rev">reverse results</a></pre>};
256 }
257
258 sub sort_relevance {
259         my ($pct) = @_;
260         sub {
261                 [ sort { (eval { $pct->{$b->topmost->{id}} } || 0)
262                                 <=>
263                         (eval { $pct->{$a->topmost->{id}} } || 0)
264         } @{$_[0]} ] };
265 }
266
267 sub mset_thread {
268         my ($ctx, $mset, $q) = @_;
269         my %pct;
270         my $ibx = $ctx->{-inbox};
271         my $msgs = $ibx->search->retry_reopen(sub { [ map {
272                 my $i = $_;
273                 my $smsg = PublicInbox::SearchMsg->load_doc($i->get_document);
274                 $pct{$smsg->mid} = $i->get_percent;
275                 $smsg;
276         } ($mset->items) ]});
277         my $r = $q->{r};
278         my $rootset = PublicInbox::SearchThread::thread($msgs,
279                 $r ? sort_relevance(\%pct) : *PublicInbox::View::sort_ds,
280                 $ctx);
281         my $skel = search_nav_bot($mset, $q). "<pre>";
282         $ctx->{-upfx} = '';
283         $ctx->{anchor_idx} = 1;
284         $ctx->{cur_level} = 0;
285         $ctx->{dst} = \$skel;
286         $ctx->{mapping} = {};
287         $ctx->{pct} = \%pct;
288         $ctx->{prev_attr} = '';
289         $ctx->{prev_level} = 0;
290         $ctx->{s_nr} = scalar(@$msgs).'+ results';
291
292         # reduce hash lookups in skel_dump
293         $ctx->{-obfuscate} = $ctx->{-inbox}->{obfuscate};
294         PublicInbox::View::walk_thread($rootset, $ctx,
295                 *PublicInbox::View::pre_thread);
296
297         @$msgs = reverse @$msgs if $r;
298         sub {
299                 return unless $msgs;
300                 my $smsg;
301                 while (my $m = pop @$msgs) {
302                         $smsg = $ibx->smsg_mime($m) and last;
303                 }
304                 if ($smsg) {
305                         return PublicInbox::View::index_entry($smsg, $ctx,
306                                 scalar @$msgs);
307                 }
308                 $msgs = undef;
309                 $skel .= "\n</pre>";
310         };
311 }
312
313 sub ctx_prepare {
314         my ($q, $ctx) = @_;
315         my $qh = $q->{'q'};
316         utf8::decode($qh);
317         $qh = ascii_html($qh);
318         $ctx->{-q_value_html} = $qh;
319         $ctx->{-atom} = '?'.$q->qs_html(x => 'A', r => undef);
320         $ctx->{-title_html} = "$qh - search results";
321         my $extra = '';
322         $extra .= qq{<input\ntype=hidden\nname=r />} if $q->{r};
323         if (my $x = $q->{x}) {
324                 $x = ascii_html($x);
325                 $extra .= qq{<input\ntype=hidden\nname=x\nvalue="$x" />};
326         }
327         $ctx->{-extra_form_html} = $extra;
328 }
329
330 sub adump {
331         my ($cb, $mset, $q, $ctx) = @_;
332         my $ibx = $ctx->{-inbox};
333         my @items = $mset->items;
334         $ctx->{search_query} = $q;
335         my $srch = $ibx->search;
336         PublicInbox::WwwAtomStream->response($ctx, 200, sub {
337                 while (my $x = shift @items) {
338                         $x = load_doc_retry($srch, $x);
339                         $x = $ibx->smsg_mime($x) and return $x;
340                 }
341                 return undef;
342         });
343 }
344
345 package PublicInbox::SearchQuery;
346 use strict;
347 use warnings;
348 use URI::Escape qw(uri_escape);
349 use PublicInbox::Hval;
350 use PublicInbox::MID qw(MID_ESC);
351
352 sub new {
353         my ($class, $qp) = @_;
354
355         my $r = $qp->{r};
356         my ($l) = (($qp->{l} || '') =~ /([0-9]+)/);
357         $l = $LIM if !$l || $l > $LIM;
358         bless {
359                 q => $qp->{'q'},
360                 x => $qp->{x} || '',
361                 o => (($qp->{o} || '0') =~ /(-?[0-9]+)/),
362                 l => $l,
363                 r => (defined $r && $r ne '0'),
364         }, $class;
365 }
366
367 sub qs_html {
368         my ($self, %over) = @_;
369
370         if (keys %over) {
371                 my $tmp = bless { %$self }, ref($self);
372                 foreach my $k (keys %over) {
373                         $tmp->{$k} = $over{$k};
374                 }
375                 $self = $tmp;
376         }
377
378         my $q = uri_escape($self->{'q'}, MID_ESC);
379         $q =~ s/%20/+/g; # improve URL readability
380         my $qs = "q=$q";
381
382         if (my $o = $self->{o}) { # ignore o == 0
383                 $qs .= "&amp;o=$o";
384         }
385         if (my $l = $self->{l}) {
386                 $qs .= "&amp;l=$l" unless $l == $LIM;
387         }
388         if (my $r = $self->{r}) {
389                 $qs .= "&amp;r";
390         }
391         if (my $x = $self->{x}) {
392                 $qs .= "&amp;x=$x" if ($x eq 't' || $x eq 'A' || $x eq 'm');
393         }
394         $qs;
395 }
396
397 1;