sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/

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