]> git.cworth.org Git - sup/blob - lib/sup/thread.rb
bugfix introduced by previous thread refactoring: relevant messages to a current...
[sup] / lib / sup / thread.rb
1 ## Herein lies all the code responsible for threading messages. It's
2 ## basically an online version of the JWZ threading algorithm:
3 ## http://www.jwz.org/doc/threading.html
4 ##
5 ## I didn't implement it for efficiency, but thanks to our search
6 ## engine backend, it's typically not applied to very many messages at
7 ## once.
8 ##
9 ## At the top level, we have a ThreadSet, which represents a set of
10 ## threads, e.g. a message folder or an inbox. Each ThreadSet contains
11 ## zero or more Threads. A Thread represents all the message related
12 ## to a particular subject. Each Thread has one or more Containers.  A
13 ## Container is a recursive structure that holds the message tree as
14 ## determined by the references: and in-reply-to: headers. EAch
15 ## Container holds zero or one messages. In the case of zero messages,
16 ## it means we've seen a reference to the message but haven't (yet)
17 ## seen the message itself.
18 ##
19 ## A Thread can have multiple top-level Containers if we decide to
20 ## group them together independent of tree structure, typically if
21 ## (e.g. due to someone using a primitive MUA) the messages have the
22 ## same subject but we don't have evidence from in-reply-to: or
23 ## references: headers. In this case Thread#each can optionally yield
24 ## a faked root object tying them all together into one tree
25 ## structure.
26
27 module Redwood
28
29 class Thread
30   include Enumerable
31
32   attr_reader :containers
33   def initialize
34     ## ah, the joys of a multithreaded application with a class called
35     ## "Thread". i keep instantiating the wrong one...
36     raise "wrong Thread class, buddy!" if block_given?
37     @containers = []
38   end
39
40   def << c
41     @containers << c
42   end
43
44   def empty?; @containers.empty?; end
45   def empty!; @containers.clear; end
46   def drop c; @containers.delete(c) or raise "bad drop"; end
47
48   ## unused
49   def dump f=$stdout
50     f.puts "=== start thread #{self} with #{@containers.length} trees ==="
51     @containers.each { |c| c.dump_recursive f }
52     f.puts "=== end thread ==="
53   end
54
55   ## yields each message, its depth, and its parent. the message yield
56   ## parameter can be a Message object, or :fake_root, or nil (no
57   ## message found but the presence of one induced from other
58   ## messages).
59   def each fake_root=false
60     adj = 0
61     root = @containers.find_all { |c| !Message.subj_is_reply?(c) }.argmin { |c| c.date || 0 }
62
63     if root
64       adj = 1
65       root.first_useful_descendant.each_with_stuff do |c, d, par|
66         yield c.message, d, (par ? par.message : nil)
67       end
68     elsif @containers.length > 1 && fake_root
69       adj = 1
70       yield :fake_root, 0, nil
71     end
72
73     @containers.each do |cont|
74       next if cont == root
75       fud = cont.first_useful_descendant
76       fud.each_with_stuff do |c, d, par|
77         ## special case here: if we're an empty root that's already
78         ## been joined by a fake root, don't emit
79         yield c.message, d + adj, (par ? par.message : nil) unless
80           fake_root && c.message.nil? && root.nil? && c == fud 
81       end
82     end
83   end
84
85   def first; each { |m, *o| return m if m }; nil; end
86   def dirty?; any? { |m, *o| m && m.dirty? }; end
87   def date; map { |m, *o| m.date if m }.compact.max; end
88   def snippet; argfind { |m, *o| m && m.snippet }; end
89   def authors; map { |m, *o| m.from if m }.compact.uniq; end
90
91   def apply_label t; each { |m, *o| m && m.add_label(t) }; end
92   def remove_label t; each { |m, *o| m && m.remove_label(t) }; end
93
94   def toggle_label label
95     if has_label? label
96       remove_label label
97       return false
98     else
99       apply_label label
100       return true
101     end
102   end
103
104   def set_labels l; each { |m, *o| m && m.labels = l }; end
105   
106   def has_label? t; any? { |m, *o| m && m.has_label?(t) }; end
107   def save index; each { |m, *o| m && m.save(index) }; end
108
109   def direct_participants
110     map { |m, *o| [m.from] + m.to if m }.flatten.compact.uniq
111   end
112
113   def participants
114     map { |m, *o| [m.from] + m.to + m.cc + m.bcc if m }.flatten.compact.uniq
115   end
116
117   def size; map { |m, *o| m ? 1 : 0 }.sum; end
118   def subj; argfind { |m, *o| m && m.subj }; end
119   def labels
120       map { |m, *o| m && m.labels }.flatten.compact.uniq.sort_by { |t| t.to_s }
121   end
122   def labels= l
123     each { |m, *o| m && m.labels = l.clone }
124   end
125
126   def latest_message
127     inject(nil) do |a, b| 
128       b = b.first
129       if a.nil?
130         b
131       elsif b.nil?
132         a
133       else
134         b.date > a.date ? b : a
135       end
136     end
137   end
138
139   def to_s
140     "<thread containing: #{@containers.join ', '}>"
141   end
142 end
143
144 ## recursive structure used internally to represent message trees as
145 ## described by reply-to: and references: headers.
146 ##
147 ## the 'id' field is the same as the message id. but the message might
148 ## be empty, in the case that we represent a message that was referenced
149 ## by another message (as an ancestor) but never received.
150 class Container
151   attr_accessor :message, :parent, :children, :id, :thread
152
153   def initialize id
154     raise "non-String #{id.inspect}" unless id.is_a? String
155     @id = id
156     @message, @parent, @thread = nil, nil, nil
157     @children = []
158   end      
159
160   def each_with_stuff parent=nil
161     yield self, 0, parent
162     @children.each do |c|
163       c.each_with_stuff(self) { |cc, d, par| yield cc, d + 1, par }
164     end
165   end
166
167   def descendant_of? o
168     if o == self
169       true
170     else
171       @parent && @parent.descendant_of?(o)
172     end
173   end
174
175   def == o; Container === o && id == o.id; end
176
177   def empty?; @message.nil?; end
178   def root?; @parent.nil?; end
179   def root; root? ? self : @parent.root; end
180
181   def first_useful_descendant
182     if empty? && @children.size == 1
183       @children.first.first_useful_descendant
184     else
185       self
186     end
187   end
188
189   def find_attr attr
190     if empty?
191       @children.argfind { |c| c.find_attr attr }
192     else
193       @message.send attr
194     end
195   end
196   def subj; find_attr :subj; end
197   def date; find_attr :date; end
198
199   def is_reply?; subj && Message.subject_is_reply?(subj); end
200
201   def to_s
202     [ "<#{id}",
203       (@parent.nil? ? nil : "parent=#{@parent.id}"),
204       (@children.empty? ? nil : "children=#{@children.map { |c| c.id }.inspect}"),
205     ].compact.join(" ") + ">"
206   end
207
208   def dump_recursive f=$stdout, indent=0, root=true, parent=nil
209     raise "inconsistency" unless parent.nil? || parent.children.include?(self)
210     unless root
211       f.print " " * indent
212       f.print "+->"
213     end
214     line = #"[#{useful? ? 'U' : ' '}] " +
215       if @message
216         "[#{thread}] #{@message.subj} " ##{@message.refs.inspect} / #{@message.replytos.inspect}"
217       else
218         "<no message>"
219       end
220
221     f.puts "#{id} #{line}"#[0 .. (105 - indent)]
222     indent += 3
223     @children.each { |c| c.dump_recursive f, indent, false, self }
224   end
225 end
226
227 ## A set of threads (so a forest). Builds thread structures by reading
228 ## messages from an index.
229 ##
230 ## If 'thread_by_subj' is true, puts messages with the same subject in
231 ## one thread, even if they don't reference each other. This is
232 ## helpful for crappy MUAs that don't set In-reply-to: or References:
233 ## headers, but means that messages may be threaded unnecessarily.
234 class ThreadSet
235   attr_reader :num_messages
236   bool_reader :thread_by_subj
237
238   def initialize index, thread_by_subj=true
239     @index = index
240     @num_messages = 0
241     ## map from message ids to container objects
242     @messages = SavingHash.new { |id| Container.new id }
243     ## map from subject strings or (or root message ids) to thread objects
244     @threads = SavingHash.new { Thread.new }
245     @thread_by_subj = thread_by_subj
246   end
247
248   def contains_id? id; @messages.member?(id) && !@messages[id].empty?; end
249   def thread_for m
250     (c = @messages[m.id]) && c.root.thread
251   end
252
253   def delete_cruft
254     @threads.each { |k, v| @threads.delete(k) if v.empty? }
255   end
256   private :delete_cruft
257
258   def threads; delete_cruft; @threads.values; end
259   def size; delete_cruft; @threads.size; end
260
261   ## unused
262   def dump f
263     @threads.each do |s, t|
264       f.puts "**********************"
265       f.puts "** for subject #{s} **"
266       f.puts "**********************"
267       t.dump f
268     end
269   end
270
271   def link p, c, overwrite=false
272     if p == c || p.descendant_of?(c) || c.descendant_of?(p) # would create a loop
273 #      puts "*** linking parent #{p} and child #{c} would create a loop"
274       return
275     end
276
277     if c.parent.nil? || overwrite
278       c.parent.children.delete c if overwrite && c.parent
279       if c.thread
280         c.thread.drop c 
281         c.thread = nil
282       end
283       p.children << c
284       c.parent = p
285     end
286   end
287   private :link
288
289   def remove mid
290     return unless(c = @messages[mid])
291
292     c.parent.children.delete c if c.parent
293     if c.thread
294       c.thread.drop c
295       c.thread = nil
296     end
297   end
298
299   ## load in (at most) num number of threads from the index
300   def load_n_threads num, opts={}
301     @index.each_id_by_date opts do |mid, builder|
302       break if size >= num
303       next if contains_id? mid
304
305       m = builder.call
306       add_message m
307       load_thread_for_message m, :load_killed => opts[:load_killed]
308       yield size if block_given?
309     end
310   end
311
312   ## loads in all messages needed to thread m
313   def load_thread_for_message m, opts={}
314     @index.each_message_in_thread_for m, opts.merge({:limit => 100}) do |mid, builder|
315       next if contains_id? mid
316       add_message builder.call
317     end
318   end
319
320   ## merges in a pre-loaded thread
321   def add_thread t
322     raise "duplicate" if @threads.values.member? t
323     t.each { |m, *o| add_message m }
324   end
325
326   def is_relevant? m
327     m.refs.any? { |ref_id| @messages.member? ref_id }
328   end
329
330   ## the heart of the threading code
331   def add_message message
332     el = @messages[message.id]
333     return if el.message # we've seen it before
334
335     el.message = message
336     oldroot = el.root
337
338     ## link via references:
339     prev = nil
340     message.refs.each do |ref_id|
341       ref = @messages[ref_id]
342       link prev, ref if prev
343       prev = ref
344     end
345     link prev, el, true if prev
346
347     ## link via in-reply-to:
348     message.replytos.each do |ref_id|
349       ref = @messages[ref_id]
350       link ref, el, true
351       break # only do the first one
352     end
353
354     root = el.root
355
356     ## new root. need to drop old one and put this one in its place
357     if root != oldroot && oldroot.thread
358       oldroot.thread.drop oldroot
359       oldroot.thread = nil
360     end
361
362     key =
363       if thread_by_subj?
364         Message.normalize_subj root.subj
365       else
366         root.id
367       end
368
369     ## check to see if the subject is still the same (in the case
370     ## that we first added a child message with a different
371     ## subject)
372     if root.thread
373       unless @threads[key] == root.thread
374         if @threads[key]
375           root.thread.empty!
376           @threads[key] << root
377           root.thread = @threads[key]
378         else
379           @threads[key] = root.thread
380         end
381       end
382     else
383       thread = @threads[key]
384       thread << root
385       root.thread = thread
386     end
387
388     ## last bit
389     @num_messages += 1
390   end
391 end
392
393 end