]> git.notmuchmail.org Git - sup/commitdiff
Merge branch 'logging-tweaks'
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Tue, 8 Sep 2009 19:25:55 +0000 (15:25 -0400)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Tue, 8 Sep 2009 19:25:55 +0000 (15:25 -0400)
Conflicts:
lib/sup/hook.rb

26 files changed:
Manifest.txt [deleted file]
bin/sup
bin/sup-add
bin/sup-config
bin/sup-sync
bin/sup-sync-back
bin/sup-tweak-labels
lib/sup.rb
lib/sup/buffer.rb
lib/sup/crypto.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/thread-view-mode.rb
lib/sup/sent.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..605c55342b11b1cdfd4e8fc9bfd64a98ba154129 100755 (executable)
--- a/bin/sup
+++ b/bin/sup
@@ -78,6 +78,7 @@ 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'
 end
 
 ## the following magic enables wide characters when used with a ruby
@@ -130,36 +131,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 +199,6 @@ begin
 
   unless $opts[:no_threads]
     PollManager.start
-    SuicideManager.start
     Index.start_lock_update_thread
   end
 
@@ -226,30 +206,40 @@ 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 =
-      begin
-        if bm.handle_input c
-          :nothing
-        else
-          bm.resolve_input_with_keymap c, global_keymap
-        end
-      rescue InputSequenceAborted
+    action = begin
+      if bm.handle_input c
         :nothing
+      else
+        bm.resolve_input_with_keymap c, global_keymap
       end
+    rescue InputSequenceAborted
+      :nothing
+    end
     case action
     when :quit_now
       break if bm.kill_all_buffers_safely
@@ -305,6 +295,8 @@ 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 :nothing, InputSequenceAborted
     when :redraw
       bm.completely_redraw_screen
@@ -315,13 +307,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 +324,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 bd55fc1825fad84b6cf6cc8c0185d52effbc5adf..b37e0b2edce0417e8ac43ad1959be8c97460cf13 100755 (executable)
@@ -17,15 +17,14 @@ Usage:
 
 Options:
 EOS
-end #' stupid ruby-mode
+end
 
 def axe q, default=nil
-  ans = 
-    if default && !default.empty?
-      ask "#{q} (enter for \"#{default}\"): "
-    else
-      ask "#{q}: "
-    end
+  ans = if default && !default.empty?
+    ask "#{q} (enter for \"#{default}\"): "
+  else
+    ask "#{q}: "
+  end
   ans.empty? ? default : ans
 end
 
@@ -54,64 +53,62 @@ def add_source
   while true do
     say "Ok, now for the details."
 
-    default_labels, components = 
-      case type
-      when :mbox
-        $last_fn ||= ENV["MAIL"]
-        fn = axe "What's the full path to the mbox file?", $last_fn #"srm
-        return if fn.nil? || fn.empty?
-
-        $last_fn = fn
-        [Redwood::MBox::Loader.suggest_labels_for(fn),
-         { :scheme => "mbox", :path => fn }]
-      when :maildir
-        $last_fn ||= ENV["MAIL"]
-        fn = axe "What's the full path to the maildir directory?", $last_fn #"srm
-        return if fn.nil? || fn.empty?
-
-        $last_fn = fn
-        [Redwood::Maildir.suggest_labels_for(fn),
-         { :scheme => "maildir", :path => fn }]
-      when :mboxssh
-        $last_server ||= "localhost"
-        srv = axe "What machine is the mbox file located on?", $last_server 
-        return if srv.nil? || srv.empty?
-        $last_server = srv
-
-        fn = axe "What's the path to the mbox file?", $last_fn #" stupid ruby-mode
-        return if fn.nil? || fn.empty?
-        $last_fn = fn
-        fn = "/#{fn}" # lame
-        [Redwood::MBox::SSHLoader.suggest_labels_for(fn),
-         { :scheme => "mbox+ssh", :host => srv, :path => fn }]
-      when :imap, :imaps
-        $last_server ||= "localhost"
-        srv = axe "What is the IMAP server (host, or host:port notation)?", $last_server
-        return if srv.nil? || srv.empty?
-        $last_server = srv
-
-        $last_folder ||= "INBOX"
-        fn = axe "What's the folder path?", $last_folder #"srm 
-        return if fn.nil? || fn.empty?
-        $last_folder = fn
-
-        fn = "/#{fn}" # lame
-        if srv =~ /^(\S+):(\d+)$/
-          host, port = $1, $2.to_i
-        else
-          host, port = srv, nil
-        end
-        [Redwood::IMAP.suggest_labels_for(fn),
-         { :scheme => type.to_s, :host => host, :port => port, :path => fn }]
-      end
-    
-    uri = 
-      begin
-        URI::Generic.build components
-      rescue URI::Error => e
-        say "Whoopsie! I couldn't build a URI from that: #{e.message}"
-        if axe_yes("Try again?") then next else return end
+    default_labels, components = case type
+    when :mbox
+      $last_fn ||= ENV["MAIL"]
+      fn = axe "What's the full path to the mbox file?", $last_fn
+      return if fn.nil? || fn.empty?
+
+      $last_fn = fn
+      [Redwood::MBox::Loader.suggest_labels_for(fn),
+       { :scheme => "mbox", :path => fn }]
+    when :maildir
+      $last_fn ||= ENV["MAIL"]
+      fn = axe "What's the full path to the maildir directory?", $last_fn
+      return if fn.nil? || fn.empty?
+
+      $last_fn = fn
+      [Redwood::Maildir.suggest_labels_for(fn),
+       { :scheme => "maildir", :path => fn }]
+    when :mboxssh
+      $last_server ||= "localhost"
+      srv = axe "What machine is the mbox file located on?", $last_server
+      return if srv.nil? || srv.empty?
+      $last_server = srv
+
+      fn = axe "What's the path to the mbox file?", $last_fn
+      return if fn.nil? || fn.empty?
+      $last_fn = fn
+      fn = "/#{fn}" # lame
+      [Redwood::MBox::SSHLoader.suggest_labels_for(fn),
+       { :scheme => "mbox+ssh", :host => srv, :path => fn }]
+    when :imap, :imaps
+      $last_server ||= "localhost"
+      srv = axe "What is the IMAP server (host, or host:port notation)?", $last_server
+      return if srv.nil? || srv.empty?
+      $last_server = srv
+
+      $last_folder ||= "INBOX"
+      fn = axe "What's the folder path?", $last_folder
+      return if fn.nil? || fn.empty?
+      $last_folder = fn
+
+      fn = "/#{fn}"
+      if srv =~ /^(\S+):(\d+)$/
+        host, port = $1, $2.to_i
+      else
+        host, port = srv, nil
       end
+      [Redwood::IMAP.suggest_labels_for(fn),
+       { :scheme => type.to_s, :host => host, :port => port, :path => fn }]
+    end
+
+    uri = begin
+      URI::Generic.build components
+    rescue URI::Error => e
+      say "Whoopsie! I couldn't build a URI from that: #{e.message}"
+      if axe_yes("Try again?") then next else return end
+    end
 
     say "I'm going to add this source: #{uri}"
     unless axe("Does that look right?", "y") =~ /^y|yes$/i
@@ -123,13 +120,12 @@ def add_source
 
     labels_str = axe("Enter any labels to be automatically added to all messages from this source, separated by spaces (or 'none')", default_labels.join(","))
 
-    labels =
-      if labels_str =~ /^\s*none\s*$/i
-        nil
-      else
-        labels_str.split(/\s+/)
-      end
-    
+    labels = if labels_str =~ /^\s*none\s*$/i
+      nil
+    else
+      labels_str.split(/\s+/)
+    end
+
     cmd = build_cmd "sup-add"
     cmd += " --unusual" unless usual
     cmd += " --archive" if archive
@@ -164,14 +160,13 @@ nary a click of the mouse!
 Just answer these simple questions and you'll be on your way.
 
 EOS
-#' stupid ruby-mode
 
 account = $config[:accounts][:default]
 
 name = axe "What's your name?", account[:name]
-email = axe "What's your (primary) email address?", account[:email] #'srm
+email = axe "What's your (primary) email address?", account[:email]
 
-say "Ok, your header will look like this:"
+say "Ok, your from header will look like this:"
 say "  From: #{name} <#{email}>"
 
 say "\nDo you have any alternate email addresses that also receive email?"
@@ -187,7 +182,6 @@ $config[:accounts][:default][:alternates] = alts
 $config[:accounts][:default][:signature] = sigfn
 $config[:editor] = editor
 
-
 done = false
 until done
   say "\nNow, we'll tell Sup where to find all your email."
@@ -222,14 +216,12 @@ else
   choose do |menu|
     menu.prompt = "Store my sent mail in? "
 
+    menu.choice('Default (an mbox in ~/.sup, aka sup://sent)') { $config[:sent_source] = 'sup://sent'} unless have_sup_sent
+
     valid_sents = Redwood::SourceManager.sources.each do |s|
       have_sup_sent = true if s.to_s.eql?('sup://sent')
-
       menu.choice(s.to_s) { $config[:sent_source] = s.to_s } if s.respond_to? :store_message
     end
-
-    menu.choice('Default (sup://sent)') { $config[:sent_source] = 'sup://sent'} unless have_sup_sent
-
   end
 end
 
@@ -239,12 +231,12 @@ say "Ok, I've saved you up a nice lil' #{Redwood::CONFIG_FN}."
 
 say <<EOS
 
-Ok. The final step is to import all your messages into the Sup index.
+The final step is to import all your messages into the Sup index.
 Depending on how many messages are in the sources, this could take
 quite a while.
 
 EOS
-#'
+
 if axe_yes "Run sup-sync to import all messages now?"
   while true
     cmd = build_cmd("sup-sync") + " --all-sources"
index b743c1c13e3cdf9f73d6d24a5c88882985c30681..003a72d9c320e97d80687effb750654e0bc337aa 100755 (executable)
@@ -112,7 +112,7 @@ else
 end
 
 seen = {}
-index.lock_or_die
+index.lock_interactively or exit
 begin
   index.load
 
@@ -174,7 +174,12 @@ begin
       ## decide what to do based on message labels and the operation we're performing
       dothis, new_labels = case
       when (op == :restore) && restored_state[m.id] && old_m && (old_m.labels != restored_state[m.id])
+        num_restored += 1
         [:update_message_state, restored_state[m.id]]
+      when (op == :restore) && restored_state[m.id] && !old_m
+        num_restored += 1
+        m.labels = restored_state[m.id]
+        :add_message
       when op == :discard
         if old_m && (old_m.labels != m.labels)
           [:update_message_state, m.labels]
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..aa8079c488edd0976eb966d1aa8f7dac956dd866 100644 (file)
@@ -85,25 +85,39 @@ 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 }
+    elsif o.respond_to? :before_marshal
+      o.before_marshal
+    else
+      o
+    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 +128,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
@@ -182,6 +195,7 @@ end
 ## set up default configuration file
 if File.exists? Redwood::CONFIG_FN
   $config = Redwood::load_yaml_obj Redwood::CONFIG_FN
+  abort "#{Redwood::CONFIG_FN} is not a valid configuration file (it's a #{$config.class}, not a hash)" unless $config.is_a?(Hash)
 else
   require 'etc'
   require 'socket'
@@ -253,7 +267,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 +276,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"
index 53479199ce6e06670bbb56cf577937cd5e2b59e8..d85090a6db57eab56a8e230a6aeb8e3a925e6339 100644 (file)
@@ -25,13 +25,13 @@ module Ncurses
   def mutex; @mutex ||= Mutex.new; end
   def sync &b; mutex.synchronize(&b); end
 
-  ## 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
 
@@ -70,7 +70,7 @@ class Buffer
   def content_height; @height - 1; end
   def content_width; @width; end
 
-  def resize rows, cols 
+  def resize rows, cols
     return if cols == @width && rows == @height
     @width = cols
     @height = rows
@@ -196,8 +196,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 +233,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 +270,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 +501,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 7f044b99428d0beb80bfb22fa1f4cda6bc127011..772b8b696c4f4f41682fe2e51f4656bc66603254 100644 (file)
@@ -129,10 +129,9 @@ class CryptoManager
       end
 
       notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
-      [RMail::Parser.read(decrypted_payload), sig, notice]
+      [notice, sig, RMail::Parser.read(decrypted_payload)]
     else
-      notice = Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
-      [nil, nil, notice]
+      Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
     end
   end
 
index d0476dad95a78429513dfcb81a311d2a3cb72c86..a2e88983cb3d7030abd845b83804828b9eb7b96b 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
 
@@ -127,15 +115,14 @@ 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
+        #debug "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..a11bf9541e6efed049754cf85442c6cdbd714c8b 100644 (file)
@@ -6,8 +6,11 @@ module Redwood
 module MBox
 
 class Loader < Source
+  include SerializeLabelsNicely
   yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
 
+  attr_reader :labels
+
   ## uri_or_fp is horrific. need to refactor.
   def initialize uri_or_fp, start_offset=0, usual=true, archived=false, id=nil, labels=nil
     @mutex = Mutex.new
@@ -168,7 +171,7 @@ class Loader < Source
     end
 
     self.cur_offset = next_offset
-    [returned_offset, (@labels + [:unread])]
+    [returned_offset, (labels + [:unread])]
   end
 end
 
index 1eda17438df45e08eaa6f5269fca073e1714bb19..40e098f2914e8c606d69c95a62234a53c12b9f10 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
 
index ed27d3dccfba056410bd90225604479f8e788797..4a7d761583e93521ed18fc057a503a92918cc639 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
@@ -166,11 +191,13 @@ class Message
 
   def has_label? t; @labels.member? t; end
   def add_label l
+    l = l.to_sym
     return if @labels.member? l
     @labels << l
     @dirty = true
   end
   def remove_label l
+    l = l.to_sym
     return unless @labels.member? l
     @labels.delete l
     @dirty = true
@@ -182,6 +209,7 @@ class Message
 
   def labels= l
     raise ArgumentError, "not a set" unless l.is_a?(Set)
+    raise ArgumentError, "not a set of labels" unless l.all? { |ll| ll.is_a?(Symbol) }
     return if @labels == l
     @labels = l
     @dirty = true
@@ -382,9 +410,13 @@ private
       return
     end
 
-    decryptedm, sig, notice = CryptoManager.decrypt payload
-    children = message_to_chunks(decryptedm, true) if decryptedm
-    [notice, sig, children].flatten.compact
+    notice, sig, decryptedm = CryptoManager.decrypt payload
+    if decryptedm # managed to decrypt
+      children = message_to_chunks(decryptedm, true)
+      [notice, sig, children]
+    else
+      [notice]
+    end
   end
 
   ## takes a RMail::Message, breaks it into Chunk:: classes.
@@ -405,11 +437,15 @@ 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)
+      if m.body
+        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)
+      else
+        [Chunk::EnclosedMessage.new(nil, "")]
+      end
     else
       filename =
         ## first, paw through the headers looking for a filename
index bac4792e832daafb1ba9964d0e3cb2387c1bc9b6..dfe30ff7c4c49c4d163341aea1cdef87a85416ee 100644 (file)
@@ -253,7 +253,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 9203dd666816a770d8537c18c6281ae8f17ed14a..87ca6c672a02522092887cf632a0f53e43f7b5ad 100644 (file)
@@ -29,6 +29,7 @@ class SentManager
 
     PollManager.each_message_from(@source) do |m|
       m.remove_label :unread
+      m.add_label :sent
       PollManager.add_new_message m
     end
   end
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..1395601367ef49fdebb9ef5019a82a07a0c83ca3 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,50 @@ 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
+    docids = term_docids(mkterm(:msgid,id))
+    fail unless docids.size <= 1
+    docids.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 +399,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 +430,7 @@ class XapianIndex < BaseIndex
     end
   end
 
-  def index_message m, opts
+  def index_message m, entry, opts
     terms = []
     text = []
 
@@ -399,6 +453,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 +461,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 +501,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 +546,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