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