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