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