]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/SearchView.pm
search: retry_reopen passes user arg to callback
[public-inbox.git] / lib / PublicInbox / SearchView.pm
1 # Copyright (C) 2015-2019 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 # display non-nested search results similar to what users expect from
92 # regular WWW search engines:
93 sub mset_summary {
94         my ($ctx, $mset, $q) = @_;
95
96         my $total = $mset->get_matches_estimated;
97         my $pad = length("$total");
98         my $pfx = ' ' x $pad;
99         my $res = \($ctx->{-html_tip});
100         my $ibx = $ctx->{-inbox};
101         my $srch = $ibx->search;
102         my $obfs_ibx = $ibx->{obfuscate} ? $ibx : undef;
103         foreach my $m ($mset->items) {
104                 my $rank = sprintf("%${pad}d", $m->get_rank + 1);
105                 my $pct = get_pct($m);
106                 my $smsg = PublicInbox::SearchMsg::from_mitem($m, $srch);
107                 unless ($smsg) {
108                         eval {
109                                 $m = "$m ".$m->get_docid . " expired\n";
110                                 $ctx->{env}->{'psgi.errors'}->print($m);
111                         };
112                         next;
113                 }
114                 my $s = ascii_html($smsg->subject);
115                 my $f = ascii_html($smsg->from_name);
116                 if ($obfs_ibx) {
117                         obfuscate_addrs($obfs_ibx, $s);
118                         obfuscate_addrs($obfs_ibx, $f);
119                 }
120                 my $date = PublicInbox::View::fmt_ts($smsg->ds);
121                 my $mid = PublicInbox::Hval->new_msgid($smsg->mid)->{href};
122                 $s = '(no subject)' if $s eq '';
123                 $$res .= qq{$rank. <b><a\nhref="$mid/">}.
124                         $s . "</a></b>\n";
125                 $$res .= "$pfx  - by $f @ $date UTC [$pct%]\n\n";
126         }
127         $$res .= search_nav_bot($mset, $q);
128         *noop;
129 }
130
131 # shorten "/full/path/to/Foo/Bar.pm" to "Foo/Bar.pm" so error
132 # messages don't reveal FS layout info in case people use non-standard
133 # installation paths
134 sub path2inc ($) {
135         my $full = $_[0];
136         if (my $short = $rmap_inc{$full}) {
137                 return $short;
138         } elsif (!scalar(keys %rmap_inc) && -e $full) {
139                 %rmap_inc = map {; "$INC{$_}" => $_ } keys %INC;
140                 # fall back to basename as last resort
141                 $rmap_inc{$full} // (split('/', $full))[-1];
142         } else {
143                 $full;
144         }
145 }
146
147 sub err_txt {
148         my ($ctx, $err) = @_;
149         my $u = $ctx->{-inbox}->base_url($ctx->{env}) . '_/text/help/';
150         $err =~ s/^\s*Exception:\s*//; # bad word to show users :P
151         $err =~ s!(\S+)!path2inc($1)!sge;
152         $err = ascii_html($err);
153         "\nBad query: <b>$err</b>\n" .
154                 qq{See <a\nhref="$u">$u</a> for help on using search};
155 }
156
157 sub search_nav_top {
158         my ($mset, $q, $ctx) = @_;
159         my $m = $q->qs_html(x => 'm', r => undef);
160         my $rv = qq{<form\naction="?$m"\nmethod="post"><pre>};
161         my $initial_q = $ctx->{-uxs_retried};
162         if (defined $initial_q) {
163                 my $rewritten = $q->{'q'};
164                 utf8::decode($initial_q);
165                 utf8::decode($rewritten);
166                 $initial_q = ascii_html($initial_q);
167                 $rewritten = ascii_html($rewritten);
168                 $rv .= " Warning: Initial query:\n <b>$initial_q</b>\n";
169                 $rv .= " returned no results, used:\n";
170                 $rv .= " <b>$rewritten</b>\n instead\n\n";
171         }
172
173         $rv .= 'Search results ordered by [';
174         if ($q->{r}) {
175                 my $d = $q->qs_html(r => 0);
176                 $rv .= qq{<a\nhref="?$d">date</a>|<b>relevance</b>};
177         } else {
178                 my $d = $q->qs_html(r => 1);
179                 $rv .= qq{<b>date</b>|<a\nhref="?$d">relevance</a>};
180         }
181
182         $rv .= ']  view[';
183
184         my $x = $q->{x};
185         if ($x eq '') {
186                 my $t = $q->qs_html(x => 't');
187                 $rv .= qq{<b>summary</b>|<a\nhref="?$t">nested</a>}
188         } elsif ($q->{x} eq 't') {
189                 my $s = $q->qs_html(x => '');
190                 $rv .= qq{<a\nhref="?$s">summary</a>|<b>nested</b>};
191         }
192         my $A = $q->qs_html(x => 'A', r => undef);
193         $rv .= qq{|<a\nhref="?$A">Atom feed</a>]};
194         $rv .= qq{\n\t\t\t\t\t\tdownload: };
195         $rv .= qq{<input\ntype=submit\nvalue="mbox.gz"/></pre></form><pre>};
196 }
197
198 sub search_nav_bot {
199         my ($mset, $q) = @_;
200         my $total = $mset->get_matches_estimated;
201         my $l = $q->{l};
202         my $rv = '</pre><hr><pre id=t>';
203         my $o = $q->{o};
204         my $off = $o < 0 ? -($o + 1) : $o;
205         my $end = $off + $mset->size;
206         my $beg = $off + 1;
207
208         if ($beg <= $end) {
209                 $rv .= "Results $beg-$end of $total";
210                 $rv .= ' (estimated)' if $end != $total;
211         } else {
212                 $rv .= "No more results, only $total";
213         }
214         my ($next, $join, $prev);
215
216         if ($o >= 0) { # sort descending
217                 my $n = $o + $l;
218                 if ($n < $total) {
219                         $next = $q->qs_html(o => $n, l => $l);
220                 }
221                 if ($o > 0) {
222                         $join = $n < $total ? '/' : '       ';
223                         my $p = $o - $l;
224                         $prev = $q->qs_html(o => ($p > 0 ? $p : 0));
225                 }
226         } else { # o < 0, sort ascending
227                 my $n = $o - $l;
228
229                 if (-$n < $total) {
230                         $next = $q->qs_html(o => $n, l => $l);
231                 }
232                 if ($o < -1) {
233                         $join = -$n < $total ? '/' : '       ';
234                         my $p = $o + $l;
235                         $prev = $q->qs_html(o => ($p < 0 ? $p : 0));
236                 }
237         }
238
239         $rv .= qq{  <a\nhref="?$next"\nrel=next>next</a>} if $next;
240         $rv .= $join if $join;
241         $rv .= qq{<a\nhref="?$prev"\nrel=prev>prev</a>} if $prev;
242
243         my $rev = $q->qs_html(o => $o < 0 ? 0 : -1);
244         $rv .= qq{ | <a\nhref="?$rev">reverse results</a></pre>};
245 }
246
247 sub sort_relevance {
248         [ sort {
249                 (eval { $b->topmost->{smsg}->{pct} } // 0) <=>
250                 (eval { $a->topmost->{smsg}->{pct} } // 0)
251         } @{$_[0]} ]
252 }
253
254 sub get_pct ($) {
255         # Capped at "99%" since "100%" takes an extra column in the
256         # thread skeleton view.  <xapian/mset.h> says the value isn't
257         # very meaningful, anyways.
258         my $n = $_[0]->get_percent;
259         $n > 99 ? 99 : $n;
260 }
261
262 sub load_msgs {
263         my ($mset) = @_;
264         [ map {
265                 my $mi = $_;
266                 my $smsg = PublicInbox::SearchMsg::from_mitem($mi);
267                 $smsg->{pct} = get_pct($mi);
268                 $smsg;
269         } ($mset->items) ]
270 }
271
272 sub mset_thread {
273         my ($ctx, $mset, $q) = @_;
274         my $msgs = $ctx->{-inbox}->search->retry_reopen(\&load_msgs, $mset);
275         my $r = $q->{r};
276         my $rootset = PublicInbox::SearchThread::thread($msgs,
277                 $r ? \&sort_relevance : \&PublicInbox::View::sort_ds,
278                 $ctx);
279         my $skel = search_nav_bot($mset, $q). "<pre>";
280         $ctx->{-upfx} = '';
281         $ctx->{anchor_idx} = 1;
282         $ctx->{cur_level} = 0;
283         $ctx->{dst} = \$skel;
284         $ctx->{mapping} = {};
285         $ctx->{searchview} = 1;
286         $ctx->{prev_attr} = '';
287         $ctx->{prev_level} = 0;
288         $ctx->{s_nr} = scalar(@$msgs).'+ results';
289
290         # reduce hash lookups in skel_dump
291         $ctx->{-obfuscate} = $ctx->{-inbox}->{obfuscate};
292         PublicInbox::View::walk_thread($rootset, $ctx,
293                 *PublicInbox::View::pre_thread);
294
295         @$msgs = reverse @$msgs if $r;
296         $ctx->{msgs} = $msgs;
297         \&mset_thread_i;
298 }
299
300 # callback for PublicInbox::WwwStream::getline
301 sub mset_thread_i {
302         my ($nr, $ctx) = @_;
303         my $msgs = $ctx->{msgs} or return;
304         while (my $smsg = pop @$msgs) {
305                 $ctx->{-inbox}->smsg_mime($smsg) or next;
306                 return PublicInbox::View::index_entry($smsg, $ctx,
307                                                         scalar @$msgs);
308         }
309         my ($skel) = delete @$ctx{qw(dst msgs)};
310         $$skel .= "\n</pre>";
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         $ctx->{items} = [ $mset->items ];
333         $ctx->{search_query} = $q; # used by WwwAtomStream::atom_header
334         $ctx->{srch} = $ctx->{-inbox}->search;
335         PublicInbox::WwwAtomStream->response($ctx, 200, \&adump_i);
336 }
337
338 # callback for PublicInbox::WwwAtomStream::getline
339 sub adump_i {
340         my ($ctx) = @_;
341         while (my $mi = shift @{$ctx->{items}}) {
342                 my $smsg = eval {
343                         PublicInbox::SearchMsg::from_mitem($mi, $ctx->{srch});
344                 } or next;
345                 $ctx->{-inbox}->smsg_mime($smsg) and return $smsg;
346         }
347 }
348
349 package PublicInbox::SearchQuery;
350 use strict;
351 use warnings;
352 use URI::Escape qw(uri_escape);
353 use PublicInbox::Hval;
354 use PublicInbox::MID qw(MID_ESC);
355
356 sub new {
357         my ($class, $qp) = @_;
358
359         my $r = $qp->{r};
360         my ($l) = (($qp->{l} || '') =~ /([0-9]+)/);
361         $l = $LIM if !$l || $l > $LIM;
362         bless {
363                 q => $qp->{'q'},
364                 x => $qp->{x} || '',
365                 o => (($qp->{o} || '0') =~ /(-?[0-9]+)/),
366                 l => $l,
367                 r => (defined $r && $r ne '0'),
368         }, $class;
369 }
370
371 sub qs_html {
372         my ($self, %over) = @_;
373
374         if (keys %over) {
375                 my $tmp = bless { %$self }, ref($self);
376                 foreach my $k (keys %over) {
377                         $tmp->{$k} = $over{$k};
378                 }
379                 $self = $tmp;
380         }
381
382         my $q = uri_escape($self->{'q'}, MID_ESC);
383         $q =~ s/%20/+/g; # improve URL readability
384         my $qs = "q=$q";
385
386         if (my $o = $self->{o}) { # ignore o == 0
387                 $qs .= "&amp;o=$o";
388         }
389         if (my $l = $self->{l}) {
390                 $qs .= "&amp;l=$l" unless $l == $LIM;
391         }
392         if (my $r = $self->{r}) {
393                 $qs .= "&amp;r";
394         }
395         if (my $x = $self->{x}) {
396                 $qs .= "&amp;x=$x" if ($x eq 't' || $x eq 'A' || $x eq 'm');
397         }
398         $qs;
399 }
400
401 1;