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