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