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