]> git.notmuchmail.org Git - sup/commitdiff
Merge branch 'logging' into next
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Tue, 1 Sep 2009 03:04:05 +0000 (23:04 -0400)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Tue, 1 Sep 2009 03:04:05 +0000 (23:04 -0400)
28 files changed:
Manifest.txt [deleted file]
bin/sup
bin/sup-add
bin/sup-sync
bin/sup-sync-back
bin/sup-tweak-labels
lib/sup.rb
lib/sup/buffer.rb
lib/sup/ferret_index.rb
lib/sup/hook.rb
lib/sup/imap.rb
lib/sup/index.rb
lib/sup/interactive-lock.rb [new file with mode: 0644]
lib/sup/maildir.rb
lib/sup/mbox/loader.rb
lib/sup/message-chunks.rb
lib/sup/message.rb
lib/sup/modes/console-mode.rb [new file with mode: 0644]
lib/sup/modes/reply-mode.rb
lib/sup/modes/thread-index-mode.rb
lib/sup/modes/thread-view-mode.rb
lib/sup/poll.rb
lib/sup/source.rb
lib/sup/suicide.rb [deleted file]
lib/sup/textfield.rb
lib/sup/thread.rb
lib/sup/util.rb
lib/sup/xapian_index.rb

diff --git a/Manifest.txt b/Manifest.txt
deleted file mode 100644 (file)
index be633d7..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-CONTRIBUTORS
-HACKING
-History.txt
-LICENSE
-Manifest.txt
-README.txt
-Rakefile
-ReleaseNotes
-bin/sup
-bin/sup-add
-bin/sup-config
-bin/sup-dump
-bin/sup-recover-sources
-bin/sup-sync
-bin/sup-sync-back
-bin/sup-tweak-labels
-doc/FAQ.txt
-doc/Hooks.txt
-doc/NewUserGuide.txt
-doc/Philosophy.txt
-lib/sup.rb
-lib/sup/account.rb
-lib/sup/buffer.rb
-lib/sup/colormap.rb
-lib/sup/contact.rb
-lib/sup/crypto.rb
-lib/sup/draft.rb
-lib/sup/hook.rb
-lib/sup/horizontal-selector.rb
-lib/sup/imap.rb
-lib/sup/index.rb
-lib/sup/keymap.rb
-lib/sup/label.rb
-lib/sup/logger.rb
-lib/sup/maildir.rb
-lib/sup/mbox.rb
-lib/sup/mbox/loader.rb
-lib/sup/mbox/ssh-file.rb
-lib/sup/mbox/ssh-loader.rb
-lib/sup/message-chunks.rb
-lib/sup/message.rb
-lib/sup/mode.rb
-lib/sup/modes/buffer-list-mode.rb
-lib/sup/modes/completion-mode.rb
-lib/sup/modes/compose-mode.rb
-lib/sup/modes/contact-list-mode.rb
-lib/sup/modes/edit-message-mode.rb
-lib/sup/modes/file-browser-mode.rb
-lib/sup/modes/forward-mode.rb
-lib/sup/modes/help-mode.rb
-lib/sup/modes/inbox-mode.rb
-lib/sup/modes/label-list-mode.rb
-lib/sup/modes/label-search-results-mode.rb
-lib/sup/modes/line-cursor-mode.rb
-lib/sup/modes/log-mode.rb
-lib/sup/modes/person-search-results-mode.rb
-lib/sup/modes/poll-mode.rb
-lib/sup/modes/reply-mode.rb
-lib/sup/modes/resume-mode.rb
-lib/sup/modes/scroll-mode.rb
-lib/sup/modes/search-results-mode.rb
-lib/sup/modes/text-mode.rb
-lib/sup/modes/thread-index-mode.rb
-lib/sup/modes/thread-view-mode.rb
-lib/sup/person.rb
-lib/sup/poll.rb
-lib/sup/rfc2047.rb
-lib/sup/sent.rb
-lib/sup/source.rb
-lib/sup/suicide.rb
-lib/sup/tagger.rb
-lib/sup/textfield.rb
-lib/sup/thread.rb
-lib/sup/undo.rb
-lib/sup/update.rb
-lib/sup/util.rb
diff --git a/bin/sup b/bin/sup
index 1ca98194b2a48d29bd7f0d84463db9d97ca960a0..cdf1ff24169cc5b1da1e27d2cf4f03ac2969daa6 100755 (executable)
--- a/bin/sup
+++ b/bin/sup
@@ -78,6 +78,8 @@ global_keymap = Keymap.new do |k|
   k.add :compose, "Compose new message", 'm', 'c'
   k.add :nothing, "Do nothing", :ctrl_g
   k.add :recall_draft, "Edit most recent draft message", 'R'
+  k.add :show_inbox, "Show the Inbox buffer", 'I'
+  k.add :show_console, "Show the Console buffer", '~'
 end
 
 ## the following magic enables wide characters when used with a ruby
@@ -130,36 +132,16 @@ end
 module_function :start_cursing, :stop_cursing
 
 Index.init
-begin
-  Index.lock
-rescue Index::LockError => e
-  require 'highline'
-
-  h = HighLine.new
-  h.wrap_at = :auto
-  h.say Index.fancy_lock_error_message_for(e)
-
-  case h.ask("Should I ask that process to kill itself? ")
-  when /^\s*y(es)?\s*$/i
-    h.say "Ok, suggesting seppuku..."
-    FileUtils.touch Redwood::SUICIDE_FN
-    sleep SuicideManager::DELAY * 2
-    FileUtils.rm_f Redwood::SUICIDE_FN
-    h.say "Let's try that again."
-    retry
-  else
-    h.say <<EOS
-Ok, giving up. If the process crashed and left a stale lockfile, you
-can fix this by manually deleting #{Index.lockfile}.
-EOS
-    exit
-  end
-end
+Index.lock_interactively or exit
 
 begin
   Redwood::start
   Index.load
 
+  $die = false
+  trap("TERM") { |x| $die = true }
+  trap("WINCH") { |x| BufferManager.sigwinch_happened! }
+
   if(s = Redwood::SourceManager.source_for DraftManager.source_name)
     DraftManager.source = s
   else
@@ -218,7 +200,6 @@ begin
 
   unless $opts[:no_threads]
     PollManager.start
-    SuicideManager.start
     Index.start_lock_update_thread
   end
 
@@ -226,18 +207,29 @@ begin
     SearchResultsMode.spawn_from_query $opts[:search]
   end
 
-  until Redwood::exceptions.nonempty? || SuicideManager.die?
-    c = 
-       begin
-         Ncurses.nonblocking_getch
-       rescue Exception => e
-         if e.is_a?(Interrupt)
-           raise if BufferManager.ask_yes_or_no("Die ungracefully now?")
-           bm.draw_screen
-           nil
-         end
-       end
-    next unless c
+  until Redwood::exceptions.nonempty? || $die
+    c = begin
+      Ncurses.nonblocking_getch
+    rescue Interrupt => e
+      raise if BufferManager.ask_yes_or_no "Die ungracefully now?"
+      BufferManager.draw_screen
+      nil
+    end
+
+    if c.nil?
+      if BufferManager.sigwinch_happened?
+        debug "redrawing screen on sigwinch"
+        BufferManager.completely_redraw_screen
+      end
+      next
+    end
+
+    if c == 410
+      ## this is ncurses's way of telling us it's detected a refresh.
+      ## since we have our own sigwinch handler, we don't do anything.
+      next
+    end
+
     bm.erase_flash
 
     action =
@@ -305,6 +297,11 @@ begin
         b, new = BufferManager.spawn_unless_exists("All drafts") { LabelSearchResultsMode.new [:draft] }
         b.mode.load_threads :num => b.content_height if new
       end
+    when :show_inbox
+      BufferManager.raise_to_front ibuf
+    when :show_console
+      b, new = bm.spawn_unless_exists("Console", :system => true) { ConsoleMode.new }
+      b.mode.run
     when :nothing, InputSequenceAborted
     when :redraw
       bm.completely_redraw_screen
@@ -315,13 +312,12 @@ begin
     bm.draw_screen
   end
 
-  bm.kill_all_buffers if SuicideManager.die?
+  bm.kill_all_buffers if $die
 rescue Exception => e
   Redwood::record_exception e, "main"
 ensure
   unless $opts[:no_threads]
     PollManager.stop if PollManager.instantiated?
-    SuicideManager.stop if PollManager.instantiated?
     Index.stop_lock_update_thread
   end
 
@@ -333,7 +329,7 @@ ensure
   Redwood::Logger.add_sink $stderr, false
   debug "stopped cursing"
 
-  if SuicideManager.instantiated? && SuicideManager.die?
+  if $die
     info "I've been ordered to commit seppuku. I obey!"
   end
 
index 8f7010ee3f4dc06aa9051006ab08a4f45a42bec9..e27a0ebf6ff446a9347d4fd8937653ab45b97db8 100755 (executable)
@@ -79,7 +79,7 @@ $terminal.wrap_at = :auto
 Redwood::start
 index = Redwood::Index.init
 
-index.lock_or_die
+index.lock_interactively or exit
 
 begin
   Redwood::SourceManager.load_sources
index b743c1c13e3cdf9f73d6d24a5c88882985c30681..2aa00c3720bcd0dbbce60499e5d14a70e01ae418 100755 (executable)
@@ -112,7 +112,7 @@ else
 end
 
 seen = {}
-index.lock_or_die
+index.lock_interactively or exit
 begin
   index.load
 
index 4d76f17f587fb75e4294b4a39c4bf52f0d5280c8..6298c97c3e6190e8704ff2cd1333843f09511fea 100755 (executable)
@@ -66,7 +66,7 @@ end
 
 Redwood::start
 index = Redwood::Index.init
-index.lock_or_die
+index.lock_interactively or exit
 
 deleted_fp, spam_fp = nil
 unless opts[:dry_run]
index 138f7e1738c7c6029ceb40003716d71763be53ab..90f6a57ddb0cbebd037f4a74dd1692a191de522e 100755 (executable)
@@ -58,10 +58,12 @@ add_labels = opts[:add].to_set_of_symbols ","
 remove_labels = opts[:remove].to_set_of_symbols ","
 
 Trollop::die "nothing to do: no labels to add or remove" if add_labels.empty? && remove_labels.empty?
+Trollop::die "no sources specified" if ARGV.empty?
 
 Redwood::start
+index = Redwood::Index.init
+index.lock_interactively or exit
 begin
-  index = Redwood::Index.init
   index.load
 
   source_ids = if opts[:all_sources]
index ed144589a705abd854573e9b9f6eddf247ec458a..12513f7df7397dd1c9a6a25c507e56f7c5f44ba6 100644 (file)
@@ -85,25 +85,37 @@ module Redwood
   module_function :reporting_thread, :record_exception, :exceptions
 
 ## one-stop shop for yamliciousness
-  def save_yaml_obj object, fn, safe=false
+  def save_yaml_obj o, fn, safe=false
+    o = if o.is_a?(Array)
+      o.map { |x| (x.respond_to?(:before_marshal) && x.before_marshal) || x }
+    else
+      o.respond_to?(:before_marshal) && o.before_marshal
+    end
+
     if safe
       safe_fn = "#{File.dirname fn}/safe_#{File.basename fn}"
       mode = File.stat(fn).mode if File.exists? fn
-      File.open(safe_fn, "w", mode) { |f| f.puts object.to_yaml }
+      File.open(safe_fn, "w", mode) { |f| f.puts o.to_yaml }
       FileUtils.mv safe_fn, fn
     else
-      File.open(fn, "w") { |f| f.puts object.to_yaml }
+      File.open(fn, "w") { |f| f.puts o.to_yaml }
     end
   end
 
   def load_yaml_obj fn, compress=false
-    if File.exists? fn
+    o = if File.exists? fn
       if compress
         Zlib::GzipReader.open(fn) { |f| YAML::load f }
       else
         YAML::load_file fn
       end
     end
+    if o.is_a?(Array)
+      o.each { |x| x.after_unmarshal! if x.respond_to?(:after_unmarshal!) }
+    else
+      o.after_unmarshal! if o.respond_to?(:after_unmarshal!)
+    end
+    o
   end
 
   def start
@@ -114,7 +126,6 @@ module Redwood
     Redwood::DraftManager.init Redwood::DRAFT_DIR
     Redwood::UpdateManager.init
     Redwood::PollManager.init
-    Redwood::SuicideManager.init Redwood::SUICIDE_FN
     Redwood::CryptoManager.init
     Redwood::UndoManager.init
     Redwood::SourceManager.init
@@ -253,7 +264,6 @@ require "sup/modes/scroll-mode"
 require "sup/modes/text-mode"
 require "sup/modes/log-mode"
 require "sup/update"
-require "sup/suicide"
 require "sup/message-chunks"
 require "sup/message"
 require "sup/source"
@@ -263,6 +273,7 @@ require "sup/imap"
 require "sup/person"
 require "sup/account"
 require "sup/thread"
+require "sup/interactive-lock"
 require "sup/index"
 require "sup/textfield"
 require "sup/colormap"
@@ -293,6 +304,7 @@ require "sup/modes/buffer-list-mode"
 require "sup/modes/poll-mode"
 require "sup/modes/file-browser-mode"
 require "sup/modes/completion-mode"
+require "sup/modes/console-mode"
 require "sup/sent"
 
 $:.each do |base|
index 53479199ce6e06670bbb56cf577937cd5e2b59e8..67ccd86ea533f15e0706233314ebcdd21188af7e 100644 (file)
@@ -28,10 +28,12 @@ module Ncurses
   ## magically, this stuff seems to work now. i could swear it didn't
   ## before. hm.
   def nonblocking_getch
-    if IO.select([$stdin], nil, nil, 1)
-      Ncurses.getch
-    else
-      nil
+    ## INSANTIY
+    ## it is NECESSARY to wrap Ncurses.getch in a select() otherwise all
+    ## background threads will be BLOCKED. (except in very modern versions
+    ## of libncurses-ruby. the current one on ubuntu seems to work well.)
+    if IO.select([$stdin], nil, nil, 0.5)
+      c = Ncurses.getch
     end
   end
 
@@ -196,8 +198,13 @@ EOS
     @flash = nil
     @shelled = @asking = false
     @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/
+    @sigwinch_happened = false
+    @sigwinch_mutex = Mutex.new
   end
 
+  def sigwinch_happened!; @sigwinch_mutex.synchronize { @sigwinch_happened = true } end
+  def sigwinch_happened?; @sigwinch_mutex.synchronize { @sigwinch_happened } end
+
   def buffers; @name_map.to_a; end
 
   def focus_on buf
@@ -228,14 +235,20 @@ EOS
   ## have to change this. but it's not clear that we will ever actually
   ## do that.
   def roll_buffers
-    @buffers.last.force_to_top = false
-    raise_to_front @buffers.first
+    bufs = rollable_buffers
+    bufs.last.force_to_top = false
+    raise_to_front bufs.first
   end
 
   def roll_buffers_backwards
-    return unless @buffers.length > 1
-    @buffers.last.force_to_top = false
-    raise_to_front @buffers[@buffers.length - 2]
+    bufs = rollable_buffers
+    return unless bufs.length > 1
+    bufs.last.force_to_top = false
+    raise_to_front bufs[bufs.length - 2]
+  end
+
+  def rollable_buffers
+    @buffers.select { |b| !b.system? || @buffers.last == b }
   end
 
   def handle_input c
@@ -259,6 +272,14 @@ EOS
   def completely_redraw_screen
     return if @shelled
 
+    ## this magic makes Ncurses get the new size of the screen
+    Ncurses.endwin
+    Ncurses.stdscr.keypad 1
+    Ncurses.curs_set 0
+    Ncurses.refresh
+    @sigwinch_mutex.synchronize { @sigwinch_happened = false }
+    debug "new screen size is #{Ncurses.rows} x #{Ncurses.cols}"
+
     status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock
 
     Ncurses.sync do
@@ -482,7 +503,7 @@ EOS
   ## returns an array of labels
   def ask_for_labels domain, question, default_labels, forbidden_labels=[]
     default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS
-    default = default_labels.join(" ")
+    default = default_labels.to_a.join(" ")
     default += " " unless default.empty?
 
     # here I would prefer to give more control and allow all_labels instead of
index df1139d97b67d878fa84f9440a173206f11cf26f..2de8727f2e2652577d0ed4e8634caae8030003ae 100644 (file)
@@ -4,6 +4,16 @@ module Redwood
 
 class FerretIndex < BaseIndex
 
+  HookManager.register "custom-search", <<EOS
+Executes before a string search is applied to the index,
+returning a new search string.
+Variables:
+  subs: The string being searched. Be careful about shadowing:
+    this variable is actually a method, so use a temporary variable
+    or explicitly call self.subs; the substitutions in index.rb
+    don't actually work.
+EOS
+
   def initialize dir=BASE_DIR
     super
 
@@ -332,7 +342,9 @@ class FerretIndex < BaseIndex
   def parse_query s
     query = {}
 
-    subs = s.gsub(/\b(to|from):(\S+)\b/) do
+    subs = HookManager.run("custom-search", :subs => s) || s
+
+    subs = subs.gsub(/\b(to|from):(\S+)\b/) do
       field, name = $1, $2
       if(p = ContactManager.contact_for(name))
         [field, p.email]
index d0476dad95a78429513dfcb81a311d2a3cb72c86..0212c6bc9dfe26c738cab3d7f8b90929b7dc981a 100644 (file)
@@ -1,33 +1,11 @@
 module Redwood
 
 class HookManager
-  ## there's probably a better way to do this, but to evaluate a hook
-  ## with a bunch of pre-set "local variables" i define a function
-  ## per variable and then instance_evaluate the code.
-  ##
-  ## how does rails do it, when you pass :locals into a partial?
-  ##
-  ## i don't bother providing setters, since i'm pretty sure the
-  ## charade will fall apart pretty quickly with respect to scoping.
-  ## "fail-fast", we'll call it.
   class HookContext
     def initialize name
       @__say_id = nil
       @__name = name
-      @__locals = {}
-    end
-
-    attr_writer :__locals
-
-    def method_missing m, *a
-      case @__locals[m]
-      when Proc
-        @__locals[m] = @__locals[m].call(*a) # only call the proc once
-      when nil
-        super
-      else
-        @__locals[m]
-      end
+      @__cache = {}
     end
 
     def say s
@@ -60,12 +38,24 @@ class HookManager
       HookManager.tags[tag] = value
     end
 
-    def __binding 
-      binding
-    end
-
-    def __cleanup
+    def __run __hook, __filename, __locals
+      __binding = binding
+      __lprocs, __lvars = __locals.partition { |k, v| v.is_a?(Proc) }
+      eval __lvars.map { |k, v| "#{k} = __locals[#{k.inspect}];" }.join, __binding
+      ## we also support closures for delays evaluation. unfortunately
+      ## we have to do this via method calls, so you don't get all the
+      ## semantics of a regular variable. not ideal.
+      __lprocs.each do |k, v|
+        self.class.instance_eval do
+          define_method k do
+            @__cache[k] ||= v.call
+          end
+        end
+      end
+      ret = eval __hook, __binding, __filename
       BufferManager.clear @__say_id if @__say_id
+      @__cache = {}
+      ret
     end
   end
 
@@ -86,18 +76,16 @@ class HookManager
   def run name, locals={}
     hook = hook_for(name) or return
     context = @contexts[hook] ||= HookContext.new(name)
-    context.__locals = locals
 
     result = nil
     begin
-      result = context.instance_eval @hooks[name], fn_for(name)
+      result = context.__run hook, fn_for(name), locals
     rescue Exception => e
       log "error running hook: #{e.message}"
       log e.backtrace.join("\n")
       @hooks[name] = nil # disable it
       BufferManager.flash "Error running hook: #{e.message}" if BufferManager.instantiated?
     end
-    context.__cleanup
     result
   end
 
@@ -123,19 +111,20 @@ EOS
 
   def enabled? name; !hook_for(name).nil? end
 
+  def clear; @hooks.clear; end
+
 private
 
   def hook_for name
     unless @hooks.member? name
-      @hooks[name] =
-        begin
-          returning IO.read(fn_for(name)) do
-            debug "read '#{name}' from #{fn_for(name)}"
-          end
-        rescue SystemCallError => e
-          #log "disabled hook for '#{name}': #{e.message}"
-          nil
+      @hooks[name] = begin
+        returning IO.read(fn_for(name)) do
+          debug "read '#{name}' from #{fn_for(name)}"
         end
+      rescue SystemCallError => e
+        #log "disabled hook for '#{name}': #{e.message}"
+        nil
+      end
     end
 
     @hooks[name]
index 30b564402e245e3b46662a004fb9964bb94d16c4..bdb9e15b52fec1971b7e705ffa5e0ac6988e5446 100644 (file)
@@ -48,6 +48,7 @@ require 'set'
 module Redwood
 
 class IMAP < Source
+  include SerializeLabelsNicely
   SCAN_INTERVAL = 60 # seconds
 
   ## upon these errors we'll try to rereconnect a few times
index dfaeee819c1db70f410de829f001ccf21c19280c..ff03f195f6bca74aefdb23eedecc01c2172ee39c 100644 (file)
@@ -13,6 +13,8 @@ end
 module Redwood
 
 class BaseIndex
+  include InteractiveLock
+
   class LockError < StandardError
     def initialize h
       @h = h
@@ -53,42 +55,6 @@ class BaseIndex
     @lock_update_thread = nil
   end
 
-  def possibly_pluralize number_of, kind
-    "#{number_of} #{kind}" +
-        if number_of == 1 then "" else "s" end
-  end
-
-  def fancy_lock_error_message_for e
-    secs = (Time.now - e.mtime).to_i
-    mins = secs / 60
-    time =
-      if mins == 0
-        possibly_pluralize secs , "second"
-      else
-        possibly_pluralize mins, "minute"
-      end
-
-    <<EOS
-Error: the sup index is locked by another process! User '#{e.user}' on
-host '#{e.host}' is running #{e.pname} with pid #{e.pid}. The process was alive
-as of #{time} ago.
-EOS
-  end
-
-  def lock_or_die
-    begin
-      lock
-    rescue LockError => e
-      $stderr.puts fancy_lock_error_message_for(e)
-      $stderr.puts <<EOS
-
-You can wait for the process to finish, or, if it crashed and left a
-stale lock file behind, you can manually delete #{@lock.path}.
-EOS
-      exit
-    end
-  end
-
   def unlock
     if @lock && @lock.locked?
       debug "unlocking #{lockfile}..."
diff --git a/lib/sup/interactive-lock.rb b/lib/sup/interactive-lock.rb
new file mode 100644 (file)
index 0000000..92a5ead
--- /dev/null
@@ -0,0 +1,74 @@
+require 'fileutils'
+
+module Redwood
+
+## wrap a nice interactive layer on top of anything that has a #lock method
+## which throws a LockError which responds to #user, #host, #mtim, #pname, and
+## #pid.
+
+module InteractiveLock
+  def pluralize number_of, kind; "#{number_of} #{kind}" + (number_of == 1 ? "" : "s") end
+
+  def time_ago_in_words time
+    secs = (Time.now - time).to_i
+    mins = secs / 60
+    time = if mins == 0
+      pluralize secs, "second"
+    else
+      pluralize mins, "minute"
+    end
+  end
+
+  DELAY = 5 # seconds
+
+  def lock_interactively stream=$stderr
+    begin
+      Index.lock
+    rescue Index::LockError => e
+      stream.puts <<EOS
+Error: the index is locked by another process! User '#{e.user}' on
+host '#{e.host}' is running #{e.pname} with pid #{e.pid}.
+The process was alive as of at least #{time_ago_in_words e.mtime} ago.
+
+EOS
+      stream.print "Should I ask that process to kill itself (y/n)? "
+      stream.flush
+
+      success = if $stdin.gets =~ /^\s*y(es)?\s*$/i
+        stream.puts "Ok, trying to kill process..."
+
+        begin
+          Process.kill "TERM", e.pid.to_i
+          sleep DELAY
+        rescue Errno::ESRCH # no such process
+          stream.puts "Hm, I couldn't kill it."
+        end
+
+        stream.puts "Let's try that again."
+        begin
+          Index.lock
+        rescue Index::LockError => e
+          stream.puts "I couldn't lock the index. The lockfile might just be stale."
+          stream.print "Should I just remove it and continue? (y/n) "
+          stream.flush
+
+          if $stdin.gets =~ /^\s*y(es)?\s*$/i
+            FileUtils.rm e.path
+
+            stream.puts "Let's try that one more time."
+            begin
+              Index.lock
+              true
+            rescue Index::LockError => e
+            end
+          end
+        end
+      end
+
+      stream.puts "Sorry, couldn't unlock the index." unless success
+      success
+    end
+  end
+end
+
+end
index 3661abbce40eb703c2314a37f6dd106e414ffd62..2c33e3bdc06b08f68b1d4b3caf564021eb6d71b8 100644 (file)
@@ -9,6 +9,7 @@ module Redwood
 ## pathnames on disk.
 
 class Maildir < Source
+  include SerializeLabelsNicely
   SCAN_INTERVAL = 30 # seconds
   MYHOSTNAME = Socket.gethostname
 
@@ -212,7 +213,7 @@ private
 
   def maildir_data msg
     fn = File.basename @ids_to_fns[msg]
-    fn =~ %r{^([^:,]+):([12]),([DFPRST]*)$}
+    fn =~ %r{^([^:]+):([12]),([DFPRST]*)$}
     [($1 || fn), ($2 || "2"), ($3 || "")]
   end
 
index 26177f76bb12041c7cbfee16414f656a6dd15afc..030759483289f2ee6bd115b818e9dfecb5a986de 100644 (file)
@@ -6,6 +6,7 @@ module Redwood
 module MBox
 
 class Loader < Source
+  include SerializeLabelsNicely
   yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
 
   ## uri_or_fp is horrific. need to refactor.
index 1eda17438df45e08eaa6f5269fca073e1714bb19..ce7d1ee4f5fde1e1df37d1ed5c6223e6ba5b3481 100644 (file)
@@ -131,9 +131,9 @@ EOS
     def initial_state; :open end
     def viewable?; @lines.nil? end
     def view_default! path
-      cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}' 2>/dev/null"
+      cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}'"
       debug "running: #{cmd.inspect}"
-      system cmd
+      BufferManager.shell_out(cmd)
       $? == 0
     end
 
@@ -208,13 +208,25 @@ EOS
 
   class EnclosedMessage
     attr_reader :lines
-    def initialize from, body
-      @from = from
-      @lines = body.split "\n"
-    end
+    def initialize from, to, cc, date, subj
+      @from = from ? "unknown sender" : from.full_adress
+      @to = to ? "" : to.map { |p| p.full_address }.join(", ")
+      @cc = cc ? "" : cc.map { |p| p.full_address }.join(", ")
+      if date
+        @date = date.rfc822
+      else
+        @date = ""
+      end
 
-    def from
-      @from ? @from.longname : "unknown sender"
+      @subj = subj
+
+      @lines = "\nFrom: #{from}\n"
+      @lines += "To: #{to}\n"
+      if !cc.empty?
+        @lines += "Cc: #{cc}\n"
+      end
+      @lines += "Date: #{date}\n"
+      @lines += "Subject: #{subj}\n\n"
     end
 
     def inlineable?; false end
@@ -224,7 +236,7 @@ EOS
     def viewable?; false end
 
     def patina_color; :generic_notice_patina_color end
-    def patina_text; "Begin enclosed message from #{from} (#{@lines.length} lines)" end
+    def patina_text; "Begin enclosed message sent on #{@date}" end
 
     def color; :quote_color end
   end
index ed27d3dccfba056410bd90225604479f8e788797..56e66de3dd744b8e35eb3e80441aff9a38edd696 100644 (file)
@@ -127,6 +127,31 @@ class Message
     @list_unsubscribe = header["list-unsubscribe"]
   end
 
+  ## Expected index entry format:
+  ## :message_id, :subject => String
+  ## :date => Time
+  ## :refs, :replytos => Array of String
+  ## :from => Person
+  ## :to, :cc, :bcc => Array of Person
+  def load_from_index! entry
+    @id = entry[:message_id]
+    @from = entry[:from]
+    @date = entry[:date]
+    @subj = entry[:subject]
+    @to = entry[:to]
+    @cc = entry[:cc]
+    @bcc = entry[:bcc]
+    @refs = (@refs + entry[:refs]).uniq
+    @replytos = entry[:replytos]
+
+    @replyto = nil
+    @list_address = nil
+    @recipient_email = nil
+    @source_marked_read = false
+    @list_subscribe = nil
+    @list_unsubscribe = nil
+  end
+
   def add_ref ref
     @refs << ref
     @dirty = true
@@ -406,10 +431,19 @@ private
       chunks
     elsif m.header.content_type == "message/rfc822"
       payload = RMail::Parser.read(m.body)
-      from = payload.header.from.first
-      from_person = from ? Person.from_address(from.format) : nil
-      [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
-        message_to_chunks(payload, encrypted)
+      from = payload.header.from.first ? payload.header.from.first.format : ""
+      to = payload.header.to.map { |p| p.format }.join(", ")
+      cc = payload.header.cc.map { |p| p.format }.join(", ")
+      subj = payload.header.subject
+      subj = subj ? Message.normalize_subj(payload.header.subject.gsub(/\s+/, " ").gsub(/\s+$/, "")) : subj
+      if Rfc2047.is_encoded? subj
+        subj = Rfc2047.decode_to $encoding, subj
+      end
+      msgdate = payload.header.date
+      from_person = from ? Person.from_address(from) : nil
+      to_people = to ? Person.from_address_list(to) : nil
+      cc_people = cc ? Person.from_address_list(cc) : nil
+      [Chunk::EnclosedMessage.new(from_person, to_people, cc_people, msgdate, subj)] + message_to_chunks(payload, encrypted)
     else
       filename =
         ## first, paw through the headers looking for a filename
diff --git a/lib/sup/modes/console-mode.rb b/lib/sup/modes/console-mode.rb
new file mode 100644 (file)
index 0000000..af3d66d
--- /dev/null
@@ -0,0 +1,103 @@
+require 'pp'
+
+module Redwood
+
+class Console
+  def initialize mode
+    @mode = mode
+  end
+
+  def query(query)
+    Enumerable::Enumerator.new(Index, :each_message, Index.parse_query(query))
+  end
+
+  def add_labels(query, *labels)
+    query(query).each { |m| m.labels += labels; m.save Index }
+  end
+
+  def remove_labels(query, *labels)
+    query(query).each { |m| m.labels -= labels; m.save Index }
+  end
+
+  def xapian; Index.instance.instance_variable_get :@xapian; end
+  def ferret; Index.instance.instance_variable_get :@index; end
+
+  ## files that won't cause problems when reloaded
+  ## TODO expand this list / convert to blacklist
+  RELOAD_WHITELIST = %w(sup/xapian_index.rb sup/modes/console-mode.rb)
+
+  def reload
+    old_verbose = $VERBOSE
+    $VERBOSE = nil
+    old_features = $".dup
+    begin
+      fs = $".grep(/^sup\//)
+      fs.reject! { |f| not RELOAD_WHITELIST.member? f }
+      fs.each { |f| $".delete f }
+      fs.each do |f|
+        @mode << "reloading #{f}\n"
+        begin
+          require f
+        rescue LoadError => e
+          raise unless e.message =~ /no such file to load/
+        end
+      end
+    rescue Exception
+      $".clear
+      $".concat old_features
+      raise
+    ensure
+      $VERBOSE = old_verbose
+    end
+    true
+  end
+
+  def clear_hooks
+    HookManager.clear
+    nil
+  end
+end
+
+class ConsoleMode < LogMode
+  register_keymap do |k|
+    k.add :run, "Restart evaluation", 'e'
+  end
+
+  def initialize
+    super "console"
+    @console = Console.new self
+    @binding = @console.instance_eval { binding }
+    self << <<EOS
+Sup #{VERSION} console.
+Available commands: #{(@console.methods - Object.methods) * ", "}
+Ctrl-g stops evaluation; 'e' restarts it.
+
+EOS
+  end
+
+  def execute cmd
+    begin
+      self << ">> #{cmd}\n"
+      ret = eval cmd, @binding
+      self << "=> #{ret.pretty_inspect}\n"
+    rescue Exception
+      self << "#{$!.class}: #{$!.message}\n"
+      clean_backtrace = []
+      $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
+      clean_backtrace.each { |l| self << "#{l}\n" }
+    end
+  end
+
+  def prompt
+    BufferManager.ask :console, "eval: "
+  end
+
+  def run
+    while true
+      cmd = prompt or return
+      execute cmd
+    end
+  end
+end
+
+end
index 700dfc1f99d310a0290e0bfcab4e8478957ca678..3d39a8ae602b9bdc927193da9cae59240b771b16 100644 (file)
@@ -40,7 +40,7 @@ Return value:
   The reply mode you desire, or nil to use the default behavior.
 EOS
 
-  def initialize message
+  def initialize message, type_arg=nil
     @m = message
 
     ## it's important to put this early because it forces a read of
@@ -138,7 +138,9 @@ EOS
     hook_reply = HookManager.run "reply-to", :modes => types
 
     @type_selector.set_to(
-      if types.include? hook_reply
+      if types.include? type_arg
+        type_arg
+      elsif types.include? hook_reply
         hook_reply
       elsif @m.is_list_message?
         :list
index fb6b2ce71c69b68011c622ef31b572f5288497d7..d1b7fdba7899fc07f324a6f5a4c21a709bb6fc84 100644 (file)
@@ -40,6 +40,7 @@ EOS
     k.add :save, "Save changes now", '$'
     k.add :jump_to_next_new, "Jump to next new thread", :tab
     k.add :reply, "Reply to latest message in a thread", 'r'
+    k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
     k.add :forward, "Forward latest message in a thread", 'f'
     k.add :toggle_tagged, "Tag/untag selected thread", 't'
     k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
@@ -584,15 +585,17 @@ EOS
     end
   end
 
-  def reply
+  def reply type_arg=nil
     t = cursor_thread or return
     m = t.latest_message
     return if m.nil? # probably won't happen
     m.load_from_source!
-    mode = ReplyMode.new m
+    mode = ReplyMode.new m, type_arg
     BufferManager.spawn "Reply to #{m.subj}", mode
   end
 
+  def reply_all; reply :all; end
+
   def forward
     t = cursor_thread or return
     m = t.latest_message
index bac4792e832daafb1ba9964d0e3cb2387c1bc9b6..27167cbdd9750158f0a76dda51b4c11740a99c9a 100644 (file)
@@ -53,6 +53,7 @@ EOS
     k.add :toggle_new, "Toggle unread/read status of message", 'N'
 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
     k.add :reply, "Reply to a message", 'r'
+    k.add :reply_all, "Reply to all participants of this message", 'G'
     k.add :forward, "Forward a message or attachment", 'f'
     k.add :bounce, "Bounce message to other recipient(s)", '!'
     k.add :alias, "Edit alias/nickname for a person", 'i'
@@ -161,12 +162,14 @@ EOS
     update
   end
 
-  def reply
+  def reply type_arg=nil
     m = @message_lines[curpos] or return
-    mode = ReplyMode.new m
+    mode = ReplyMode.new m, type_arg
     BufferManager.spawn "Reply to #{m.subj}", mode
   end
 
+  def reply_all; reply :all; end
+
   def subscribe_to_list
     m = @message_lines[curpos] or return
     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
@@ -253,7 +256,7 @@ EOS
     new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels
 
     return unless new_labels
-    @thread.labels = (reserved_labels + new_labels).uniq
+    @thread.labels = Set.new(reserved_labels) + new_labels
     new_labels.each { |l| LabelManager << l }
     update
     UpdateManager.relay self, :labeled, @thread.first
index 46fe5c5ed2cc700c294e92a2a802be47fdd754cd..e8da5570fb44687aa1339afffa4c9b8836289005 100644 (file)
@@ -11,6 +11,12 @@ Variables:
   message: the new message
 EOS
 
+  HookManager.register "after-add-message", <<EOS
+Executes after all messages are added to the index.
+Variables:
+  messages: an array of the new messages added
+EOS
+
   HookManager.register "before-poll", <<EOS
 Executes immediately before a poll for new messages commences.
 No variables.
@@ -137,6 +143,7 @@ EOS
     begin
       return if source.done? || source.has_errors?
 
+      messages = []
       source.each do |offset, source_labels|
         if source.has_errors?
           warn "error loading messages from #{source}: #{source.error.message}"
@@ -147,10 +154,13 @@ EOS
         m.labels += source_labels + (source.archived? ? [] : [:inbox])
         m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
         m.labels.each { |l| LabelManager << l }
+        messages.push m
 
         HookManager.run "before-add-message", :message => m
         yield m
       end
+      HookManager.run "after-add-message", :messages => messages
+
     rescue SourceError => e
       warn "problem getting messages from #{source}: #{e.message}"
       Redwood::report_broken_sources :force_to_top => true
index 8154591f6adce27c071e16d11784f8dd77b05888..78386ff3fd19b0f74c75820827346df036f84c61 100644 (file)
@@ -161,6 +161,21 @@ protected
   end
 end
 
+## if you have a @labels instance variable, include this
+## to serialize them nicely as an array, rather than as a
+## nasty set.
+module SerializeLabelsNicely
+  def before_marshal # can return an object
+    c = clone
+    c.instance_eval { @labels = @labels.to_a.map { |l| l.to_s } }
+    c
+  end
+
+  def after_unmarshal!
+    @labels = Set.new(@labels.map { |s| s.to_sym })
+  end
+end
+
 class SourceManager
   include Singleton
 
@@ -209,7 +224,7 @@ class SourceManager
           File.chmod 0600, fn
           FileUtils.mv fn, bakfn, :force => true unless File.exists?(bakfn) && File.size(fn) == 0
         end
-        Redwood::save_yaml_obj sources.sort_by { |s| s.id.to_i }, fn, true
+        Redwood::save_yaml_obj sources, fn, true
         File.chmod 0600, fn
       end
       @sources_dirty = false
diff --git a/lib/sup/suicide.rb b/lib/sup/suicide.rb
deleted file mode 100644 (file)
index bef6325..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-module Redwood
-
-class SuicideManager
-  include Singleton
-
-  DELAY = 5
-
-  def initialize fn
-    @fn = fn
-    @die = false
-    @thread = nil
-    FileUtils.rm_f @fn
-  end
-
-  bool_reader :die
-
-  def start
-    @thread = Redwood::reporting_thread("suicide watch") do
-      while true
-        sleep DELAY
-        if File.exists? @fn
-          FileUtils.rm_f @fn
-          @die = true
-        end
-      end
-    end
-  end
-
-  def stop
-    @thread.kill if @thread
-    @thread = nil
-  end
-end
-
-end
index b8dec59d368af5508ee85b40ae78a3dade452f83..76803bf97c9a00bc394a6f9e59c4f26b6b318b9e 100644 (file)
@@ -35,9 +35,9 @@ class TextField
     @completion_block = block
     @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 256, 0
     @form = Ncurses::Form.new_form [@field]
-    @value = default
+    @value = default || ''
     Ncurses::Form.post_form @form
-    set_cursed_value default if default
+    set_cursed_value @value
   end
 
   def position_cursor
index ca3f30d08cffabdab36225ac68e274fd5092184c..2300305c0710da7dfcb55891b5967abdc27e7f8f 100644 (file)
@@ -357,7 +357,7 @@ class ThreadSet
     return if threads.size < 2
 
     containers = threads.map do |t|
-      c = @messages.member?(c) ? @messages[t.first.id] : nil
+      c = @messages.member?(t.first.id) ? @messages[t.first.id] : nil
       raise "not in threadset: #{t.first.id}" unless c && c.message
       c
     end
index 9c1a84ad2077f8308f5e394e1430474eae6c493f..f99e1c17c79a3add8d5da43901c6b161d5c7170d 100644 (file)
@@ -25,6 +25,7 @@ class Lockfile
   def lockinfo_on_disk
     h = load_lock_id IO.read(path)
     h['mtime'] = File.mtime path
+    h['path'] = path
     h
   end
 
@@ -91,7 +92,7 @@ end
 
 class Range
   ## only valid for integer ranges (unless I guess it's exclusive)
-  def size 
+  def size
     last - first + (exclude_end? ? 0 : 1)
   end
 end
@@ -176,7 +177,7 @@ class String
   ## nasty multibyte hack for ruby 1.8. if it's utf-8, split into chars using
   ## the utf8 regex and count those. otherwise, use the byte length.
   def display_length
-    if $encoding == "UTF-8"
+    if $encoding == "UTF-8" || $encoding == "utf8"
       scan(/./u).size
     else
       size
index dbf66431bc263ada5420b9a981f766c9ca5675d4..c2607283fd20e61a37be94b39fc84d5ec88cf2ee 100644 (file)
@@ -1,5 +1,4 @@
 require 'xapian'
-require 'gdbm'
 require 'set'
 
 module Redwood
@@ -9,6 +8,7 @@ module Redwood
 # for searching due to precomputing thread membership.
 class XapianIndex < BaseIndex
   STEM_LANGUAGE = "english"
+  INDEX_VERSION = '1'
 
   ## dates are converted to integers for xapian, and are used for document ids,
   ## so we must ensure they're reasonably valid. this typically only affect
@@ -23,13 +23,18 @@ class XapianIndex < BaseIndex
   end
 
   def load_index
-    @entries = MarshalledGDBM.new File.join(@dir, "entries.db")
-    @docids = MarshalledGDBM.new File.join(@dir, "docids.db")
-    @thread_members = MarshalledGDBM.new File.join(@dir, "thread_members.db")
-    @thread_ids = MarshalledGDBM.new File.join(@dir, "thread_ids.db")
-    @assigned_docids = GDBM.new File.join(@dir, "assigned_docids.db")
-
-    @xapian = Xapian::WritableDatabase.new(File.join(@dir, "xapian"), Xapian::DB_CREATE_OR_OPEN)
+    path = File.join(@dir, 'xapian')
+    if File.exists? path
+      @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN)
+      db_version = @xapian.get_metadata 'version'
+      db_version = '0' if db_version.empty?
+      if db_version != INDEX_VERSION
+        fail "This Sup version expects a v#{INDEX_VERSION} index, but you have an existing v#{db_version} index. Please downgrade to your previous version and dump your labels before upgrading to this version (then run sup-sync --restore)."
+      end
+    else
+      @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_CREATE)
+      @xapian.set_metadata 'version', INDEX_VERSION
+    end
     @term_generator = Xapian::TermGenerator.new()
     @term_generator.stemmer = Xapian::Stem.new(STEM_LANGUAGE)
     @enquire = Xapian::Enquire.new @xapian
@@ -48,43 +53,35 @@ class XapianIndex < BaseIndex
   end
 
   def contains_id? id
-    synchronize { @entries.member? id }
+    synchronize { find_docid(id) && true }
   end
 
   def source_for_id id
-    synchronize { @entries[id][:source_id] }
+    synchronize { get_entry(id)[:source_id] }
   end
 
   def delete id
-    synchronize { @xapian.delete_document @docids[id] }
+    synchronize { @xapian.delete_document mkterm(:msgid, id) }
   end
 
   def build_message id
-    entry = synchronize { @entries[id] }
+    entry = synchronize { get_entry id }
     return unless entry
 
     source = SourceManager[entry[:source_id]]
     raise "invalid source #{entry[:source_id]}" unless source
 
-    mk_addrs = lambda { |l| l.map { |e,n| "#{n} <#{e}>" } * ', ' }
-    mk_refs = lambda { |l| l.map { |r| "<#{r}>" } * ' ' }
-    fake_header = {
-      'message-id' => entry[:message_id],
-      'date' => Time.at(entry[:date]),
-      'subject' => entry[:subject],
-      'from' => mk_addrs[[entry[:from]]],
-      'to' => mk_addrs[entry[:to]],
-      'cc' => mk_addrs[entry[:cc]],
-      'bcc' => mk_addrs[entry[:bcc]],
-      'reply-tos' => mk_refs[entry[:replytos]],
-      'references' => mk_refs[entry[:refs]],
-     }
-
-      m = Message.new :source => source, :source_info => entry[:source_info],
-                  :labels => entry[:labels],
-                  :snippet => entry[:snippet]
-      m.parse_header fake_header
-      m
+    m = Message.new :source => source, :source_info => entry[:source_info],
+                    :labels => entry[:labels], :snippet => entry[:snippet]
+
+    mk_person = lambda { |x| Person.new(*x.reverse!) }
+    entry[:from] = mk_person[entry[:from]]
+    entry[:to].map!(&mk_person)
+    entry[:cc].map!(&mk_person)
+    entry[:bcc].map!(&mk_person)
+
+    m.load_from_index! entry
+    m
   end
 
   def add_message m; sync_message m end
@@ -92,7 +89,7 @@ class XapianIndex < BaseIndex
   def update_message_state m; sync_message m end
 
   def sync_message m, opts={}
-    entry = synchronize { @entries[m.id] }
+    entry = synchronize { get_entry m.id }
     snippet = m.snippet
     entry ||= {}
     labels = m.labels
@@ -117,9 +114,7 @@ class XapianIndex < BaseIndex
     labels.each { |l| LabelManager << l }
 
     synchronize do
-      index_message m, opts
-      union_threads([m.id] + m.refs + m.replytos)
-      @entries[m.id] = d
+      index_message m, d, opts
     end
     true
   end
@@ -152,8 +147,26 @@ class XapianIndex < BaseIndex
   def each_message_in_thread_for m, opts={}
     # TODO thread by subject
     # TODO handle killed threads
-    ids = synchronize { @thread_members[@thread_ids[m.id]] } || []
-    ids.select { |id| contains_id? id }.each { |id| yield id, lambda { build_message id } }
+    return unless doc = find_doc(m.id)
+    queue = doc.value(THREAD_VALUENO).split(',')
+    msgids = [m.id]
+    seen_threads = Set.new
+    seen_messages = Set.new [m.id]
+    while not queue.empty?
+      thread_id = queue.pop
+      next if seen_threads.member? thread_id
+      return false if thread_killed? thread_id
+      seen_threads << thread_id
+      docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x }
+      docs.each do |doc|
+        msgid = doc.value MSGID_VALUENO
+        next if seen_messages.member? msgid
+        msgids << msgid
+        seen_messages << msgid
+        queue.concat doc.value(THREAD_VALUENO).split(',')
+      end
+    end
+    msgids.each { |id| yield id, lambda { build_message id } }
     true
   end
 
@@ -302,11 +315,16 @@ class XapianIndex < BaseIndex
     'label' => 'L',
     'source_id' => 'I',
     'attachment_extension' => 'O',
+    'msgid' => 'Q',
+    'thread' => 'H',
+    'ref' => 'R',
   }
 
   PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
 
-  DATE_VALUENO = 0
+  MSGID_VALUENO = 0
+  THREAD_VALUENO = 1
+  DATE_VALUENO = 2
 
   MAX_TERM_LENGTH = 245
 
@@ -322,14 +340,48 @@ class XapianIndex < BaseIndex
   def assign_docid m, truncated_date
     t = (truncated_date.to_i - MIDDLE_DATE.to_i).to_f
     docid = (DOCID_SCALE - DOCID_SCALE/(Math::E**(-(t/TIME_SCALE)) + 1)).to_i
+    while docid > 0 and docid_exists? docid
+      docid -= 1
+    end
+    docid > 0 ? docid : nil
+  end
+
+  # XXX is there a better way?
+  def docid_exists? docid
     begin
-      while @assigned_docids.member? [docid].pack("N")
-        docid -= 1
-      end
-    rescue
+      @xapian.doclength docid
+      true
+    rescue RuntimeError #Xapian::DocNotFoundError
+      raise unless $!.message =~ /DocNotFoundError/
+      false
     end
-    @assigned_docids[[docid].pack("N")] = ''
-    docid
+  end
+
+  def term_docids term
+    @xapian.postlist(term).map { |x| x.docid }
+  end
+
+  def find_docid id
+    term_docids(mkterm(:msgid,id)).tap { |x| fail unless x.size <= 1 }.first
+  end
+
+  def find_doc id
+    return unless docid = find_docid(id)
+    @xapian.document docid
+  end
+
+  def get_id docid
+    return unless doc = @xapian.document(docid)
+    doc.value MSGID_VALUENO
+  end
+
+  def get_entry id
+    return unless doc = find_doc(id)
+    Marshal.load doc.data
+  end
+
+  def thread_killed? thread_id
+    not run_query(Q.new(Q::OP_AND, mkterm(:thread, thread_id), mkterm(:label, :Killed)), 0, 1).empty?
   end
 
   def synchronize &b
@@ -345,7 +397,7 @@ class XapianIndex < BaseIndex
 
   def run_query_ids xapian_query, offset, limit
     matchset = run_query xapian_query, offset, limit
-    matchset.matches.map { |r| r.document.data }
+    matchset.matches.map { |r| r.document.value MSGID_VALUENO }
   end
 
   Q = Xapian::Query
@@ -376,7 +428,7 @@ class XapianIndex < BaseIndex
     end
   end
 
-  def index_message m, opts
+  def index_message m, entry, opts
     terms = []
     text = []
 
@@ -399,6 +451,7 @@ class XapianIndex < BaseIndex
     terms << mkterm(:date,m.date) if m.date
     m.labels.each { |t| terms << mkterm(:label,t) }
     terms << mkterm(:type, 'mail')
+    terms << mkterm(:msgid, m.id)
     terms << mkterm(:source_id, m.source.id)
     m.attachments.each do |a|
       a =~ /\.(\w+)$/ or next
@@ -406,6 +459,23 @@ class XapianIndex < BaseIndex
       terms << t
     end
 
+    ## Thread membership
+    children = term_docids(mkterm(:ref, m.id)).map { |docid| @xapian.document docid }
+    parent_ids = m.refs + m.replytos
+    parents = parent_ids.map { |id| find_doc id }.compact
+    thread_members = SavingHash.new { [] }
+    (children + parents).each do |doc2|
+      thread_ids = doc2.value(THREAD_VALUENO).split ','
+      thread_ids.each { |thread_id| thread_members[thread_id] << doc2 }
+    end
+
+    thread_ids = thread_members.empty? ? [m.id] : thread_members.keys
+
+    thread_ids.each { |thread_id| terms << mkterm(:thread, thread_id) }
+    parent_ids.each do |ref|
+      terms << mkterm(:ref, ref)
+    end
+
     # Full text search content
     text << [subject_text, PREFIX['subject']]
     text << [subject_text, PREFIX['body']]
@@ -429,17 +499,29 @@ class XapianIndex < BaseIndex
       Xapian.sortable_serialise 0
     end
 
-    doc = Xapian::Document.new
-    docid = @docids[m.id] || assign_docid(m, truncated_date)
+    docid = nil
+    unless doc = find_doc(m.id)
+      doc = Xapian::Document.new
+      if not docid = assign_docid(m, truncated_date)
+        # Could be triggered by spam
+        Redwood::log "warning: docid underflow, dropping #{m.id.inspect}"
+        return
+      end
+    else
+      doc.clear_terms
+      doc.clear_values
+      docid = doc.docid
+    end
 
     @term_generator.document = doc
     text.each { |text,prefix| @term_generator.index_text text, 1, prefix }
     terms.each { |term| doc.add_term term if term.length <= MAX_TERM_LENGTH }
+    doc.add_value MSGID_VALUENO, m.id
+    doc.add_value THREAD_VALUENO, (thread_ids * ',')
     doc.add_value DATE_VALUENO, date_value
-    doc.data = m.id
+    doc.data = Marshal.dump entry
 
     @xapian.replace_document docid, doc
-    @docids[m.id] = docid
   end
 
   # Construct a Xapian term
@@ -462,48 +544,13 @@ class XapianIndex < BaseIndex
       PREFIX['source_id'] + args[0].to_s.downcase
     when :attachment_extension
       PREFIX['attachment_extension'] + args[0].to_s.downcase
+    when :msgid, :ref, :thread
+      PREFIX[type.to_s] + args[0][0...(MAX_TERM_LENGTH-1)]
     else
       raise "Invalid term type #{type}"
     end
   end
 
-  # Join all the given message-ids into a single thread
-  def union_threads ids
-    seen_threads = Set.new
-    related = Set.new
-
-    # Get all the ids that will be in the new thread
-    ids.each do |id|
-      related << id
-      thread_id = @thread_ids[id]
-      if thread_id && !seen_threads.member?(thread_id)
-        thread_members = @thread_members[thread_id]
-        related.merge thread_members
-        seen_threads << thread_id
-      end
-    end
-
-    # Pick a leader and move all the others to its thread
-    a = related.to_a
-    best, *rest = a.sort_by { |x| x.hash }
-    @thread_members[best] = a
-    @thread_ids[best] = best
-    rest.each do |x|
-      @thread_members.delete x
-      @thread_ids[x] = best
-    end
-  end
 end
 
 end
-
-class MarshalledGDBM < GDBM
-  def []= k, v
-    super k, Marshal.dump(v)
-  end
-
-  def [] k
-    v = super k
-    v ? Marshal.load(v) : nil
-  end
-end