]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/SearchThread.pm
thread: remove Mail::Thread dependency
[public-inbox.git] / lib / PublicInbox / SearchThread.pm
1 # This library is free software; you can redistribute it and/or modify
2 # it under the same terms as Perl itself.
3 #
4 # This license differs from the rest of public-inbox
5 #
6 # Our own jwz-style threading class based on Mail::Thread from CPAN.
7 # Mail::Thread is unmaintained and available on some distros.
8 # We also do not want pruning or subject grouping, since we want
9 # to encourage strict threading and hopefully encourage people
10 # to use proper In-Reply-To.
11 #
12 # This includes fixes from several open bugs for Mail::Thread
13 #
14 # Avoid circular references
15 # - https://rt.cpan.org/Public/Bug/Display.html?id=22817
16 #
17 # And avoid recursion in recurse_down:
18 # - https://rt.cpan.org/Ticket/Display.html?id=116727
19 # - http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=833479
20 package PublicInbox::SearchThread;
21 use strict;
22 use warnings;
23 use Email::Abstract;
24
25 sub new {
26         return bless {
27                 messages => $_[1],
28                 id_table => {},
29                 rootset  => []
30         }, $_[0];
31 }
32
33 sub _get_hdr {
34         my ($class, $msg, $hdr) = @_;
35         Email::Abstract->get_header($msg, $hdr) || '';
36 }
37
38 sub _uniq {
39         my %seen;
40         return grep { !$seen{$_}++ } @_;
41 }
42
43 sub _references {
44         my $class = shift;
45         my $msg = shift;
46         my @references = ($class->_get_hdr($msg, "References") =~ /<([^>]+)>/g);
47         my $foo = $class->_get_hdr($msg, "In-Reply-To");
48         chomp $foo;
49         $foo =~ s/.*?<([^>]+)>.*/$1/;
50         push @references, $foo
51           if $foo =~ /^\S+\@\S+$/ && (!@references || $references[-1] ne $foo);
52         return _uniq(@references);
53 }
54
55 sub _msgid {
56         my ($class, $msg) = @_;
57         my $id = $class->_get_hdr($msg, "Message-ID");
58         die "attempt to thread message with no id" unless $id;
59         chomp $id;
60         $id =~ s/^<([^>]+)>.*/$1/; # We expect this not to have <>s
61         return $id;
62 }
63
64 sub rootset { @{$_[0]{rootset}} }
65
66 sub thread {
67         my $self = shift;
68         $self->_setup();
69         $self->{rootset} = [ grep { !$_->parent } values %{$self->{id_table}} ];
70         $self->_finish();
71 }
72
73 sub _finish {
74         my $self = shift;
75         delete $self->{id_table};
76         delete $self->{seen};
77 }
78
79 sub _get_cont_for_id {
80         my $self = shift;
81         my $id = shift;
82         $self->{id_table}{$id} ||= $self->_container_class->new($id);
83 }
84
85 sub _container_class { 'PublicInbox::SearchThread::Container' }
86
87 sub _setup {
88         my ($self) = @_;
89
90         _add_message($self, $_) foreach @{$self->{messages}};
91 }
92
93 sub _add_message ($$) {
94         my ($self, $message) = @_;
95
96         # A. if id_table...
97         my $this_container = $self->_get_cont_for_id($self->_msgid($message));
98         $this_container->message($message);
99
100         # B. For each element in the message's References field:
101         my @refs = $self->_references($message);
102
103         my $prev;
104         for my $ref (@refs) {
105                 # Find a Container object for the given Message-ID
106                 my $container = $self->_get_cont_for_id($ref);
107
108                 # Link the References field's Containers together in the
109                 # order implied by the References header
110                 # * If they are already linked don't change the existing links
111                 # * Do not add a link if adding that link would introduce
112                 #   a loop...
113
114                 if ($prev &&
115                         !$container->parent &&  # already linked
116                         !$container->has_descendent($prev) # would loop
117                    ) {
118                         $prev->add_child($container);
119                 }
120                 $prev = $container;
121         }
122
123         # C. Set the parent of this message to be the last element in
124         # References...
125         if ($prev &&
126                 !$this_container->has_descendent($prev) # would loop
127            ) {
128                 $prev->add_child($this_container)
129         }
130 }
131
132 sub order {
133         my $self = shift;
134         my $ordersub = shift;
135
136         # make a fake root
137         my $root = $self->_container_class->new( 'fakeroot' );
138         $root->add_child( $_ ) for @{ $self->{rootset} };
139
140         # sort it
141         $root->order_children( $ordersub );
142
143         # and untangle it
144         my @kids = $root->children;
145         $self->{rootset} = \@kids;
146         $root->remove_child($_) for @kids;
147 }
148
149 package PublicInbox::SearchThread::Container;
150 use Carp qw(croak);
151 use Scalar::Util qw(weaken);
152
153 sub new { my $self = shift; bless { id => shift }, $self; }
154
155 sub message { $_[0]->{message} = $_[1] if @_ == 2; $_[0]->{message} }
156 sub parent { @_ == 2 ? weaken($_[0]->{parent} = $_[1]) : $_[0]->{parent} }
157 sub child { $_[0]->{child} = $_[1] if @_ == 2; $_[0]->{child} }
158 sub next { $_[0]->{next} = $_[1] if @_ == 2; $_[0]->{next} }
159 sub messageid { $_[0]->{id} }
160
161 sub add_child {
162         my ($self, $child) = @_;
163         croak "Cowardly refusing to become my own parent: $self"
164           if $self == $child;
165
166         if (grep { $_ == $child } $self->children) {
167                 # All is potentially correct with the world
168                 $child->parent($self);
169                 return;
170         }
171
172         $child->parent->remove_child($child) if $child->parent;
173
174         $child->next($self->child);
175         $self->child($child);
176         $child->parent($self);
177 }
178
179 sub remove_child {
180         my ($self, $child) = @_;
181         return unless $self->child;
182         if ($self->child == $child) {  # First one's easy.
183                 $self->child($child->next);
184                 $child->next(undef);
185                 $child->parent(undef);
186                 return;
187         }
188
189         my $x = $self->child;
190         my $prev = $x;
191         while ($x = $x->next) {
192                 if ($x == $child) {
193                         $prev->next($x->next); # Unlink x
194                         $x->next(undef);
195                         $x->parent(undef);       # Deparent it
196                         return;
197                 }
198                 $prev = $x;
199         }
200         # oddly, we can get here
201         $child->next(undef);
202         $child->parent(undef);
203 }
204
205 sub has_descendent {
206         my $self = shift;
207         my $child = shift;
208         die "Assertion failed: $child" unless eval {$child};
209         my $there = 0;
210         $self->recurse_down(sub { $there = 1 if $_[0] == $child });
211
212         return $there;
213 }
214
215 sub children {
216         my $self = shift;
217         my @children;
218         my $visitor = $self->child;
219         while ($visitor) {
220                 push @children, $visitor;
221                 $visitor = $visitor->next
222         }
223         return @children;
224 }
225
226 sub set_children {
227         my $self = shift;
228         my $walk = $self->child( shift );
229         while (@_) { $walk = $walk->next( shift ) }
230         $walk->next(undef) if $walk;
231 }
232
233 sub order_children {
234         my $self = shift;
235         my $ordersub = shift;
236
237         return unless $ordersub;
238
239         my $sub = sub {
240                 my $cont = shift;
241                 my @children = $cont->children;
242                 return if @children < 2;
243                 $cont->set_children( $ordersub->( @children ) );
244         };
245         $self->iterate_down( undef, $sub );
246         undef $sub;
247 }
248
249 # non-recursive version of recurse_down to avoid stack depth warnings
250 sub recurse_down {
251         my ($self, $callback) = @_;
252         my %seen;
253         my @q = ($self);
254         while (my $cont = shift @q) {
255                 $seen{$cont}++;
256                 $callback->($cont);
257
258                 if (my $next = $cont->next) {
259                         if ($seen{$next}) {
260                                 $cont->next(undef);
261                         } else {
262                                 push @q, $next;
263                         }
264                 }
265                 if (my $child = $cont->child) {
266                         if ($seen{$child}) {
267                                 $cont->child(undef);
268                         } else {
269                                 push @q, $child;
270                         }
271                 }
272         }
273 }
274
275 sub iterate_down {
276         my $self = shift;
277         my ($before, $after) = @_;
278
279         my %seen;
280         my $walk = $self;
281         my $depth = 0;
282         my @visited;
283         while ($walk) {
284                 push @visited, [ $walk, $depth ];
285                 $before->($walk, $depth) if $before;
286
287                 # spot/break loops
288                 $seen{$walk}++;
289
290                 my $child = $walk->child;
291                 if ($child && $seen{$child}) {
292                         $walk->child(undef);
293                         $child = undef;
294                 }
295
296                 my $next = $walk->next;
297                 if ($next && $seen{$next}) {
298                         $walk->next(undef);
299                         $next = undef;
300                 }
301
302                 # go down, or across
303                 if ($child) {
304                         $next = $child;
305                         ++$depth;
306                 }
307
308                 # no next?  look up
309                 if (!$next) {
310                         my $up = $walk;
311                         while ($up && !$next) {
312                                 $up = $up->parent;
313                                 --$depth;
314                                 $next = $up->next if $up;
315                         }
316                 }
317                 $walk = $next;
318         }
319         return unless $after;
320         while (@visited) { $after->(@{ pop @visited }) }
321 }
322
323 1;