]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/SearchThread.pm
thread: pass array refs instead of entire arrays
[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         \@children;
224 }
225
226 sub set_children {
227         my ($self, $children) = @_;
228         my $walk = $self->{child} = shift @$children;
229         do {
230                 $walk = $walk->{next} = shift @$children;
231         } while ($walk);
232 }
233
234 sub order_children {
235         my $self = shift;
236         my $ordersub = shift;
237
238         return unless $ordersub;
239
240         my $sub = sub {
241                 my $cont = shift;
242                 my $children = $cont->children;
243                 return if @$children < 2;
244                 $cont->set_children( $ordersub->( $children ) );
245         };
246         $self->iterate_down( undef, $sub );
247         undef $sub;
248 }
249
250 # non-recursive version of recurse_down to avoid stack depth warnings
251 sub recurse_down {
252         my ($self, $callback) = @_;
253         my %seen;
254         my @q = ($self);
255         while (my $cont = shift @q) {
256                 $seen{$cont}++;
257                 $callback->($cont);
258
259                 if (my $next = $cont->next) {
260                         if ($seen{$next}) {
261                                 $cont->next(undef);
262                         } else {
263                                 push @q, $next;
264                         }
265                 }
266                 if (my $child = $cont->child) {
267                         if ($seen{$child}) {
268                                 $cont->child(undef);
269                         } else {
270                                 push @q, $child;
271                         }
272                 }
273         }
274 }
275
276 sub iterate_down {
277         my $self = shift;
278         my ($before, $after) = @_;
279
280         my %seen;
281         my $walk = $self;
282         my $depth = 0;
283         my @visited;
284         while ($walk) {
285                 push @visited, [ $walk, $depth ];
286                 $before->($walk, $depth) if $before;
287
288                 # spot/break loops
289                 $seen{$walk}++;
290
291                 my $child = $walk->child;
292                 if ($child && $seen{$child}) {
293                         $walk->child(undef);
294                         $child = undef;
295                 }
296
297                 my $next = $walk->next;
298                 if ($next && $seen{$next}) {
299                         $walk->next(undef);
300                         $next = undef;
301                 }
302
303                 # go down, or across
304                 if ($child) {
305                         $next = $child;
306                         ++$depth;
307                 }
308
309                 # no next?  look up
310                 if (!$next) {
311                         my $up = $walk;
312                         while ($up && !$next) {
313                                 $up = $up->parent;
314                                 --$depth;
315                                 $next = $up->next if $up;
316                         }
317                 }
318                 $walk = $next;
319         }
320         return unless $after;
321         while (@visited) { $after->(@{ pop @visited }) }
322 }
323
324 1;