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