]> git.cworth.org Git - sup/blob - lib/sup/thread.rb
ab15493654acddff79be066082d44445ff5a0781
[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 deduced from other
58   ## messages).
59   def each fake_root=false
60     adj = 0
61     root = @containers.find_all { |c| c.message && !Message.subj_is_reply?(c.message.subj) }.argmin { |c| c.date }
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
89     with_snippets = select { |m, *o| m && m.snippet && !m.snippet.empty? }
90     first_unread, * = with_snippets.select { |m, *o| m.has_label?(:unread) }.sort_by { |m, *o| m.date }.first
91     return first_unread.snippet if first_unread
92     last_read, * = with_snippets.sort_by { |m, *o| m.date }.last
93     return last_read.snippet if last_read
94     ""
95   end
96   def authors; map { |m, *o| m.from if m }.compact.uniq; end
97
98   def apply_label t; each { |m, *o| m && m.add_label(t) }; end
99   def remove_label t; each { |m, *o| m && m.remove_label(t) }; end
100
101   def toggle_label label
102     if has_label? label
103       remove_label label
104       return false
105     else
106       apply_label label
107       return true
108     end
109   end
110
111   def set_labels l; each { |m, *o| m && m.labels = l }; end
112   
113   def has_label? t; any? { |m, *o| m && m.has_label?(t) }; end
114   def save index; each { |m, *o| m && m.save(index) }; end
115
116   def direct_participants
117     map { |m, *o| [m.from] + m.to if m }.flatten.compact.uniq
118   end
119
120   def participants
121     map { |m, *o| [m.from] + m.to + m.cc + m.bcc if m }.flatten.compact.uniq
122   end
123
124   def size; map { |m, *o| m ? 1 : 0 }.sum; end
125   def subj; argfind { |m, *o| m && m.subj }; end
126   def labels
127       map { |m, *o| m && m.labels }.flatten.compact.uniq.sort_by { |t| t.to_s }
128   end
129   def labels= l
130     each { |m, *o| m && m.labels = l.clone }
131   end
132
133   def latest_message
134     inject(nil) do |a, b| 
135       b = b.first
136       if a.nil?
137         b
138       elsif b.nil?
139         a
140       else
141         b.date > a.date ? b : a
142       end
143     end
144   end
145
146   def to_s
147     "<thread containing: #{@containers.join ', '}>"
148   end
149
150   def prune_dangling_container_trees!
151     @containers.delete_if { |c| c.dangling? }
152   end
153 end
154
155 ## recursive structure used internally to represent message trees as
156 ## described by reply-to: and references: headers.
157 ##
158 ## the 'id' field is the same as the message id. but the message might
159 ## be empty, in the case that we represent a message that was referenced
160 ## by another message (as an ancestor) but never received.
161 class Container
162   attr_accessor :message, :parent, :children, :id, :thread
163
164   def initialize id
165     raise "non-String #{id.inspect}" unless id.is_a? String
166     @id = id
167     @message, @parent, @thread = nil, nil, nil
168     @children = []
169   end      
170
171   def each_with_stuff parent=nil
172     yield self, 0, parent
173     @children.each do |c|
174       c.each_with_stuff(self) { |cc, d, par| yield cc, d + 1, par }
175     end
176   end
177
178   def dangling?
179     if @children.any? { |c| !c.empty? }
180       false
181     elsif @children.any? { |c| !c.dangling? }
182       false
183     else
184       true
185     end
186   end
187
188   def descendant_of? o
189     if o == self
190       true
191     else
192       @parent && @parent.descendant_of?(o)
193     end
194   end
195
196   def == o; Container === o && id == o.id; end
197
198   def empty?; @message.nil?; end
199   def root?; @parent.nil?; end
200   def root; root? ? self : @parent.root; end
201
202   ## skip over any containers which are empty and have only one child. we use
203   ## this make the threaded display a little nicer, and only stick in the
204   ## "missing message" line when it's graphically necessary, i.e. when the
205   ## missing message has more than one descendent.
206   def first_useful_descendant
207     if empty? && @children.size == 1
208       @children.first.first_useful_descendant
209     else
210       self
211     end
212   end
213
214   def find_attr attr
215     if empty?
216       @children.argfind { |c| c.find_attr attr }
217     else
218       @message.send attr
219     end
220   end
221   def subj; find_attr :subj; end
222   def date; find_attr :date; end
223
224   def is_reply?; subj && Message.subject_is_reply?(subj); end
225
226   def to_s
227     [ "<#{id}",
228       (@parent.nil? ? nil : "parent=#{@parent.id}"),
229       (@children.empty? ? nil : "children=#{@children.map { |c| c.id }.inspect}"),
230     ].compact.join(" ") + ">"
231   end
232
233   def dump_recursive f=$stdout, indent=0, root=true, parent=nil
234     raise "inconsistency" unless parent.nil? || parent.children.include?(self)
235     unless root
236       f.print " " * indent
237       f.print "+->"
238     end
239     line = #"[#{useful? ? 'U' : ' '}] " +
240       if @message
241         "[#{thread}] #{@message.subj} " ##{@message.refs.inspect} / #{@message.replytos.inspect}"
242       else
243         "<no message>"
244       end
245
246     f.puts "#{id} #{line}"#[0 .. (105 - indent)]
247     indent += 3
248     @children.each { |c| c.dump_recursive f, indent, false, self }
249   end
250 end
251
252 ## A set of threads, so a forest. Is integrated with the index and
253 ## builds thread structures by reading messages from it.
254 ##
255 ## If 'thread_by_subj' is true, puts messages with the same subject in
256 ## one thread, even if they don't reference each other. This is
257 ## helpful for crappy MUAs that don't set In-reply-to: or References:
258 ## headers, but means that messages may be threaded unnecessarily.
259 class ThreadSet
260   attr_reader :num_messages
261   bool_reader :thread_by_subj
262
263   def initialize index, thread_by_subj=true
264     @index = index
265     @num_messages = 0
266     ## map from message ids to container objects
267     @messages = SavingHash.new { |id| Container.new id }
268     ## map from subject strings or (or root message ids) to thread objects
269     @threads = SavingHash.new { Thread.new }
270     @thread_by_subj = thread_by_subj
271   end
272
273   def thread_for_id mid; (c = @messages[mid]) && c.root.thread end
274   def contains_id? id; @messages.member?(id) && !@messages[id].empty? end
275   def thread_for m; thread_for_id m.id end
276   def contains? m; contains_id? m.id end
277
278   def threads; prune_empty_threads.values end
279   def size; prune_empty_threads.size end
280
281   ## unused
282   def dump f
283     @threads.each do |s, t|
284       f.puts "**********************"
285       f.puts "** for subject #{s} **"
286       f.puts "**********************"
287       t.dump f
288     end
289   end
290
291   ## link two containers
292   def link p, c, overwrite=false
293     if p == c || p.descendant_of?(c) || c.descendant_of?(p) # would create a loop
294 #      puts "*** linking parent #{p} and child #{c} would create a loop"
295       return
296     end
297
298     if c.parent.nil? || overwrite
299       remove_container c
300       p.children << c
301       c.parent = p
302     end
303   end
304   private :link
305
306   def remove_container c
307     c.parent.children.delete c if c.parent # remove from tree
308     thread = c.root.thread # find containing thread
309     if thread
310       thread.prune_dangling_container_trees!
311       c.root.thread = nil
312     end
313   end
314   private :remove_container
315
316   def prune_empty_threads; @threads.delete_if { |k, t| t.empty? } end
317   private :prune_empty_threads
318
319   ## remove a single message id. not used anywhere, afaik.
320   def remove_id mid
321     return unless(c = @messages[mid])
322     remove_container c
323   end
324
325   def remove_thread_containing_id mid
326     c = @messages[mid] or return
327     t = c.root.thread
328     @threads.delete_if { |key, thread| t == thread }
329   end
330
331   ## load in (at most) num number of threads from the index
332   def load_n_threads num, opts={}
333     @index.each_id_by_date opts do |mid, builder|
334       break if size >= num
335       next if contains_id? mid
336
337       m = builder.call
338       load_thread_for_message m, :skip_killed => opts[:skip_killed], :load_deleted => opts[:load_deleted], :load_spam => opts[:load_spam]
339       yield size if block_given?
340     end
341   end
342
343   ## loads in all messages needed to thread m
344   ## may do nothing if m's thread is killed
345   def load_thread_for_message m, opts={}
346     good = @index.each_message_in_thread_for m, opts do |mid, builder|
347       next if contains_id? mid
348       add_message builder.call
349     end
350     add_message m if good
351   end
352
353   ## merges in a pre-loaded thread
354   def add_thread t
355     raise "duplicate" if @threads.values.member? t
356     t.each { |m, *o| add_message m }
357   end
358
359   def is_relevant? m
360     m.refs.any? { |ref_id| @messages.member? ref_id }
361   end
362
363   ## the heart of the threading code
364   def add_message message
365     el = @messages[message.id]
366     return if el.message # we've seen it before
367
368     el.message = message
369     oldroot = el.root
370
371     ## link via references:
372     prev = nil
373     message.refs.each do |ref_id|
374       ref = @messages[ref_id]
375       link prev, ref if prev
376       prev = ref
377     end
378     link prev, el, true if prev
379
380     ## link via in-reply-to:
381     message.replytos.each do |ref_id|
382       ref = @messages[ref_id]
383       link ref, el, true
384       break # only do the first one
385     end
386
387     root = el.root
388
389     ## new root. need to drop old one and put this one in its place
390     if root != oldroot && oldroot.thread
391       oldroot.thread.drop oldroot
392       oldroot.thread = nil
393     end
394
395     key =
396       if thread_by_subj?
397         Message.normalize_subj root.subj
398       else
399         root.id
400       end
401
402     ## check to see if the subject is still the same (in the case
403     ## that we first added a child message with a different
404     ## subject)
405     if root.thread
406       unless @threads[key] == root.thread
407         if @threads[key]
408           root.thread.empty!
409           @threads[key] << root
410           root.thread = @threads[key]
411         else
412           @threads[key] = root.thread
413         end
414       end
415     else
416       thread = @threads[key]
417       thread << root
418       root.thread = thread
419     end
420
421     ## last bit
422     @num_messages += 1
423   end
424 end
425
426 end