]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/View.pm
use raw header for Message-ID
[public-inbox.git] / lib / PublicInbox / View.pm
1 # Copyright (C) 2014-2015 all contributors <meta@public-inbox.org>
2 # License: AGPLv3 or later (https://www.gnu.org/licenses/agpl-3.0.txt)
3 #
4 # Used for displaying the HTML web interface.
5 # See Documentation/design_www.txt for this.
6 package PublicInbox::View;
7 use strict;
8 use warnings;
9 use URI::Escape qw/uri_escape_utf8/;
10 use Date::Parse qw/str2time/;
11 use Encode qw/find_encoding/;
12 use Encode::MIME::Header;
13 use Email::MIME::ContentType qw/parse_content_type/;
14 use PublicInbox::Hval;
15 use PublicInbox::Linkify;
16 use PublicInbox::MID qw/mid_clean id_compress mid2path mid_mime/;
17 require POSIX;
18
19 # TODO: make these constants tunable
20 use constant MAX_INLINE_QUOTED => 12; # half an 80x24 terminal
21 use constant MAX_TRUNC_LEN => 72;
22 use constant T_ANCHOR => '#u';
23 use constant INDENT => '  ';
24
25 *ascii_html = *PublicInbox::Hval::ascii_html;
26
27 my $enc_utf8 = find_encoding('UTF-8');
28
29 # public functions:
30 sub msg_html {
31         my ($ctx, $mime, $full_pfx, $footer) = @_;
32         if (defined $footer) {
33                 $footer = "\n" . $footer;
34         } else {
35                 $footer = '';
36         }
37         my $hdr = $mime->header_obj;
38         headers_to_html_header($hdr, $full_pfx, $ctx) .
39                 multipart_text_as_html($mime, $full_pfx) .
40                 '</pre><hr /><pre>' .
41                 html_footer($hdr, 1, $full_pfx, $ctx) .
42                 $footer .
43                 '</pre></body></html>';
44 }
45
46 # /$LISTNAME/$MESSAGE_ID/R/
47 sub msg_reply {
48         my ($ctx, $hdr, $footer) = @_;
49         my $s = $hdr->header('Subject');
50         $s = '(no subject)' if (!defined $s) || ($s eq '');
51         my $f = $hdr->header('From');
52         $f = '' unless defined $f;
53         $s = PublicInbox::Hval->new_oneline($s);
54         my $mid = $hdr->header_raw('Message-ID');
55         $mid = PublicInbox::Hval->new_msgid($mid);
56         my $t = $s->as_html;
57         my $se_url =
58          'https://kernel.org/pub/software/scm/git/docs/git-send-email.html';
59
60         my ($arg, $link) = mailto_arg_link($hdr);
61         push @$arg, '/path/to/YOUR_REPLY';
62
63         "<html><head><title>replying to \"$t\"</title></head><body><pre>" .
64         "replying to message:\n\n" .
65         "Subject: <b>$t</b>\n" .
66         "From: ". ascii_html($f) .
67         "\nDate: " .  ascii_html($hdr->header('Date')) .
68         "\nMessage-ID: &lt;" . $mid->as_html . "&gt;\n\n" .
69         "There are multiple ways to reply:\n\n" .
70         "* Save the following mbox file, import it into your mail client,\n" .
71         "  and reply-to-all from there: <a\nhref=../raw>mbox</a>\n\n" .
72         "* Reply to all the recipients using the <b>--to</b>, <b>--cc</b>,\n" .
73         "  and <b>--in-reply-to</b> switches of git-send-email(1):\n\n" .
74         "\tgit send-email \\\n\t\t" .
75         join(" \\ \n\t\t", @$arg ). "\n\n" .
76         qq(  <a\nhref="$se_url">$se_url</a>\n\n) .
77         "* If your mail client supports setting the <b>In-Reply-To</b>" .
78         " header\n  via mailto: links, try the " .
79         qq(<a\nhref="$link">mailto: link</a>\n) .
80         "\nFor context, the original <a\nhref=../>message</a> or " .
81         qq(<a\nhref="../t/#u">thread</a>) .
82         '</pre><hr /><pre>' . $footer .  '</pre></body></html>';
83 }
84
85 sub feed_entry {
86         my ($class, $mime, $full_pfx) = @_;
87
88         # no <head> here for <style>...
89         PublicInbox::Hval::PRE .
90                 multipart_text_as_html($mime, $full_pfx) . '</pre>';
91 }
92
93 sub in_reply_to {
94         my ($hdr) = @_;
95         my $irt = $hdr->header_raw('In-Reply-To');
96
97         return mid_clean($irt) if (defined $irt);
98
99         my $refs = $hdr->header_raw('References');
100         if ($refs && $refs =~ /<([^>]+)>\s*\z/s) {
101                 return $1;
102         }
103         undef;
104 }
105
106 # this is already inside a <pre>
107 sub index_entry {
108         my ($fh, $mime, $level, $state) = @_;
109         my $midx = $state->{anchor_idx}++;
110         my $ctx = $state->{ctx};
111         my $srch = $ctx->{srch};
112         my ($prev, $next) = ($midx - 1, $midx + 1);
113         my $part_nr = 0;
114         my $hdr = $mime->header_obj;
115         my $enc = enc_for($hdr->header("Content-Type"));
116         my $subj = $hdr->header('Subject');
117
118         my $mid_raw = mid_clean(mid_mime($mime));
119         my $id = anchor_for($mid_raw);
120         my $seen = $state->{seen};
121         $seen->{$id} = "#$id"; # save the anchor for children, later
122
123         my $mid = PublicInbox::Hval->new_msgid($mid_raw);
124         my $from = PublicInbox::Hval->new_oneline($hdr->header('From'))->raw;
125         my @from = Email::Address->parse($from);
126         $from = $from[0]->name;
127
128         $from = PublicInbox::Hval->new_oneline($from)->as_html;
129         $subj = PublicInbox::Hval->new_oneline($subj)->as_html;
130         my $root_anchor = $state->{root_anchor} || '';
131         my $path = $root_anchor ? '../../' : '';
132         my $href = $mid->as_href;
133         my $irt = in_reply_to($hdr);
134         my $parent_anchor = $seen->{anchor_for($irt)} if defined $irt;
135
136         if ($srch) {
137                 my $t = $ctx->{flat} ? 'T' : 't';
138                 $subj = "<a\nhref=\"${path}$href/$t/#u\">$subj</a>";
139         }
140         if ($root_anchor eq $id) {
141                 $subj = "<u\nid=u>$subj</u>";
142         }
143
144         my $ts = _msg_date($hdr);
145         my $rv = "<pre\nid=s$midx>";
146         $rv .= "<b\nid=$id>$subj</b>\n";
147         $rv .= "- $from @ $ts UTC - ";
148         $rv .= "<a\nhref=\"#s$next\">next</a>";
149         if ($prev >= 0) {
150                 $rv .= "/<a\nhref=\"#s$prev\">prev</a>";
151         }
152         $fh->write($rv .= "\n\n");
153
154         my ($fhref, $more_ref);
155         my $mhref = "${path}$href/";
156         my $more = 'permalink';
157
158         # show full message if it's our root message
159         my $neq = $root_anchor ne $id;
160         if ($neq || ($neq && $level != 0 && !$ctx->{flat})) {
161                 $fhref = "${path}$href/f/";
162                 $more_ref = \$more;
163         }
164         # scan through all parts, looking for displayable text
165         $mime->walk_parts(sub {
166                 index_walk($fh, $_[0], $enc, \$part_nr, $fhref, $more_ref);
167         });
168         $mime->body_set('');
169
170         my $txt = "${path}$href/raw";
171         $rv = "\n<a\nhref=\"$mhref\">$more</a> <a\nhref=\"$txt\">raw</a> ";
172         $rv .= html_footer($hdr, 0, undef, $ctx, $mhref);
173
174         if (defined $irt) {
175                 unless (defined $parent_anchor) {
176                         my $v = PublicInbox::Hval->new_msgid($irt, 1);
177                         $v = $v->as_href;
178                         $parent_anchor = "${path}$v/";
179                 }
180                 $rv .= " <a\nhref=\"$parent_anchor\">parent</a>";
181         }
182         if (my $pct = $state->{pct}) { # used by SearchView.pm
183                 $rv .= " [relevance $pct->{$mid_raw}%]";
184         } elsif ($srch) {
185                 if ($ctx->{flat}) {
186                         $rv .= " [<a\nhref=\"${path}$href/t/#u\">threaded</a>" .
187                                 "|<b>flat</b>]";
188                 } else {
189                         $rv .= " [<b>threaded</b>|" .
190                                 "<a\nhref=\"${path}$href/T/#u\">flat</a>]";
191                 }
192         }
193         $fh->write($rv .= '</pre>');
194 }
195
196 sub thread_html {
197         my ($ctx, $foot, $srch) = @_;
198         sub { emit_thread_html($_[0], $ctx, $foot, $srch) }
199 }
200
201 # only private functions below.
202
203 sub emit_thread_html {
204         my ($cb, $ctx, $foot, $srch) = @_;
205         my $mid = $ctx->{mid};
206         my $res = $srch->get_thread($mid);
207         my $msgs = load_results($res);
208         my $nr = scalar @$msgs;
209         return missing_thread($cb, $ctx) if $nr == 0;
210         my $flat = $ctx->{flat};
211         my $orig_cb = $cb;
212         my $seen = {};
213         my $state = {
214                 ctx => $ctx,
215                 seen => $seen,
216                 root_anchor => anchor_for($mid),
217                 anchor_idx => 0,
218                 cur_level => 0,
219         };
220
221         require PublicInbox::Git;
222         my $git = $ctx->{git} ||= PublicInbox::Git->new($ctx->{git_dir});
223         if ($flat) {
224                 pre_anchor_entry($seen, $_) for (@$msgs);
225                 __thread_entry(\$cb, $git, $state, $_, 0) for (@$msgs);
226         } else {
227                 my $th = thread_results($msgs);
228                 thread_entry(\$cb, $git, $state, $_, 0) for $th->rootset;
229                 if (my $max = $state->{cur_level}) {
230                         $cb->write(('</ul></li>' x ($max - 1)) . '</ul>');
231                 }
232         }
233         $git = undef;
234         Email::Address->purge_cache;
235
236         # there could be a race due to a message being deleted in git
237         # but still being in the Xapian index:
238         return missing_thread($cb, $ctx) if ($orig_cb eq $cb);
239
240         my $final_anchor = $state->{anchor_idx};
241         my $next = "<a\nid=s$final_anchor>";
242         $next .= $final_anchor == 1 ? 'only message in' : 'end of';
243         $next .= " thread</a>, back to <a\nhref=\"../../\">index</a>";
244         $next .= "\ndownload thread: ";
245         $next .= "<a\nhref=\"../t.mbox.gz\">mbox.gz</a>";
246         $next .= " / follow: <a\nhref=\"../t.atom\">Atom feed</a>";
247         $cb->write('<hr /><pre>' . $next . "\n\n".
248                         $foot .  '</pre></body></html>');
249         $cb->close;
250 }
251
252 sub index_walk {
253         my ($fh, $part, $enc, $part_nr, $fhref, $more) = @_;
254         my $s = add_text_body($enc, $part, $part_nr, $fhref);
255
256         return if $s eq '';
257
258         $s .= "\n"; # ensure there's a trailing newline
259
260         $fh->write($s);
261 }
262
263 sub enc_for {
264         my ($ct, $default) = @_;
265         $default ||= $enc_utf8;
266         defined $ct or return $default;
267         my $ct_parsed = parse_content_type($ct);
268         if ($ct_parsed) {
269                 if (my $charset = $ct_parsed->{attributes}->{charset}) {
270                         my $enc = find_encoding($charset);
271                         return $enc if $enc;
272                 }
273         }
274         $default;
275 }
276
277 sub multipart_text_as_html {
278         my ($mime, $full_pfx, $srch) = @_;
279         my $rv = "";
280         my $part_nr = 0;
281         my $enc = enc_for($mime->header("Content-Type"));
282
283         # scan through all parts, looking for displayable text
284         $mime->walk_parts(sub {
285                 my ($part) = @_;
286                 $part = add_text_body($enc, $part, \$part_nr, $full_pfx, 1);
287                 $rv .= $part;
288                 $rv .= "\n" if $part ne '';
289         });
290         $mime->body_set('');
291         $rv;
292 }
293
294 sub add_filename_line {
295         my ($enc, $fn) = @_;
296         my $len = 72;
297         my $pad = "-";
298         $fn = $enc->decode($fn);
299         $len -= length($fn);
300         $pad x= ($len/2) if ($len > 0);
301         "$pad " . ascii_html($fn) . " $pad\n";
302 }
303
304 sub flush_quote {
305         my ($quot, $n, $part_nr, $full_pfx, $final, $do_anchor) = @_;
306
307         # n.b.: do not use <blockquote> since it screws up alignment
308         # w.r.t. unquoted text.  Repliers may rely on pre-formatted
309         # alignment to point out a certain word in quoted text.
310         if ($full_pfx) {
311                 if (!$final && scalar(@$quot) <= MAX_INLINE_QUOTED) {
312                         # show quote inline
313                         my $l = PublicInbox::Linkify->new;
314                         my $rv = join('', map { $l->linkify_1($_) } @$quot);
315                         @$quot = ();
316                         $rv = ascii_html($rv);
317                         return $l->linkify_2($rv);
318                 }
319
320                 # show a short snippet of quoted text and link to full version:
321                 @$quot = map { s/^(?:>\s*)+//gm; $_ } @$quot;
322                 my $cur = join(' ', @$quot);
323                 @$quot = split(/\s+/, $cur);
324                 $cur = '';
325                 do {
326                         my $tmp = shift(@$quot);
327                         my $len = length($tmp) + length($cur);
328                         if ($len > MAX_TRUNC_LEN) {
329                                 @$quot = ();
330                         } else {
331                                 $cur .= $tmp . ' ';
332                         }
333                 } while (@$quot && length($cur) < MAX_TRUNC_LEN);
334                 @$quot = ();
335                 $cur =~ s/ \z/ .../s;
336                 $cur = ascii_html($cur);
337                 my $nr = ++$$n;
338                 "&gt; [<a\nhref=\"$full_pfx#q${part_nr}_$nr\">$cur</a>]\n";
339         } else {
340                 # show everything in the full version with anchor from
341                 # short version (see above)
342                 my $l = PublicInbox::Linkify->new;
343                 my $rv .= join('', map { $l->linkify_1($_) } @$quot);
344                 @$quot = ();
345                 $rv = ascii_html($rv);
346                 return $l->linkify_2($rv) unless $do_anchor;
347                 my $nr = ++$$n;
348                 "<a\nid=q${part_nr}_$nr></a>" . $l->linkify_2($rv);
349         }
350 }
351
352 sub add_text_body {
353         my ($enc_msg, $part, $part_nr, $full_pfx, $do_anchor) = @_;
354         return '' if $part->subparts;
355
356         my $ct = $part->content_type;
357         # account for filter bugs...
358         if (defined $ct && $ct =~ m!\btext/x?html\b!i) {
359                 $part->body_set('');
360                 return '';
361         }
362         my $enc = enc_for($ct, $enc_msg);
363         my $n = 0;
364         my $nr = 0;
365         my $s = $part->body;
366         $part->body_set('');
367         $s = $enc->decode($s);
368         my @lines = split(/^/m, $s);
369         $s = '';
370
371         if ($$part_nr > 0) {
372                 my $fn = $part->filename;
373                 defined($fn) or $fn = "part #" . ($$part_nr + 1);
374                 $s .= add_filename_line($enc, $fn);
375         }
376
377         my @quot;
378         while (defined(my $cur = shift @lines)) {
379                 if ($cur !~ /^>/) {
380                         # show the previously buffered quote inline
381                         if (scalar @quot) {
382                                 $s .= flush_quote(\@quot, \$n, $$part_nr,
383                                                   $full_pfx, 0, $do_anchor);
384                         }
385
386                         # regular line, OK
387                         my $l = PublicInbox::Linkify->new;
388                         $cur = $l->linkify_1($cur);
389                         $cur = ascii_html($cur);
390                         $s .= $l->linkify_2($cur);
391                 } else {
392                         push @quot, $cur;
393                 }
394         }
395         if (scalar @quot) {
396                 $s .= flush_quote(\@quot, \$n, $$part_nr, $full_pfx, 1,
397                                   $do_anchor);
398         }
399         ++$$part_nr;
400
401         $s =~ s/[ \t]+$//sgm; # kill per-line trailing whitespace
402         $s =~ s/\A\n+//s; # kill leading blank lines
403         $s =~ s/\s+\z//s; # kill all trailing spaces (final "\n" added if ne '')
404         $s;
405 }
406
407 sub headers_to_html_header {
408         my ($hdr, $full_pfx, $ctx) = @_;
409         my $srch = $ctx->{srch} if $ctx;
410         my $rv = "";
411         my @title;
412         my $mid = $hdr->header_raw('Message-ID');
413         $mid = PublicInbox::Hval->new_msgid($mid);
414         foreach my $h (qw(From To Cc Subject Date)) {
415                 my $v = $hdr->header($h);
416                 defined($v) && ($v ne '') or next;
417                 $v = PublicInbox::Hval->new_oneline($v);
418
419                 if ($h eq 'From') {
420                         my @from = Email::Address->parse($v->raw);
421                         $title[1] = ascii_html($from[0]->name);
422                 } elsif ($h eq 'Subject') {
423                         $title[0] = $v->as_html;
424                         if ($srch) {
425                                 $rv .= "$h: <b\nid=t>";
426                                 $rv .= $v->as_html . "</b>\n";
427                                 next;
428                         }
429                 }
430                 $rv .= "$h: " . $v->as_html . "\n";
431
432         }
433         $rv .= 'Message-ID: &lt;' . $mid->as_html . '&gt; ';
434         my $upfx = $full_pfx ? '' : '../';
435         $rv .= "(<a\nhref=\"${upfx}raw\">raw</a>)\n";
436         my $atom;
437         if ($srch) {
438                 thread_inline(\$rv, $ctx, $hdr, $upfx);
439
440                 $atom = qq{<link\nrel=alternate\ntitle="Atom feed"\n} .
441                         qq!href="${upfx}t.atom"\ntype="application/atom+xml"/>!;
442         } else {
443                 $rv .= _parent_headers_nosrch($hdr);
444                 $atom = '';
445         }
446         $rv .= "\n";
447
448         ("<html><head><title>".  join(' - ', @title) . "</title>$atom".
449          PublicInbox::Hval::STYLE . "</head><body><pre>" . $rv);
450 }
451
452 sub thread_inline {
453         my ($dst, $ctx, $hdr, $upfx) = @_;
454         my $srch = $ctx->{srch};
455         my $mid = mid_clean($hdr->header_raw('Message-ID'));
456         my $res = $srch->get_thread($mid);
457         my $nr = $res->{total};
458         my $expand = "<a\nhref=\"${upfx}t/#u\">expand</a> " .
459                         "/ <a\nhref=\"${upfx}t.mbox.gz\">mbox.gz</a>";
460
461         $$dst .= 'Thread: ';
462         my $parent = in_reply_to($hdr);
463         if ($nr <= 1) {
464                 if (defined $parent) {
465                         $$dst .= "($expand)\n ";
466                         $$dst .= ghost_parent("$upfx../", $parent) . "\n";
467                 } else {
468                         $$dst .= "[no followups, yet] ($expand)\n";
469                 }
470                 $ctx->{next_msg} = undef;
471                 $ctx->{parent_msg} = $parent;
472                 return;
473         }
474
475         $$dst .= "~$nr messages ($expand";
476         if ($nr > MAX_INLINE_QUOTED) {
477                 $$dst .= qq! / <a\nhref="#b">[scroll down]</a>!;
478         }
479         $$dst .= ")\n";
480
481         my $subj = $srch->subject_path($hdr->header('Subject'));
482         my $state = {
483                 seen => { $subj => 1 },
484                 srch => $srch,
485                 cur => $mid,
486                 parent_cmp => defined $parent ? $parent : '',
487                 parent => $parent,
488                 prev_attr => '',
489                 prev_level => 0,
490         };
491         for (thread_results(load_results($res))->rootset) {
492                 inline_dump($dst, $state, $upfx, $_, 0);
493         }
494         $$dst .= "<a\nid=b></a>"; # anchor for body start
495         $ctx->{next_msg} = $state->{next_msg};
496         $ctx->{parent_msg} = $state->{parent};
497 }
498
499 sub _parent_headers_nosrch {
500         my ($hdr) = @_;
501         my $rv = '';
502
503         my $irt = in_reply_to($hdr);
504         if (defined $irt) {
505                 my $v = PublicInbox::Hval->new_msgid($irt, 1);
506                 my $html = $v->as_html;
507                 my $href = $v->as_href;
508                 $rv .= "In-Reply-To: &lt;";
509                 $rv .= "<a\nhref=\"../$href/\">$html</a>&gt;\n";
510         }
511
512         my $refs = $hdr->header_raw('References');
513         if ($refs) {
514                 # avoid redundant URLs wasting bandwidth
515                 my %seen;
516                 $seen{$irt} = 1 if defined $irt;
517                 my @refs;
518                 my @raw_refs = ($refs =~ /<([^>]+)>/g);
519                 foreach my $ref (@raw_refs) {
520                         next if $seen{$ref};
521                         $seen{$ref} = 1;
522                         push @refs, linkify_ref_nosrch($ref);
523                 }
524
525                 if (@refs) {
526                         $rv .= 'References: '. join(' ', @refs) . "\n";
527                 }
528         }
529         $rv;
530 }
531
532 sub mailto_arg_link {
533         my ($hdr) = @_;
534         my %cc; # everyone else
535         my $to; # this is the From address
536
537         foreach my $h (qw(From To Cc)) {
538                 my $v = $hdr->header($h);
539                 defined($v) && ($v ne '') or next;
540                 my @addrs = Email::Address->parse($v);
541                 foreach my $recip (@addrs) {
542                         my $address = $recip->address;
543                         my $dst = lc($address);
544                         $cc{$dst} ||= $address;
545                         $to ||= $dst;
546                 }
547         }
548         Email::Address->purge_cache;
549         my @arg;
550
551         my $subj = $hdr->header('Subject') || '';
552         $subj = "Re: $subj" unless $subj =~ /\bRe:/i;
553         my $mid = $hdr->header_raw('Message-ID');
554         push @arg, "--in-reply-to='" . ascii_html($mid) . "'";
555         my $irt = uri_escape_utf8($mid);
556         delete $cc{$to};
557         push @arg, '--to=' . ascii_html($to);
558         $to = uri_escape_utf8($to);
559         $subj = uri_escape_utf8($subj);
560         my $cc = join(',', sort values %cc);
561         push @arg, '--cc=' . ascii_html($cc);
562         $cc = uri_escape_utf8($cc);
563         my $href = "mailto:$to?In-Reply-To=$irt&Cc=${cc}&Subject=$subj";
564         $href =~ s/%20/+/g;
565
566         (\@arg, $href);
567 }
568
569 sub html_footer {
570         my ($mime, $standalone, $full_pfx, $ctx, $mhref) = @_;
571
572         my $srch = $ctx->{srch} if $ctx;
573         my $upfx = $full_pfx ? '../' : '../../';
574         my $tpfx = $full_pfx ? '' : '../';
575         my $idx = $standalone ? " <a\nhref=\"$upfx\">index</a>" : '';
576         my $irt = '';
577
578         if ($srch && $standalone) {
579                 $idx .= qq{ / follow: <a\nhref="${tpfx}t.atom">Atom feed</a>\n};
580         }
581         if ($idx && $srch) {
582                 my $p = $ctx->{parent_msg};
583                 my $next = $ctx->{next_msg};
584                 if ($p) {
585                         $p = PublicInbox::Hval->new_oneline($p);
586                         $p = $p->as_href;
587                         $irt = "<a\nhref=\"$upfx$p/\">parent</a> ";
588                 } else {
589                         $irt = ' ' x length('parent ');
590                 }
591                 if ($next) {
592                         $irt .= "<a\nhref=\"$upfx$next/\">next</a> ";
593                 } else {
594                         $irt .= ' ' x length('next ');
595                 }
596                 if ($p || $next) {
597                         $irt .= "<a\nhref=\"${tpfx}t/#u\">thread</a> ";
598                 } else {
599                         $irt .= ' ' x length('thread ');
600                 }
601         } else {
602                 $irt = '';
603         }
604
605         $mhref = './' unless defined $mhref;
606         $irt . qq(<a\nhref="${mhref}R/">reply</a>) . $idx;
607 }
608
609 sub linkify_ref_nosrch {
610         my $v = PublicInbox::Hval->new_msgid($_[0], 1);
611         my $html = $v->as_html;
612         my $href = $v->as_href;
613         "&lt;<a\nhref=\"../$href/\">$html</a>&gt;";
614 }
615
616 sub anchor_for {
617         my ($msgid) = @_;
618         my $id = $msgid;
619         if ($id !~ /\A[a-f0-9]{40}\z/) {
620                 $id = id_compress(mid_clean($id), 1);
621         }
622         'm' . $id;
623 }
624
625 sub thread_html_head {
626         my ($cb, $header, $state) = @_;
627         $$cb = $$cb->([200, ['Content-Type'=> 'text/html; charset=UTF-8']]);
628
629         my $s = PublicInbox::Hval->new_oneline($header->header('Subject'));
630         $s = $s->as_html;
631         $$cb->write("<html><head><title>$s</title>".
632                 qq{<link\nrel=alternate\ntitle="Atom feed"\n} .
633                 qq!href="../t.atom"\ntype="application/atom+xml"/>! .
634                 PublicInbox::Hval::STYLE .
635                 "</head><body>");
636 }
637
638 sub pre_anchor_entry {
639         my ($seen, $mime) = @_;
640         my $id = anchor_for(mid_mime($mime));
641         $seen->{$id} = "#$id"; # save the anchor for children, later
642 }
643
644 sub ghost_parent {
645         my ($upfx, $mid) = @_;
646         # 'subject dummy' is used internally by Mail::Thread
647         return '[no common parent]' if ($mid eq 'subject dummy');
648
649         $mid = PublicInbox::Hval->new_msgid($mid);
650         my $href = $mid->as_href;
651         my $html = $mid->as_html;
652         qq{[parent not found: &lt;<a\nhref="$upfx$href/">$html</a>&gt;]};
653 }
654
655 sub thread_adj_level {
656         my ($fh, $state, $level) = @_;
657
658         my $max = $state->{cur_level};
659         if ($level <= 0) {
660                 return '' if $max == 0; # flat output
661
662                 # reset existing lists
663                 my $x = $max > 1 ? ('</ul></li>' x ($max - 1)) : '';
664                 $fh->write($x . '</ul>');
665                 $state->{cur_level} = 0;
666                 return '';
667         }
668         if ($level == $max) { # continue existing list
669                 $fh->write('<li>');
670         } elsif ($level < $max) {
671                 my $x = $max > 1 ? ('</ul></li>' x ($max - $level)) : '';
672                 $fh->write($x .= '<li>');
673                 $state->{cur_level} = $level;
674         } else { # ($level > $max) # start a new level
675                 $state->{cur_level} = $level;
676                 $fh->write(($max ? '<li>' : '') . '<ul><li>');
677         }
678         '</li>';
679 }
680
681 sub ghost_flush {
682         my ($fh, $state, $upfx, $mid, $level) = @_;
683
684         my $end = thread_adj_level($fh, $state, $level);
685         $fh->write('<pre>'. ghost_parent($upfx, $mid) .  '</pre>' . $end);
686 }
687
688 sub __thread_entry {
689         my ($cb, $git, $state, $mime, $level) = @_;
690
691         # lazy load the full message from mini_mime:
692         $mime = eval {
693                 my $path = mid2path(mid_clean(mid_mime($mime)));
694                 Email::MIME->new($git->cat_file('HEAD:'.$path));
695         } or return;
696
697         if ($state->{anchor_idx} == 0) {
698                 thread_html_head($cb, $mime, $state, $level);
699         }
700         my $fh = $$cb;
701         if (my $ghost = delete $state->{ghost}) {
702                 # n.b. ghost messages may only be parents, not children
703                 foreach my $g (@$ghost) {
704                         ghost_flush($fh, $state, '../../', @$g);
705                 }
706         }
707         my $end = thread_adj_level($fh, $state, $level);
708         index_entry($fh, $mime, $level, $state);
709         $fh->write($end) if $end;
710
711         1;
712 }
713
714 sub indent_for {
715         my ($level) = @_;
716         INDENT x ($level - 1);
717 }
718
719 sub __ghost_prepare {
720         my ($state, $node, $level) = @_;
721         my $ghost = $state->{ghost} ||= [];
722         push @$ghost, [ $node->messageid, $level ];
723 }
724
725 sub thread_entry {
726         my ($cb, $git, $state, $node, $level) = @_;
727         return unless $node;
728         if (my $mime = $node->message) {
729                 unless (__thread_entry($cb, $git, $state, $mime, $level)) {
730                         __ghost_prepare($state, $node, $level);
731                 }
732         } else {
733                 __ghost_prepare($state, $node, $level);
734         }
735
736         thread_entry($cb, $git, $state, $node->child, $level + 1);
737         thread_entry($cb, $git, $state, $node->next, $level);
738 }
739
740 sub load_results {
741         my ($res) = @_;
742
743         [ map { $_->mini_mime } @{delete $res->{msgs}} ];
744 }
745
746 sub msg_timestamp {
747         my ($hdr) = @_;
748         my $ts = eval { str2time($hdr->header('Date')) };
749         defined($ts) ? $ts : 0;
750 }
751
752 sub thread_results {
753         my ($msgs, $nosubject) = @_;
754         require PublicInbox::Thread;
755         my $th = PublicInbox::Thread->new(@$msgs);
756         no warnings 'once';
757         $Mail::Thread::nosubject = $nosubject;
758         $th->thread;
759         $th->order(*sort_ts);
760         $th
761 }
762
763 sub missing_thread {
764         my ($cb, $ctx) = @_;
765         require PublicInbox::ExtMsg;
766
767         $cb->(PublicInbox::ExtMsg::ext_msg($ctx))
768 }
769
770 sub _msg_date {
771         my ($hdr) = @_;
772         my $ts = $hdr->header('X-PI-TS') || msg_timestamp($hdr);
773         fmt_ts($ts);
774 }
775
776 sub fmt_ts { POSIX::strftime('%Y-%m-%d %k:%M', gmtime($_[0])) }
777
778 sub _inline_header {
779         my ($dst, $state, $upfx, $hdr, $level) = @_;
780         my $dot = $level == 0 ? '' : '` ';
781
782         my $cur = $state->{cur};
783         my $mid = mid_clean($hdr->header_raw('Message-ID'));
784         my $f = $hdr->header('X-PI-From');
785         my $d = _msg_date($hdr);
786         $f = PublicInbox::Hval->new_oneline($f)->as_html;
787         my $pfx = ' ' . $d . ' ' . indent_for($level);
788         my $attr = $f;
789         $state->{first_level} ||= $level;
790
791         if ($attr ne $state->{prev_attr} || $state->{prev_level} > $level) {
792                 $state->{prev_attr} = $attr;
793         } else {
794                 $attr = '';
795         }
796         $state->{prev_level} = $level;
797
798         if ($cur) {
799                 if ($cur eq $mid) {
800                         delete $state->{cur};
801                         $$dst .= "$pfx$dot<b><a\nid=r\nhref=\"#b\">".
802                                  "$attr [this message]</a></b>\n";
803
804                         return;
805                 }
806         } else {
807                 $state->{next_msg} ||= $mid;
808         }
809
810         # Subject is never undef, this mail was loaded from
811         # our Xapian which would've resulted in '' if it were
812         # really missing (and Filter rejects empty subjects)
813         my $s = $hdr->header('Subject');
814         my $h = $state->{srch}->subject_path($s);
815         if ($state->{seen}->{$h}) {
816                 $s = undef;
817         } else {
818                 $state->{seen}->{$h} = 1;
819                 $s = PublicInbox::Hval->new($s);
820                 $s = $s->as_html;
821         }
822         my $m = PublicInbox::Hval->new_msgid($mid);
823         $m = $upfx . '../' . $m->as_href . '/';
824         if (defined $s) {
825                 $$dst .= "$pfx$dot<a\nhref=\"$m\">$s</a> $attr\n";
826         } else {
827                 $$dst .= "$pfx$dot<a\nhref=\"$m\">$f</a>\n";
828         }
829 }
830
831 sub inline_dump {
832         my ($dst, $state, $upfx, $node, $level) = @_;
833         return unless $node;
834         if (my $mime = $node->message) {
835                 my $hdr = $mime->header_obj;
836                 my $mid = mid_clean($hdr->header_obj('Message-ID'));
837                 if ($mid eq $state->{parent_cmp}) {
838                         $state->{parent} = $mid;
839                 }
840                 _inline_header($dst, $state, $upfx, $hdr, $level);
841         } else {
842                 my $dot = $level == 0 ? '' : '` ';
843                 my $pfx = (' ' x length(' 1970-01-01 13:37 ')).
844                         indent_for($level) . $dot;
845                 $$dst .= $pfx;
846                 $$dst .= ghost_parent("$upfx../", $node->messageid) . "\n";
847         }
848         inline_dump($dst, $state, $upfx, $node->child, $level+1);
849         inline_dump($dst, $state, $upfx, $node->next, $level);
850 }
851
852 sub sort_ts {
853         sort {
854                 (eval { $a->topmost->message->header('X-PI-TS') } || 0) <=>
855                 (eval { $b->topmost->message->header('X-PI-TS') } || 0)
856         } @_;
857 }
858
859 sub rsort_ts {
860         sort {
861                 (eval { $b->topmost->message->header('X-PI-TS') } || 0) <=>
862                 (eval { $a->topmost->message->header('X-PI-TS') } || 0)
863         } @_;
864 }
865
866 # accumulate recent topics if search is supported
867 # returns 1 if done, undef if not
868 sub add_topic {
869         my ($state, $node, $level) = @_;
870         return unless $node;
871         my $child_adjust = 1;
872
873         if (my $x = $node->message) {
874                 $x = $x->header_obj;
875                 my $subj;
876
877                 $subj = $x->header('Subject');
878                 $subj = $state->{srch}->subject_normalized($subj);
879
880                 if (++$state->{subjs}->{$subj} == 1) {
881                         push @{$state->{order}}, [ $level, $subj ];
882                 }
883
884                 my $mid = mid_clean($x->header_raw('Message-ID'));
885
886                 my $ts = $x->header('X-PI-TS');
887                 my $exist = $state->{latest}->{$subj};
888                 if (!$exist || $exist->[1] < $ts) {
889                         $state->{latest}->{$subj} = [ $mid, $ts ];
890                 }
891         } else {
892                 # ghost message, do not bump level
893                 $child_adjust = 0;
894         }
895
896         add_topic($state, $node->child, $level + $child_adjust);
897         add_topic($state, $node->next, $level);
898 }
899
900 sub dump_topics {
901         my ($state) = @_;
902         my $order = $state->{order};
903         my $subjs = $state->{subjs};
904         my $latest = $state->{latest};
905         return "\n[No topics in range]</pre>" unless (scalar @$order);
906         my $dst = '';
907         my $pfx;
908         my $prev = 0;
909         my $prev_attr = '';
910         while (defined(my $info = shift @$order)) {
911                 my ($level, $subj) = @$info;
912                 my $n = delete $subjs->{$subj};
913                 my ($mid, $ts) = @{delete $latest->{$subj}};
914                 $mid = PublicInbox::Hval->new_msgid($mid)->as_href;
915                 $subj = PublicInbox::Hval->new($subj)->as_html;
916                 $pfx = indent_for($level);
917                 my $nl = $level == $prev ? "\n" : '';
918                 my $dot = $level == 0 ? '' : '` ';
919                 $dst .= "$nl$pfx$dot<a\nhref=\"$mid/t/#u\"><b>$subj</b></a>\n";
920
921                 my $attr;
922                 $ts = fmt_ts($ts);
923                 $attr = " $ts UTC";
924
925                 # $n isn't the total number of posts on the topic,
926                 # just the number of posts in the current results
927                 # window, so leave it unlabeled
928                 $n = $n == 1 ? '' : " ($n+ messages)";
929
930                 if ($level == 0 || $attr ne $prev_attr) {
931                         my $mbox = qq(<a\nhref="$mid/t.mbox.gz">mbox.gz</a>);
932                         my $atom = qq(<a\nhref="$mid/t.atom">Atom</a>);
933                         $pfx .= INDENT if $level > 0;
934                         $dst .= $pfx . $attr . $n . " - $mbox / $atom\n";
935                         $prev_attr = $attr;
936                 }
937         }
938         $dst .= '</pre>';
939 }
940
941 sub emit_index_topics {
942         my ($state, $fh) = @_;
943         my $off = $state->{ctx}->{cgi}->param('o');
944         $off = 0 unless defined $off;
945         $state->{order} = [];
946         $state->{subjs} = {};
947         $state->{latest} = {};
948         my $max = 25;
949         my %opts = ( offset => int $off, limit => $max * 4 );
950         while (scalar @{$state->{order}} < $max) {
951                 my $res = $state->{srch}->query('', \%opts);
952                 my $nr = scalar @{$res->{msgs}} or last;
953
954                 for (rsort_ts(thread_results(load_results($res), 1)->rootset)) {
955                         add_topic($state, $_, 0);
956                 }
957                 $opts{offset} += $nr;
958         }
959
960         $fh->write(dump_topics($state));
961         $opts{offset};
962 }
963
964 1;