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