]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/View.pm
view: deduplicate common code for loading search results
[public-inbox.git] / lib / PublicInbox / View.pm
1 # Copyright (C) 2014, Eric Wong <normalperson@yhbt.net> and all contributors
2 # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
3 package PublicInbox::View;
4 use strict;
5 use warnings;
6 use URI::Escape qw/uri_escape_utf8/;
7 use Date::Parse qw/str2time/;
8 use Encode qw/find_encoding/;
9 use Encode::MIME::Header;
10 use Email::MIME::ContentType qw/parse_content_type/;
11 use PublicInbox::Hval;
12 use PublicInbox::MID qw/mid_clean mid_compressed mid2path/;
13 use Digest::SHA;
14 require POSIX;
15
16 # TODO: make these constants tunable
17 use constant MAX_INLINE_QUOTED => 12; # half an 80x24 terminal
18 use constant MAX_TRUNC_LEN => 72;
19 use constant PRE_WRAP => "<pre\nstyle=\"white-space:pre-wrap\">";
20 use constant T_ANCHOR => '#u';
21
22 *ascii_html = *PublicInbox::Hval::ascii_html;
23
24 my $enc_utf8 = find_encoding('UTF-8');
25
26 # public functions:
27 sub msg_html {
28         my ($class, $mime, $full_pfx, $footer, $srch) = @_;
29         if (defined $footer) {
30                 $footer = "\n" . $footer;
31         } else {
32                 $footer = '';
33         }
34         headers_to_html_header($mime, $full_pfx, $srch) .
35                 multipart_text_as_html($mime, $full_pfx) .
36                 '</pre><hr />' . PRE_WRAP .
37                 html_footer($mime, 1, $full_pfx, $srch) . $footer .
38                 '</pre></body></html>';
39 }
40
41 sub feed_entry {
42         my ($class, $mime, $full_pfx) = @_;
43
44         PRE_WRAP . multipart_text_as_html($mime, $full_pfx) . '</pre>';
45 }
46
47 # this is already inside a <pre>
48 # state = [ time, seen = {}, first_commit, page_nr = 0 ]
49 sub index_entry {
50         my (undef, $mime, $level, $state) = @_;
51         my (undef, $seen, $first_commit) = @$state;
52         my $midx = $state->[3]++;
53         my ($prev, $next) = ($midx - 1, $midx + 1);
54         my $rv = '';
55         my $part_nr = 0;
56         my $enc_msg = enc_for($mime->header("Content-Type"));
57         my $subj = $mime->header('Subject');
58         my $header_obj = $mime->header_obj;
59
60         my $mid_raw = $header_obj->header_raw('Message-ID');
61         my $id = anchor_for($mid_raw);
62         $seen->{$id} = "#$id"; # save the anchor for later
63
64         my $mid = PublicInbox::Hval->new_msgid($mid_raw);
65         my $from = PublicInbox::Hval->new_oneline($mime->header('From'))->raw;
66         my @from = Email::Address->parse($from);
67         $from = $from[0]->name;
68         (defined($from) && length($from)) or $from = $from[0]->address;
69
70         $from = PublicInbox::Hval->new_oneline($from)->as_html;
71         $subj = PublicInbox::Hval->new_oneline($subj)->as_html;
72         my $pfx = ('  ' x $level);
73         my $root_anchor = $seen->{root_anchor};
74         my $path;
75         my $more = 'permalink';
76         if ($root_anchor) {
77                 $path = '../';
78                 $subj = "<u\nid=\"u\">$subj</u>" if $root_anchor eq $id;
79         } else {
80                 $path = '';
81         }
82
83         my $ts = $mime->header('X-PI-TS');
84         my $fmt = '%Y-%m-%d %H:%M UTC';
85         $ts = POSIX::strftime($fmt, gmtime($ts));
86
87         $rv .= "$pfx<b\nid=\"$id\">$subj</b>\n$pfx";
88         $rv .= "- by $from @ $ts - ";
89         $rv .= "<a\nid=\"s$midx\"\nhref=\"#s$next\">next</a>";
90         if ($prev >= 0) {
91                 $rv .= "/<a\nhref=\"#s$prev\">prev</a>";
92         }
93         $rv .= "\n\n";
94
95         my $irt = $header_obj->header_raw('In-Reply-To');
96         my ($anchor_idx, $anchor, $t_anchor);
97         if (defined $irt) {
98                 $anchor_idx = anchor_for($irt);
99                 $anchor = $seen->{$anchor_idx};
100                 $t_anchor = T_ANCHOR;
101         } else {
102                 $t_anchor = '';
103         }
104         my $href = $mid->as_href;
105         my $mhref = "${path}m/$href.html";
106         my $fhref = "${path}f/$href.html";
107         # scan through all parts, looking for displayable text
108         $mime->walk_parts(sub {
109                 $rv .= index_walk($_[0], $pfx, $enc_msg, $part_nr, $fhref,
110                                   \$more);
111                 $part_nr++;
112         });
113
114         $rv .= "\n$pfx<a\nhref=\"$mhref\">$more</a> ";
115         my $txt = "${path}m/$href.txt";
116         $rv .= "<a\nhref=\"$txt\">raw</a> ";
117         $rv .= html_footer($mime, 0);
118
119         if (defined $irt) {
120                 unless (defined $anchor) {
121                         my $v = PublicInbox::Hval->new_msgid($irt);
122                         $v = $v->as_href;
123                         $anchor = "${path}m/$v.html";
124                         $seen->{$anchor_idx} = $anchor;
125                 }
126                 $rv .= " <a\nhref=\"$anchor\">parent</a>";
127         }
128
129         if ($first_commit) {
130                 $rv .= " <a\nhref=\"t/$href.html$t_anchor\">thread</a>";
131         }
132
133         $rv . "\n\n";
134 }
135
136 sub thread_html {
137         my (undef, $ctx, $foot, $srch) = @_;
138         my $mid = mid_compressed($ctx->{mid});
139         my $res = $srch->get_thread($mid);
140         my $rv = '';
141         my $msgs = load_results($ctx, $res);
142         my $nr = scalar @$msgs;
143         return $rv if $nr == 0;
144         require PublicInbox::Thread;
145         my $th = PublicInbox::Thread->new(@$msgs);
146         $th->thread;
147         $th->order(*PublicInbox::Thread::sort_ts);
148         my $state = [ undef, { root_anchor => anchor_for($mid) }, undef, 0 ];
149         thread_entry(\$rv, $state, $_, 0) for $th->rootset;
150         my $final_anchor = $state->[3];
151         my $next = "<a\nid=\"s$final_anchor\">end of thread</a>\n";
152
153         $rv .= "</pre><hr />" . PRE_WRAP . $next . $foot . "</pre>";
154 }
155
156 sub subject_path_html {
157         my (undef, $ctx, $foot, $srch) = @_;
158         my $path = $ctx->{subject_path};
159         my $res = $srch->get_subject_path($path);
160         my $rv = '';
161         my $msgs = load_results($ctx, $res);
162         my $nr = scalar @$msgs;
163         return $rv if $nr == 0;
164         require PublicInbox::Thread;
165         my $th = PublicInbox::Thread->new(@$msgs);
166         $th->thread;
167         $th->order(*PublicInbox::Thread::sort_ts);
168         my $state = [ undef, { root_anchor => 'dummy' }, undef, 0 ];
169         thread_entry(\$rv, $state, $_, 0) for $th->rootset;
170         my $final_anchor = $state->[3];
171         my $next = "<a\nid=\"s$final_anchor\">end of thread</a>\n";
172
173         $rv .= "</pre><hr />" . PRE_WRAP . $next . $foot . "</pre>";
174 }
175
176 # only private functions below.
177
178 sub index_walk {
179         my ($part, $pfx, $enc_msg, $part_nr, $fhref, $more) = @_;
180         my $rv = '';
181         return $rv if $part->subparts; # walk_parts already recurses
182         my $ct = $part->content_type;
183
184         # account for filter bugs...
185         return if defined $ct && $ct =~ m!\btext/[xh]+tml\b!i;
186
187         my $enc = enc_for($ct, $enc_msg);
188
189         if ($part_nr > 0) {
190                 my $fn = $part->filename;
191                 defined($fn) or $fn = "part #" . ($part_nr + 1);
192                 $rv .= $pfx . add_filename_line($enc->decode($fn));
193         }
194
195         my $s = add_text_body_short($enc, $part, $part_nr, $fhref);
196
197         # drop the remainder of git patches, they're usually better
198         # to review when the full message is viewed
199         $s =~ s!^---+\n.*\z!!ms and $$more = 'more...';
200
201         # Drop signatures
202         $s =~ s/^-- \n.*\z//ms and $$more = 'more...';
203
204         # kill any leading or trailing whitespace
205         $s =~ s/\A\s+//s;
206         $s =~ s/\s+\z//s;
207
208         if (length $s) {
209                 # add prefix:
210                 $s =~ s/^/$pfx/sgm;
211
212                 $rv .= $s . "\n";
213         }
214         $rv;
215 }
216
217 sub enc_for {
218         my ($ct, $default) = @_;
219         $default ||= $enc_utf8;
220         defined $ct or return $default;
221         my $ct_parsed = parse_content_type($ct);
222         if ($ct_parsed) {
223                 if (my $charset = $ct_parsed->{attributes}->{charset}) {
224                         my $enc = find_encoding($charset);
225                         return $enc if $enc;
226                 }
227         }
228         $default;
229 }
230
231 sub multipart_text_as_html {
232         my ($mime, $full_pfx, $srch) = @_;
233         my $rv = "";
234         my $part_nr = 0;
235         my $enc_msg = enc_for($mime->header("Content-Type"));
236
237         # scan through all parts, looking for displayable text
238         $mime->walk_parts(sub {
239                 my ($part) = @_;
240                 return if $part->subparts; # walk_parts already recurses
241                 my $ct = $part->content_type;
242
243                 # account for filter bugs...
244                 return if defined $ct && $ct =~ m!\btext/[xh]+tml\b!i;
245
246                 my $enc = enc_for($ct, $enc_msg);
247
248                 if ($part_nr > 0) {
249                         my $fn = $part->filename;
250                         defined($fn) or $fn = "part #" . ($part_nr + 1);
251                         $rv .= add_filename_line($enc->decode($fn));
252                 }
253
254                 if (defined $full_pfx) {
255                         $rv .= add_text_body_short($enc, $part, $part_nr,
256                                                 $full_pfx);
257                 } else {
258                         $rv .= add_text_body_full($enc, $part, $part_nr);
259                 }
260                 $rv .= "\n" unless $rv =~ /\n\z/s;
261                 ++$part_nr;
262         });
263         $rv;
264 }
265
266 sub add_filename_line {
267         my ($fn) = @_;
268         my $len = 72;
269         my $pad = "-";
270
271         $len -= length($fn);
272         $pad x= ($len/2) if ($len > 0);
273         "$pad " . ascii_html($fn) . " $pad\n";
274 }
275
276 my $LINK_RE = qr!\b((?:ftp|https?|nntp)://[@\w\+\&\?\.\%\;/#=-]+)!;
277
278 sub linkify {
279         # no newlines added here since it'd break the splitting we do
280         # to fold quotes
281         $_[0] =~ s!$LINK_RE!<a href="$1">$1</a>!g;
282 }
283
284 sub add_text_body_short {
285         my ($enc, $part, $part_nr, $full_pfx) = @_;
286         my $n = 0;
287         my $s = ascii_html($enc->decode($part->body));
288         linkify($s);
289         $s =~ s!^((?:(?:&gt;[^\n]*)\n)+)!
290                 my $cur = $1;
291                 my @lines = split(/\n/, $cur);
292                 if (@lines > MAX_INLINE_QUOTED) {
293                         # show a short snippet of quoted text
294                         $cur = join(' ', @lines);
295                         $cur =~ s/^&gt;\s*//;
296
297                         my @sum = split(/\s+/, $cur);
298                         $cur = '';
299                         do {
300                                 my $tmp = shift(@sum);
301                                 my $len = length($tmp) + length($cur);
302                                 if ($len > MAX_TRUNC_LEN) {
303                                         @sum = ();
304                                 } else {
305                                         $cur .= $tmp . ' ';
306                                 }
307                         } while (@sum && length($cur) < MAX_TRUNC_LEN);
308                         $cur =~ s/ \z/ .../;
309                         "&gt; &lt;<a\nhref=\"${full_pfx}#q${part_nr}_" . $n++ .
310                                 "\">$cur<\/a>&gt;\n";
311                 } else {
312                         $cur;
313                 }
314         !emg;
315         $s;
316 }
317
318 sub add_text_body_full {
319         my ($enc, $part, $part_nr) = @_;
320         my $n = 0;
321         my $s = ascii_html($enc->decode($part->body));
322         linkify($s);
323         $s =~ s!^((?:(?:&gt;[^\n]*)\n)+)!
324                 my $cur = $1;
325                 my @lines = split(/\n/, $cur);
326                 if (@lines > MAX_INLINE_QUOTED) {
327                         "<a\nid=q${part_nr}_" . $n++ . ">$cur</a>";
328                 } else {
329                         $cur;
330                 }
331         !emg;
332         $s;
333 }
334
335 sub headers_to_html_header {
336         my ($mime, $full_pfx, $srch) = @_;
337
338         my $rv = "";
339         my @title;
340         foreach my $h (qw(From To Cc Subject Date)) {
341                 my $v = $mime->header($h);
342                 defined($v) && length($v) or next;
343                 $v = PublicInbox::Hval->new_oneline($v);
344
345                 if ($h eq 'From') {
346                         my @from = Email::Address->parse($v->raw);
347                         $title[1] = ascii_html($from[0]->name);
348                 } elsif ($h eq 'Subject') {
349                         $title[0] = $v->as_html;
350                         if ($srch) {
351                                 my $path = $srch->subject_path($v->raw);
352                                 $rv .= "$h: <a\nhref=\"../s/$path.html\">";
353                                 $rv .= $v->as_html . "</a>\n";
354                                 next;
355                         }
356                 }
357                 $rv .= "$h: " . $v->as_html . "\n";
358
359         }
360
361         my $header_obj = $mime->header_obj;
362         my $mid = $header_obj->header_raw('Message-ID');
363         $mid = PublicInbox::Hval->new_msgid($mid);
364         $rv .= 'Message-ID: &lt;' . $mid->as_html . '&gt; ';
365         my $href = $mid->as_href;
366         $href = "../m/$href" unless $full_pfx;
367         $rv .= "(<a\nhref=\"$href.txt\">raw</a>)\n";
368
369         my $irt = $header_obj->header_raw('In-Reply-To');
370         if (defined $irt) {
371                 my $v = PublicInbox::Hval->new_msgid($irt);
372                 my $html = $v->as_html;
373                 my $href = $v->as_href;
374                 $rv .= "In-Reply-To: &lt;";
375                 $rv .= "<a\nhref=\"$href.html\">$html</a>&gt;\n";
376         }
377
378         my $refs = $header_obj->header_raw('References');
379         if ($refs) {
380                 $refs =~ s/\s*\Q$irt\E\s*// if (defined $irt);
381                 my @refs = ($refs =~ /<([^>]+)>/g);
382                 if (@refs) {
383                         $rv .= 'References: '. linkify_refs(@refs) . "\n";
384                 }
385         }
386
387         $rv .= "\n";
388
389         ("<html><head><title>".  join(' - ', @title) .
390          '</title></head><body>' . PRE_WRAP . $rv);
391 }
392
393 sub html_footer {
394         my ($mime, $standalone, $full_pfx, $srch) = @_;
395         my %cc; # everyone else
396         my $to; # this is the From address
397
398         foreach my $h (qw(From To Cc)) {
399                 my $v = $mime->header($h);
400                 defined($v) && length($v) or next;
401                 my @addrs = Email::Address->parse($v);
402                 foreach my $recip (@addrs) {
403                         my $address = $recip->address;
404                         my $dst = lc($address);
405                         $cc{$dst} ||= $address;
406                         $to ||= $dst;
407                 }
408         }
409         Email::Address->purge_cache if $standalone;
410
411         my $subj = $mime->header('Subject') || '';
412         $subj = "Re: $subj" unless $subj =~ /\bRe:/;
413         my $mid = $mime->header_obj->header_raw('Message-ID');
414         my $irt = uri_escape_utf8($mid);
415         delete $cc{$to};
416         $to = uri_escape_utf8($to);
417         $subj = uri_escape_utf8($subj);
418
419         my $cc = uri_escape_utf8(join(',', sort values %cc));
420         my $href = "mailto:$to?In-Reply-To=$irt&Cc=${cc}&Subject=$subj";
421
422         my $idx = $standalone ? " <a\nhref=\"../\">index</a>" : '';
423         if ($idx && $srch) {
424                 $irt = $mime->header_obj->header_raw('In-Reply-To') || '';
425                 $mid = mid_compressed(mid_clean($mid));
426                 my $t_anchor = length $irt ? T_ANCHOR : '';
427                 $idx = " <a\nhref=\"../t/$mid.html$t_anchor\">thread</a>$idx";
428                 my $res = $srch->get_replies($mid);
429                 if (my $c = $res->{count}) {
430                         $c = $c == 1 ? '1 reply' : "$c replies";
431                         $idx .= "\n$c:\n";
432                         thread_replies(\$idx, $mime, $res);
433                 } else {
434                         $idx .= "\n(no replies yet)\n";
435                 }
436                 if ($irt) {
437                         $irt = PublicInbox::Hval->new_msgid($irt);
438                         $irt = $irt->as_href;
439                         $irt = "<a\nhref=\"$irt\">parent</a> ";
440                 } else {
441                         $irt = ' ' x length('parent ');
442                 }
443         } else {
444                 $irt = '';
445         }
446
447         "$irt<a\nhref=\"" . ascii_html($href) . '">reply</a>' . $idx;
448 }
449
450 sub linkify_refs {
451         join(' ', map {
452                 my $v = PublicInbox::Hval->new_msgid($_);
453                 my $html = $v->as_html;
454                 my $href = $v->as_href;
455                 "&lt;<a\nhref=\"$href.html\">$html</a>&gt;";
456         } @_);
457 }
458
459 sub anchor_for {
460         my ($msgid) = @_;
461         'm' . mid_compressed(mid_clean($msgid));
462 }
463
464 sub simple_dump {
465         my ($dst, $root, $node, $level) = @_;
466         my $pfx = '  ' x $level;
467         $$dst .= $pfx;
468         if (my $x = $node->message) {
469                 my $mid = $x->header('Message-ID');
470                 if ($root->[0] ne $mid) {
471                         my $s = $x->header('Subject');
472                         my $h = hash_subj($s);
473                         if ($root->[1]->{$h}) {
474                                 $s = '';
475                         } else {
476                                 $root->[1]->{$h} = 1;
477                                 $s = PublicInbox::Hval->new($s);
478                                 $s = $s->as_html;
479                         }
480                         my $m = PublicInbox::Hval->new_msgid($mid);
481                         my $f = PublicInbox::Hval->new($x->header('X-PI-From'));
482                         my $d = PublicInbox::Hval->new($x->header('X-PI-Date'));
483                         $m = $m->as_href . '.html';
484                         $f = $f->as_html;
485                         $d = $d->as_html . ' UTC';
486                         if (length($s) == 0) {
487                                 $$dst .= "` <a\nhref=\"$m\">$f @ $d</a>\n";
488                         } else {
489                                 $$dst .= "` <a\nhref=\"$m\">$s</a>\n" .
490                                      "$pfx  by $f @ $d\n";
491                         }
492                 }
493         }
494         simple_dump($dst, $root, $node->child, $level + 1) if $node->child;
495         simple_dump($dst, $root, $node->next, $level) if $node->next;
496 }
497
498 sub hash_subj {
499         my ($subj) = @_;
500         $subj =~ s/\A\s+//;
501         $subj =~ s/\s+\z//;
502         $subj =~ s/^(?:re|aw):\s*//i; # remove reply prefix (aw: German)
503         $subj =~ s/\s+/ /;
504         Digest::SHA::sha1($subj);
505 }
506
507 sub thread_replies {
508         my ($dst, $root, $res) = @_;
509         my @msgs = map { $_->mini_mime } @{$res->{msgs}};
510         foreach (@{$res->{msgs}}) {
511                 print STDERR "smsg->path: <", $_->path, ">\n";
512         }
513         require PublicInbox::Thread;
514         $root->header_set('X-PI-TS', '0');
515         my $th = PublicInbox::Thread->new($root, @msgs);
516         $th->thread;
517         $th->order(*PublicInbox::Thread::sort_ts);
518         $root = [ $root->header('Message-ID'),
519                   { hash_subj($root->header('Subject')) => 1 } ];
520         simple_dump($dst, $root, $_, 0) for $th->rootset;
521 }
522
523 sub thread_html_head {
524         my ($mime) = @_;
525         my $s = PublicInbox::Hval->new_oneline($mime->header('Subject'));
526         $s = $s->as_html;
527         "<html><head><title>$s</title></head><body>" . PRE_WRAP
528
529 }
530
531 sub thread_entry {
532         my ($dst, $state, $node, $level) = @_;
533         # $state = [ $search_res, $seen, undef, 0 (msg_nr) ];
534         # $seen is overloaded with 3 types of fields:
535         #       1) "root_anchor" => anchor_for(Message-ID),
536         #       2) seen subject hashes: sha1(subject) => 1
537         #       3) anchors hashes: "#$sha1_hex" (same as $seen in index_entry)
538         if (my $mime = $node->message) {
539                 if (length($$dst) == 0) {
540                         $$dst .= thread_html_head($mime);
541                 }
542                 $$dst .= index_entry(undef, $mime, $level, $state);
543         }
544         thread_entry($dst, $state, $node->child, $level + 1) if $node->child;
545         thread_entry($dst, $state, $node->next, $level) if $node->next;
546 }
547
548 sub load_results {
549         my ($ctx, $res) = @_;
550
551         require PublicInbox::GitCatFile;
552         my $git = PublicInbox::GitCatFile->new($ctx->{git_dir});
553         my @msgs;
554         while (my $smsg = shift @{$res->{msgs}}) {
555                 my $m = $smsg->mid;
556                 my $path = mid2path($m);
557
558                 # FIXME: duplicated code from Feed.pm
559                 my $mime = eval {
560                         my $str = $git->cat_file("HEAD:$path");
561                         Email::MIME->new($str);
562                 };
563                 unless ($@) {
564                         my $t = eval { str2time($mime->header('Date')) };
565                         defined($t) or $t = 0;
566                         $mime->header_set('X-PI-TS', $t);
567                         push @msgs, $mime;
568                 }
569         }
570         \@msgs;
571 }
572
573 1;