]> git.cworth.org Git - sup/commitdiff
Merge branch 'master' into next
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Wed, 13 May 2009 20:53:00 +0000 (13:53 -0700)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Wed, 13 May 2009 20:53:00 +0000 (13:53 -0700)
33 files changed:
Manifest.txt
README.txt
bin/sup
bin/sup-sync
contrib/completion/_sup.zsh [new file with mode: 0644]
doc/NewUserGuide.txt
lib/sup.rb
lib/sup/buffer.rb
lib/sup/colormap.rb
lib/sup/draft.rb
lib/sup/imap.rb
lib/sup/index.rb
lib/sup/label.rb
lib/sup/logger.rb
lib/sup/maildir.rb
lib/sup/mbox.rb
lib/sup/mbox/loader.rb
lib/sup/message.rb
lib/sup/mode.rb
lib/sup/modes/buffer-list-mode.rb
lib/sup/modes/contact-list-mode.rb
lib/sup/modes/edit-message-mode.rb
lib/sup/modes/inbox-mode.rb
lib/sup/modes/label-list-mode.rb
lib/sup/modes/resume-mode.rb
lib/sup/modes/thread-index-mode.rb
lib/sup/poll.rb
lib/sup/source.rb
lib/sup/undo.rb [new file with mode: 0644]
test/dummy_source.rb
test/test_header_parsing.rb [new file with mode: 0644]
test/test_mbox_parsing.rb [deleted file]
test/test_message.rb

index 6ecfe670d07649cf288cfadcfe78f3ce71cd4731..be633d776e2b1e5165edeac4d7631cf714471207 100644 (file)
@@ -71,5 +71,6 @@ 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
index 9a4490acabe3da4e986d470e46bea0b0da3c49e4..0437de9f0c91b87b300c99077a88d7df82162100 100644 (file)
@@ -80,7 +80,7 @@ Current limitations which will be fixed:
 - Unix-centrism in MIME attachment handling and in sendmail
   invocation.
 
-- Several obvious missing features, like undo, filters / saved
+- Several obvious missing features, like filters / saved
   searches, message annotations, etc.
 
 == SYNOPSYS:
diff --git a/bin/sup b/bin/sup
index d8eee0e3215fff5cc52470a4695aef3c561e60d4..0af3d11ecfea671bc299342f6ca410d6cb83bf12 100644 (file)
--- a/bin/sup
+++ b/bin/sup
@@ -67,9 +67,9 @@ global_keymap = Keymap.new do |k|
   k.add :quit_now, "Quit Sup immediately", 'Q'
   k.add :help, "Show help", '?'
   k.add :roll_buffers, "Switch to next buffer", 'b'
-#  k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
+  k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
   k.add :kill_buffer, "Kill the current buffer", 'x'
-  k.add :list_buffers, "List all buffers", 'B'
+  k.add :list_buffers, "List all buffers", ';'
   k.add :list_contacts, "List contacts", 'C'
   k.add :redraw, "Redraw screen", :ctrl_l
   k.add :search, "Search all messages", '\\', 'F'
@@ -81,6 +81,36 @@ global_keymap = Keymap.new do |k|
   k.add :recall_draft, "Edit most recent draft message", 'R'
 end
 
+## the following magic enables wide characters when used with a ruby
+## ncurses.so that's been compiled against libncursesw. (note the w.) why
+## this works, i have no idea. much like pretty much every aspect of
+## dealing with curses.  cargo cult programming at its best.
+##
+## BSD users: if libc.so.6 is not found, try installing compat6x.
+require 'dl/import'
+module LibC
+  extend DL::Importable
+  setlocale_lib = case Config::CONFIG['arch']
+    when /darwin/; "libc.dylib"
+    when /cygwin/; "cygwin1.dll"
+    else; "libc.so.6"
+  end
+
+  Redwood::log "dynamically loading setlocale() from #{setlocale_lib}"
+  begin
+    dlload setlocale_lib
+    extern "void setlocale(int, const char *)"
+    Redwood::log "setting locale..."
+    LibC.setlocale(6, "")  # LC_ALL == 6
+  rescue RuntimeError => e
+    Redwood::log "cannot dlload setlocale(); ncurses wide character support probably broken."
+    Redwood::log "dlload error was #{e.class}: #{e.message}"
+    if Config::CONFIG['arch'] =~ /bsd/
+      Redwood::log "BSD variant detected. You may have to install a compat6x package to acquire libc."
+    end
+  end
+end
+
 def start_cursing
   Ncurses.initscr
   Ncurses.noecho
@@ -230,7 +260,7 @@ begin
     when :kill_buffer
       bm.kill_buffer_safely bm.focus_buf
     when :list_buffers
-      bm.spawn_unless_exists("Buffer List") { BufferListMode.new }
+      bm.spawn_unless_exists("buffer list", :system => true) { BufferListMode.new }
     when :list_contacts
       b, new = bm.spawn_unless_exists("Contact List") { ContactListMode.new }
       b.mode.load_in_background if new
@@ -241,7 +271,7 @@ begin
     when :search_unread
       SearchResultsMode.spawn_from_query "is:unread"
     when :list_labels
-      labels = LabelManager.listable_labels.map { |l| LabelManager.string_for l }
+      labels = LabelManager.all_labels.map { |l| LabelManager.string_for l }
       user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels
       unless user_label.nil?
         if user_label.empty?
index 91710d47495ab263a69e03ec709657d1ee8b66ae..01c0ebaac42d452760c1e20999f2e4c37a50f597 100644 (file)
@@ -5,6 +5,8 @@ require 'rubygems'
 require 'trollop'
 require "sup"
 
+PROGRESS_UPDATE_INTERVAL = 15 # seconds
+
 class Float
   def to_s; sprintf '%.2f', self; end
    def to_time_s
@@ -122,7 +124,9 @@ begin
 
   unless target == :new
     if opts[:start_at]
-      sources.each { |s| s.seek_to! opts[:start_at] }
+      Trollop::die :start_at, "can only be used on one source" unless sources.size == 1
+      sources.first.seek_to! opts[:start_at]
+      sources.first.correct_offset! if sources.first.respond_to?(:correct_offset!)
     else
       sources.each { |s| s.reset! }
     end
@@ -137,6 +141,15 @@ begin
       num_scanned += 1
       seen[m.id] = true
 
+      if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
+        last_info_time = Time.now
+        elapsed = last_info_time - start_time
+        start = opts[:start_at] || source.start_offset
+        pctdone = 100.0 * (source.cur_offset - start).to_f / (source.end_offset - start).to_f
+        remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
+        $stderr.printf "## read %dm (about %.0f%%) @ %.1fm/s. %s elapsed, about %s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
+      end
+
       ## skip if we're operating only on changed messages, the message
       ## is in the index, and it's unchanged from what the source is
       ## reporting.
@@ -171,16 +184,16 @@ begin
         ## nothin! use default source labels
       end
 
-      if Time.now - last_info_time > 60
+      if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
         last_info_time = Time.now
         elapsed = last_info_time - start_time
         pctdone = source.respond_to?(:pct_done) ? source.pct_done : 100.0 * (source.cur_offset.to_f - source.start_offset).to_f / (source.end_offset - source.start_offset).to_f
         remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
-        $stderr.puts "## #{num_scanned} (#{pctdone}%) read; #{elapsed.to_time_s} elapsed; #{remaining.to_time_s} remaining"
+        $stderr.printf "## read %dm (about %.0f%%) @ %.1fm/s. %s elapsed, about %s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
       end
 
       if index_state.nil?
-        puts "Adding message #{source}##{offset} with state {#{m.labels * ', '}}" if opts[:verbose]
+        puts "Adding message #{source}##{offset} from #{m.from} with state {#{m.labels * ', '}}" if opts[:verbose]
         num_added += 1
       else
         puts "Updating message #{source}##{offset}, source #{entry[:source_id]} => #{source.id}, offset #{entry[:source_info]} => #{offset}, state {#{index_state * ', '}} => {#{m.labels * ', '}}" if opts[:verbose]
@@ -200,9 +213,7 @@ begin
   ## API.
   ##
   ## TODO: move this to Index, i suppose.
-
-
-  if target == :all || target == :changed
+  if (target == :all || target == :changed) && !opts[:start_at]
     $stderr.puts "Deleting missing messages from the index..."
     num_del, num_scanned = 0, 0
     sources.each do |source|
diff --git a/contrib/completion/_sup.zsh b/contrib/completion/_sup.zsh
new file mode 100644 (file)
index 0000000..76870ca
--- /dev/null
@@ -0,0 +1,114 @@
+#compdef sup sup-add sup-config sup-dump sup-sync sup-sync-back sup-tweak-labels sup-recover-sources
+# vim: set et sw=2 sts=2 ts=2 ft=zsh :
+
+# TODO: sources completion: maildir://some/dir, mbox://some/file, ...
+#       for sup-add, sup-sync, sup-sync-back, sup-tweak-labels
+
+(( ${+functions[_sup_cmd]} )) ||
+_sup_cmd()
+{
+  _arguments -s : \
+    "(--list-hooks -l)"{--list-hooks,-l}"[list all hooks and descriptions, and quit]" \
+    "(--no-threads -n)"{--no-threads,-n}"[turn off threading]" \
+    "(--no-initial-poll -o)"{--no-initial-poll,-o}"[Don't poll for new messages when starting]" \
+    "(--search -s)"{--search,-s}"[search for this query upon startup]:Query: " \
+    "(--compose -c)"{--compose,-c}"[compose message to this recipient upon startup]:Email: " \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_add_cmd]} )) ||
+_sup_add_cmd()
+{
+  _arguments -s : \
+    "(--archive -a)"{--archive,-a}"[automatically archive all new messages from this source]" \
+    "(--unusual -u)"{--unusual,-u}"[do not automatically poll for new messages from this source]" \
+    "(--labels -l)"{--labels,-l}"[set of labels to apply to all messages from this source]:Labels: " \
+    "(--force-new -f)"{--force-new,-f}"[create a new account for this source, even if one already exists]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_config_cmd]} )) ||
+_sup_config_cmd()
+{
+  _arguments -s : \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_dump_cmd]} )) ||
+_sup_dump_cmd()
+{
+  _arguments -s : \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_recover_sources_cmd]} )) ||
+_sup_recover_sources_cmd()
+{
+  _arguments -s : \
+    "--archive[automatically archive all new messages from this source]" \
+    "--scan-num[number of messages to scan per source]:" \
+    "--unusual[do not automatically poll for new messages from this source]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_sync_cmd]} )) ||
+_sup_sync_cmd()
+{
+  # XXX Add only when --restore is given: (--restored -r)
+  #     Add only when --changed or--all are given: (--start-at -s)
+  _arguments -s : \
+    "--new[operate on new messages only]" \
+    "(--changed -c)"{--changed,-c}"[scan over the entire source for messages that have been deleted, altered, or moved from another source]" \
+    "(--restored -r)"{--restored,-r}"[operate only on those messages included in a dump file as specified by --restore which have changed state]" \
+    "(--all -a)"{--all,-a}"[operate on all messages in the source, regardless of newness or changedness]" \
+    "(--start-at -s)"{--start-at,-s}"[start at a particular offset]:Offset: " \
+    "--asis[if the message is already in the index, preserve its state, otherwise, use default source state]" \
+    "--restore[restore message state from a dump file created with sup-dump]:File:_file" \
+    "--discard[discard any message state in the index and use the default source state]" \
+    "(--archive -x)"{--archive,-x}"[mark messages as archived when using the default source state]" \
+    "(--read -e)"{--read,-e}"[mark messages as read when using the default source state]" \
+    "--extra-labels[apply these labels when using the default source state]:Labels: " \
+    "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+    "(--optimize -o)"{--optimize,-o}"[as the final operation, optimize the index]" \
+    "--all-sources[scan over all sources]" \
+    "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_sync_back_cmd]} )) ||
+_sup_sync_back_cmd()
+{
+  _arguments -s : \
+    "(--drop-deleted -d)"{--drop-deleted,-d}"[drop deleted messages]" \
+    "--move-deleted[move deleted messages to a local mbox file]:File:_file" \
+    "(--drop-spam -s)"{--drop-spam,-s}"[drop spam messages]" \
+    "--move-spam[move spam messages to a local mbox file]:File:_file" \
+    "--with-dotlockfile[specific dotlockfile location (mbox files only)]:File:_file" \
+    "--dont-use-dotlockfile[don't use dotlockfile to lock mbox files]" \
+    "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+    "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_tweak_labels_cmd]} )) ||
+_sup_tweak_labels_cmd()
+{
+  _arguments -s : \
+    "(--add -a)"{--add,-a}"[which labels to add to every message from the specified sources]:Labels: " \
+    "(--remove -r)"{--remove,-r}"[which labels to remove from every message from the specified sources]:Labels: " \
+    "--all-sources[scan over all sources]" \
+    "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+    "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+_call_function ret _${words[1]//-/_}_cmd
+return ret
+
index e4d49eef3bcb87aae0d8b83f37bb89c7896689f3..5f9c3baad61c3a9a41265cd0709c221a508ae936 100644 (file)
@@ -98,9 +98,9 @@ press 'n' and 'p' to jump forward and backward between open messages,
 aligning the display as necessary.
 
 Now press 'x' to kill the thread view buffer. You should see the inbox
-again. If you don't, you can cycle through the buffers by pressing
-'b', or you can press 'B' to see a list of all buffers and simply
-select the inbox.
+again. If you don't, you can cycle through the buffers by pressing 'b'
+and 'B' (forwards and backwards, respectively), or you can press ';' to
+see a list of all buffers and simply select the inbox.
 
 There are many operations you can perform on threads beyond viewing
 them. To archive a thread, press 'a'. The thread will disappear from
@@ -125,8 +125,8 @@ in the labels as a sequence of space-separated words. To cancel the
 input, press Ctrl-G.
 
 Many of these operations can be applied to a group of threads. Press
-'t' to tag a thread. Tag a couple, then press ';' to apply the next
-command to the set of threads. ';t', of course, will untag all tagged
+'t' to tag a thread. Tag a couple, then press '+' to apply the next
+command to the set of threads. '+t', of course, will untag all tagged
 messages.
 
 Ok, let's try using labels and search. Press 'L' to do a quick label
@@ -245,7 +245,6 @@ Here's what I recommend:
    inbox, and you can browse the mailing list traffic at any point by
    searching for that label.
 
-
 Appendix C: Reading blogs with Sup
 ----------------------------------
 
index 3afac5e012b0e9438434981fa2ebaae068ec9ea4..96510b2e2d7419629a0a3fd3e9d96eafca29c36e 100644 (file)
@@ -6,19 +6,6 @@ require 'fileutils'
 require 'gettext'
 require 'curses'
 
-## the following magic enables wide characters when used with a ruby
-## ncurses.so that's been compiled against libncursesw. (note the w.) why
-## this works, i have no idea. much like pretty much every aspect of
-## dealing with curses.  cargo cult programming at its best.
-
-require 'dl/import'
-module LibC
-  extend DL::Importable
-  dlload Config::CONFIG['arch'] =~ /darwin/ ? "libc.dylib" : "libc.so.6"
-  extern "void setlocale(int, const char *)"
-end
-LibC.setlocale(6, "")  # LC_ALL == 6
-
 class Object
   ## this is for debugging purposes because i keep calling #id on the
   ## wrong object and i want it to throw an exception
@@ -123,6 +110,7 @@ module Redwood
     Redwood::PollManager.new
     Redwood::SuicideManager.new Redwood::SUICIDE_FN
     Redwood::CryptoManager.new
+    Redwood::UndoManager.new
   end
 
   def finish
@@ -279,6 +267,7 @@ require "sup/tagger"
 require "sup/draft"
 require "sup/poll"
 require "sup/crypto"
+require "sup/undo"
 require "sup/horizontal-selector"
 require "sup/modes/line-cursor-mode"
 require "sup/modes/help-mode"
index c9e34b3595c62b82b64350163e0d547e127f95eb..cb61e12ffd19df6a2ccc0a2df7c54ec80066938e 100644 (file)
@@ -51,8 +51,8 @@ module Redwood
 class InputSequenceAborted < StandardError; end
 
 class Buffer
-  attr_reader :mode, :x, :y, :width, :height, :title
-  bool_reader :dirty
+  attr_reader :mode, :x, :y, :width, :height, :title, :atime
+  bool_reader :dirty, :system
   bool_accessor :force_to_top
 
   def initialize window, mode, width, height, opts={}
@@ -63,6 +63,8 @@ class Buffer
     @title = opts[:title] || ""
     @force_to_top = opts[:force_to_top] || false
     @x, @y, @width, @height = 0, 0, width, height
+    @atime = Time.at 0
+    @system = opts[:system] || false
   end
 
   def content_height; @height - 1; end
@@ -97,6 +99,7 @@ class Buffer
     @mode.draw
     draw_status status
     commit
+    @atime = Time.now
   end
 
   ## s nil means a blank line!
@@ -338,7 +341,7 @@ EOS
     ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
     ## (opts[:left] || 0))
     w = Ncurses.stdscr
-    b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
+    b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
     mode.buffer = b
     @name_map[realtitle] = b
 
@@ -478,7 +481,9 @@ EOS
     default = default_labels.join(" ")
     default += " " unless default.empty?
 
-    applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
+    # here I would prefer to give more control and allow all_labels instead of
+    # user_defined_labels only
+    applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
 
     answer = ask_many_with_completions domain, question, applyable_labels, default
 
index 469ed0b91874342a8fde6787008aa625b9d4e576..fe60f61362a5d65cd3abe22d4fcfbf8bc21d26c2 100644 (file)
@@ -46,7 +46,10 @@ class Colormap
     :completion_character => { :fg => "white", :bg => "default", :attrs => ["bold"] },
     :horizontal_selector_selected => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
     :horizontal_selector_unselected => { :fg => "cyan", :bg => "default" },
-    :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }
+    :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] },
+    :system_buf => { :fg => "blue", :bg => "default" },
+    :regular_buf => { :fg => "white", :bg => "default" },
+    :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
   }
   
   def initialize
index 35fac30ee8cc0915632958abb6579c6ede54ebfa..32266b5374eb3d67b336926a28e8fe75eecf30ce 100644 (file)
@@ -79,9 +79,7 @@ class DraftLoader < Source
   def fn_for_offset o; File.join(@dir, o.to_s); end
 
   def load_header offset
-    File.open fn_for_offset(offset) do |f|
-      return MBox::read_header(f)
-    end
+    File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f }
   end
   
   def load_message offset
index 4eb13f4ed619d3259df4894eec4a8b66c11c326e..7508c2c7b3e64d4fd39f7a8467a380d68128f6d0 100644 (file)
@@ -93,7 +93,7 @@ class IMAP < Source
   def == o; o.is_a?(IMAP) && o.uri == self.uri && o.username == self.username; end
 
   def load_header id
-    MBox::read_header StringIO.new(raw_header(id))
+    parse_raw_email_header StringIO.new(raw_header(id))
   end
 
   def load_message id
index 838d601347f1f86ffba21ac72e37c3e191ac95ba..c66a24eac428ab7256ea237db16c42e126189dd7 100644 (file)
@@ -177,31 +177,31 @@ EOS
     end
   end
 
-  ## Syncs the message to the index: deleting if it's already there,
-  ## and adding either way. Index state will be determined by m.labels.
+  ## Syncs the message to the index, replacing any previous version.  adding
+  ## either way. Index state will be determined by the message's #labels
+  ## accessor.
   ##
-  ## docid and entry can be specified if they're already known.
-  def sync_message m, docid=nil, entry=nil, opts={}
-    docid, entry = load_entry_for_id m.id unless docid && entry
+  ## if need_load is false, docid and entry are assumed to be set to the
+  ## result of load_entry_for_id (which can be nil).
+  def sync_message m, need_load=true, docid=nil, entry=nil, opts={}
+    docid, entry = load_entry_for_id m.id if need_load
 
     raise "no source info for message #{m.id}" unless m.source && m.source_info
     @index_mutex.synchronize do
       raise "trying to delete non-corresponding entry #{docid} with index message-id #{@index[docid][:message_id].inspect} and parameter message id #{m.id.inspect}" if docid && @index[docid][:message_id] != m.id
     end
 
-    source_id = 
-      if m.source.is_a? Integer
-        m.source
-      else
-        m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
-      end
+    source_id = if m.source.is_a? Integer
+      m.source
+    else
+      m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
+    end
 
-    snippet = 
-      if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
-        ""
-      else
-        m.snippet
-      end
+    snippet = if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
+      ""
+    else
+      m.snippet
+    end
 
     ## write the new document to the index. if the entry already exists in the
     ## index, reuse it (which avoids having to reload the entry from the source,
@@ -261,15 +261,14 @@ EOS
       :refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
     }
 
-    @index_mutex.synchronize  do
+    @index_mutex.synchronize do
       @index.delete docid if docid
       @index.add_document d
     end
 
-    docid, entry = load_entry_for_id m.id
-    ## this hasn't been triggered in a long time. TODO: decide whether it's still a problem.
-    raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
-    true
+    ## this hasn't been triggered in a long time.
+    ## docid, entry = load_entry_for_id m.id
+    ## raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
   end
 
   def save_index fn=File.join(@dir, "ferret")
@@ -414,9 +413,11 @@ EOS
         "references" => doc[:refs].split(/\s+/).map { |x| "<#{x}>" }.join(" "),
       }
 
-      Message.new :source => source, :source_info => doc[:source_info].to_i,
+      m = Message.new :source => source, :source_info => doc[:source_info].to_i,
                   :labels => doc[:label].split(" ").map { |s| s.intern },
-                  :snippet => doc[:snippet], :header => fake_header
+                  :snippet => doc[:snippet]
+      m.parse_header fake_header
+      m
     end
   end
 
index 11bf30d76b6578d728ddef39e681e5693c725df1..8902dda5757584c4c9dee553ae4146e415544545 100644 (file)
@@ -7,9 +7,6 @@ class LabelManager
   ## add/remove these via normal label mechanisms.
   RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent, :deleted, :inbox, :attachment ]
 
-  ## labels which it nonetheless makes sense to search for by
-  LISTABLE_RESERVED_LABELS = [ :starred, :spam, :draft, :sent, :killed, :deleted, :inbox, :attachment ]
-
   ## labels that will typically be hidden from the user
   HIDDEN_RESERVED_LABELS = [ :starred, :unread, :attachment ]
 
@@ -28,18 +25,18 @@ class LabelManager
     self.class.i_am_the_instance self
   end
 
-  ## all listable (just user-defined at the moment) labels, ordered
+  ## all labels user-defined and system, ordered
   ## nicely and converted to pretty strings. use #label_for to recover
   ## the original label.
-  def listable_labels
+  def all_labels
     ## uniq's only necessary here because of certain upgrade issues
-    (LISTABLE_RESERVED_LABELS + @labels.keys).uniq
+    (RESERVED_LABELS + @labels.keys).uniq
   end
 
-  ## all apply-able (user-defined and system listable) labels, ordered
+  ## all user-defined labels, ordered
   ## nicely and converted to pretty strings. use #label_for to recover
   ## the original label.
-  def applyable_labels
+  def user_defined_labels
     @labels.keys
   end
 
index ebdeebe74dc67369342680c2d5a07f56ecadeb34..4ac6551bff21e50ab45704f295d6d930edf1ce6d 100644 (file)
@@ -18,7 +18,7 @@ class Logger
   def make_buf
     return if @mode.buffer || !BufferManager.instantiated? || !@respawn || @spawning
     @spawning = true
-    @mode.buffer = BufferManager.instance.spawn "<log>", @mode, :hidden => true
+    @mode.buffer = BufferManager.instance.spawn "log", @mode, :hidden => true, :system => true
     @spawning = false
   end
 
index 3d584f76d6054d2c5ffaa10954ea752ed98c808c..a9ae05c71ec61befa861001fd5aca7dd7c3c0a41 100644 (file)
@@ -56,7 +56,7 @@ class Maildir < Source
 
   def load_header id
     scan_mailbox
-    with_file_for(id) { |f| MBox::read_header f }
+    with_file_for(id) { |f| parse_raw_email_header f }
   end
 
   def load_message id
index 8497a37f11a6f6a00bce35953e4a43d5c96103e7..53b4e8ce65cdd67f836154feeb59bde8202679a2 100644 (file)
@@ -5,77 +5,7 @@ require "sup/rfc2047"
 
 module Redwood
 
-## some utility functions. actually these are not mbox-specific at all
-## and should be moved somewhere else.
-##
-## TODO: move functionality to somewhere better, like message.rb
 module MBox
-  BREAK_RE = /^From \S+/
-  HEADER_RE = /\s*(.*?)\s*/
-
-  def read_header f
-    header = {}
-    last = nil
-
-    ## i do it in this weird way because i am trying to speed things up
-    ## when scanning over large mbox files.
-    while(line = f.gets)
-      case line
-      ## these three can occur multiple times, and we want the first one
-      when /^(Delivered-To):#{HEADER_RE}$/i,
-        /^(X-Original-To):#{HEADER_RE}$/i,
-        /^(Envelope-To):#{HEADER_RE}$/i: header[last = $1] ||= $2
-
-      when /^(From):#{HEADER_RE}$/i,
-        /^(To):#{HEADER_RE}$/i,
-        /^(Cc):#{HEADER_RE}$/i,
-        /^(Bcc):#{HEADER_RE}$/i,
-        /^(Subject):#{HEADER_RE}$/i,
-        /^(Date):#{HEADER_RE}$/i,
-        /^(References):#{HEADER_RE}$/i,
-        /^(In-Reply-To):#{HEADER_RE}$/i,
-        /^(Reply-To):#{HEADER_RE}$/i,
-        /^(List-Post):#{HEADER_RE}$/i,
-        /^(List-Subscribe):#{HEADER_RE}$/i,
-        /^(List-Unsubscribe):#{HEADER_RE}$/i,
-        /^(Status):#{HEADER_RE}$/i,
-        /^(X-\S+):#{HEADER_RE}$/: header[last = $1] = $2
-      when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
-
-      when /^\r*$/: break
-      when /^\S+:/: last = nil # some other header we don't care about
-      else
-        header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
-      end
-    end
-
-    if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
-      header[mid_field] = $1
-    end
-
-    header.each do |k, v|
-      next unless Rfc2047.is_encoded? v
-      header[k] =
-        begin
-          Rfc2047.decode_to $encoding, v
-        rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
-          Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
-          v
-        end
-    end
-    header
-  end
-  
-  ## never actually called
-  def read_body f
-    body = []
-    f.each_line do |l|
-      break if l =~ BREAK_RE
-      body << l.chomp
-    end
-    body
-  end
-
-  module_function :read_header, :read_body
+  BREAK_RE = /^From \S+@\S+ /
 end
 end
index 7fe912900e16d477fc7d690873d92e9c623f48c2..c35d0c8cd05afe0d2b1de8b6db668d79f9ef1681 100644 (file)
@@ -59,7 +59,7 @@ class Loader < Source
       unless l =~ BREAK_RE
         raise OutOfSyncSourceError, "mismatch in mbox file offset #{offset.inspect}: #{l.inspect}." 
       end
-      header = MBox::read_header @f
+      header = parse_raw_email_header @f
     end
     header
   end
@@ -68,25 +68,36 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       begin
-        RMail::Mailbox::MBoxReader.new(@f).each_message do |input|
-          m = RMail::Parser.read(input)
-          if m.body && m.body.is_a?(String)
-            m.body.gsub!(/^>From /, "From ")
-          end
-          return m
-        end
+        ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore
+        ## "From" at the start of a message body line.
+        string = ""
+        l = @f.gets
+        string << l until @f.eof? || (l = @f.gets) =~ BREAK_RE
+        RMail::Parser.read string
       rescue RMail::Parser::Error => e
         raise FatalSourceError, "error parsing mbox file: #{e.message}"
       end
     end
   end
 
+  ## scan forward until we're at the valid start of a message
+  def correct_offset!
+    @mutex.synchronize do
+      @f.seek cur_offset
+      string = ""
+      until @f.eof? || (l = @f.gets) =~ BREAK_RE
+        string << l
+      end
+      self.cur_offset += string.length
+    end
+  end
+
   def raw_header offset
     ret = ""
     @mutex.synchronize do
       @f.seek offset
       until @f.eof? || (l = @f.gets) =~ /^\r*$/
-        ret += l
+        ret << l
       end
     end
     ret
@@ -94,7 +105,7 @@ class Loader < Source
 
   def raw_message offset
     ret = ""
-    each_raw_message_line(offset) { |l| ret += l }
+    each_raw_message_line(offset) { |l| ret << l }
     ret
   end
 
index 0ee46fb25d7ecbb97807910c9fa0785337bccc2c..e86075c5e6ffcd7675651c5b98d62f165e61b349 100644 (file)
@@ -60,49 +60,42 @@ class Message
     ## why.
     @refs = []
 
-    parse_header(opts[:header] || @source.load_header(@source_info))
+    #parse_header(opts[:header] || @source.load_header(@source_info))
   end
 
   def parse_header header
-    header.keys.each { |k| header[k.downcase] = header[k] } # canonicalize
-
-    fakeid = nil
-    fakename = nil
-
-    @id =
-      if header["message-id"]
-        sanitize_message_id header["message-id"]
-      else
-        fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
-      end
+    @id = if header["message-id"]
+      mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
+      sanitize_message_id mid
+    else
+      id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
+      from = header["from"]
+      #Redwood::log "faking non-existent message-id for message from #{from}: #{id}"
+      id
+    end
     
-    @from =
-      if header["from"]
-        Person.from_address header["from"]
-      else
-        fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
-        Person.from_address fakename
-      end
-
-    Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
-    Redwood::log "faking from for message #@id: #{fakename}" if fakename
-
-    date = header["date"]
-    @date =
-      case date
-      when Time
-        date
-      when String
-        begin
-          Time.parse date
-        rescue ArgumentError => e
-          Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
-          Time.now
-        end
-      else
-        Redwood::log "faking date header for #{@id}"
+    @from = Person.from_address(if header["from"]
+      header["from"]
+    else
+      name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
+      #Redwood::log "faking non-existent sender for message #@id: #{name}"
+      name
+    end)
+
+    @date = case(date = header["date"])
+    when Time
+      date
+    when String
+      begin
+        Time.parse date
+      rescue ArgumentError => e
+        #Redwood::log "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})"
         Time.now
       end
+    else
+      #Redwood::log "faking non-existent date header for #{@id}"
+      Time.now
+    end
 
     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
     @to = Person.from_address_list header["to"]
@@ -130,7 +123,6 @@ class Message
     @list_subscribe = header["list-subscribe"]
     @list_unsubscribe = header["list-unsubscribe"]
   end
-  private :parse_header
 
   def add_ref ref
     @refs << ref
@@ -198,7 +190,7 @@ class Message
   ## this is called when the message body needs to actually be loaded.
   def load_from_source!
     @chunks ||=
-      if @source.has_errors?
+      if @source.respond_to?(:has_errors?) && @source.has_errors?
         [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
       else
         begin
@@ -372,6 +364,7 @@ private
     [notice, sig, children].flatten.compact
   end
 
+  ## takes a RMail::Message, breaks it into Chunk:: classes.
   def message_to_chunks m, encrypted=false, sibling_types=[]
     if m.multipart?
       chunks =
index 9900dc303d80395a3026ced166fc82b78a15ce54..bea46d92d425835142f2ae3b94eca035b0a64148 100644 (file)
@@ -24,6 +24,7 @@ class Mode
   end
 
   def killable?; true; end
+  def unsaved?; false end
   def draw; end
   def focus; end
   def blur; end
index 91b2fac7fa4d50e974368486ca17a1ddc44f462e..1554caedfe77e2bfeb8a99efbfd65f63f479c074 100644 (file)
@@ -16,6 +16,7 @@ class BufferListMode < LineCursorMode
 
   def focus
     reload # buffers may have been killed or created since last view
+    set_cursor_pos 0
   end
 
 protected
@@ -26,10 +27,13 @@ protected
   end
 
   def regen_text
-    @bufs = BufferManager.buffers.sort_by { |name, buf| name }
+    @bufs = BufferManager.buffers.reject { |name, buf| buf.mode == self }.sort_by { |name, buf| buf.atime }.reverse
     width = @bufs.max_of { |name, buf| buf.mode.name.length }
     @text = @bufs.map do |name, buf|
-      sprintf "%#{width}s  %s", buf.mode.name, name
+      base_color = buf.system? ? :system_buf_color : :regular_buf_color
+      [[base_color, sprintf("%#{width}s ", buf.mode.name)],
+       [:modified_buffer_color, (buf.mode.unsaved? ? '*' : ' ')],
+       [base_color, " " + name]]
     end
   end
 
index 7c16babd61aacf220998bfb93aca300c0629d112..edbef5e3a27f3f9cba65c8cf1cfaecec05021e3e 100644 (file)
@@ -23,7 +23,7 @@ class ContactListMode < LineCursorMode
     k.add :reload, "Drop contact list and reload", 'D'
     k.add :alias, "Edit alias/or name for contact", 'a', 'i'
     k.add :toggle_tagged, "Tag/untag current line", 't'
-    k.add :apply_to_tagged, "Apply next command to all tagged items", ';'
+    k.add :apply_to_tagged, "Apply next command to all tagged items", '+'
     k.add :search, "Search for messages from particular people", 'S'
   end
 
index 31aa8972766c34d17ae8f9cda5379b5363af559f..c91827938bbbbe0ef104d48cf02ff924af9e10f2 100644 (file)
@@ -12,7 +12,7 @@ class EditMessageMode < LineCursorMode
 
   FORCE_HEADERS = %w(From To Cc Bcc Subject)
   MULTI_HEADERS = %w(To Cc Bcc)
-  NON_EDITABLE_HEADERS = %w(Message-Id Date)
+  NON_EDITABLE_HEADERS = %w(Message-id Date)
 
   HookManager.register "signature", <<EOS
 Generates a message signature.
@@ -145,6 +145,8 @@ EOS
     !edited? || BufferManager.ask_yes_or_no("Discard message?")
   end
 
+  def unsaved?; edited? end
+
   def attach_file
     fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
     return unless fn
@@ -212,7 +214,7 @@ protected
 
   def parse_file fn
     File.open(fn) do |f|
-      header = MBox::read_header f
+      header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
       body = f.readlines.map { |l| l.chomp }
 
       header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
@@ -390,7 +392,7 @@ protected
 
       contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
       if contacts
-        text = contacts.map { |s| s.longname }.join(", ")
+        text = contacts.map { |s| s.full_address }.join(", ")
         @header[field] = parse_header field, text
         update
       end
index 559892d5655f07b80a1186e114950156220a13c5..21eb9ac43bff5cbd01fb0e10807b61381aabf1bd 100644 (file)
@@ -26,12 +26,28 @@ class InboxMode < ThreadIndexMode
 
   def archive
     return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    undo = lambda {
+      thread.apply_label :inbox
+      add_or_unhide thread.first
+    }
+    UndoManager.register("archiving thread #{thread.first.id}", undo)
+
     cursor_thread.remove_label :inbox
     hide_thread cursor_thread
     regen_text
   end
 
   def multi_archive threads
+    undo = threads.map {|t|
+             lambda{
+               t.apply_label :inbox
+               add_or_unhide t.first
+             }}
+    UndoManager.register("archiving #{threads.size} #{threads.size.pluralize 'thread'}",
+                         undo << lambda {regen_text} )
+
     threads.each do |t|
       t.remove_label :inbox
       hide_thread t
@@ -41,6 +57,15 @@ class InboxMode < ThreadIndexMode
 
   def read_and_archive
     return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    undo = lambda {
+      thread.apply_label :inbox
+      thread.apply_label :unread
+      add_or_unhide thread.first
+    }
+    UndoManager.register("reading and archiving thread ", undo)
+
     cursor_thread.remove_label :unread
     cursor_thread.remove_label :inbox
     hide_thread cursor_thread
@@ -48,6 +73,16 @@ class InboxMode < ThreadIndexMode
   end
 
   def multi_read_and_archive threads
+    undo = threads.map {|t|
+      lambda {
+        t.apply_label :inbox
+        t.apply_label :unread
+        add_or_unhide t.first
+      }
+    }
+    UndoManager.register("reading and archiving #{threads.size} #{threads.size.pluralize 'thread'}",
+                         undo << lambda {regen_text})
+
     threads.each do |t|
       t.remove_label :unread
       t.remove_label :inbox
index 132b654066a8f107e2e52d7d2622c722e1e65ea6..a35d110df8d0607589b9c412682818674c181c9c 100644 (file)
@@ -48,12 +48,12 @@ protected
 
   def regen_text
     @text = []
-    labels = LabelManager.listable_labels
+    labels = LabelManager.all_labels
 
     counts = labels.map do |label|
       string = LabelManager.string_for label
       total = Index.num_results_for :label => label
-      unread = Index.num_results_for :labels => [label, :unread]
+      unread = (label == :unread)? total : Index.num_results_for(:labels => [label, :unread])
       [label, string, total, unread]
     end.sort_by { |l, s, t, u| s.downcase }
 
index b1c69fc1dc2f35fe1eea2350b21873e6c08470e9..e527542bd8266a6c0a38666955a2434f98d13516 100644 (file)
@@ -11,6 +11,8 @@ class ResumeMode < EditMessageMode
     super :header => header, :body => body, :have_signature => true
   end
 
+  def unsaved?; !@safe end
+
   def killable?
     return true if @safe
 
index cd9a0277689a9debba64003332345adc96d530fb..b44dc1889f623e30b32ca095cf3c0249cf6b0494 100644 (file)
@@ -42,8 +42,9 @@ EOS
     k.add :toggle_tagged, "Tag/untag selected thread", 't'
     k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
     k.add :tag_matching, "Tag matching threads", 'g'
-    k.add :apply_to_tagged, "Apply next command to all tagged threads", ';'
+    k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
     k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+    k.add :undo, "Undo the previous action", 'u'
   end
 
   def initialize hidden_labels=[], load_thread_opts={}
@@ -79,12 +80,14 @@ EOS
     end
   end
 
+  def unsaved?; dirty? end
   def lines; @text.length; end
   def [] i; @text[i]; end
   def contains_thread? t; @threads.include?(t) end
 
   def reload
     drop_all_threads
+    UndoManager.clear
     BufferManager.draw_screen
     load_threads :num => buffer.content_height
   end
@@ -210,6 +213,10 @@ EOS
     add_or_unhide m
   end
 
+  def undo
+    UndoManager.undo
+  end
+
   def update
     @mutex.synchronize do
       ## let's see you do THIS in python
@@ -233,65 +240,128 @@ EOS
   end
 
   def actually_toggle_starred t
+    thread = t # cargo cult programming
+    pos = curpos
     if t.has_label? :starred # if ANY message has a star
+      undo = lambda {
+        thread.first.add_label :starred
+        update_text_for_line pos
+        UpdateManager.relay self, :starred, thread.first
+      }
       t.remove_label :starred # remove from all
       UpdateManager.relay self, :unstarred, t.first
     else
+      undo = lambda {
+        thread.remove_label :starred
+        update_text_for_line pos
+        UpdateManager.relay self, :unstarred, thread.first
+      }
       t.first.add_label :starred # add only to first
       UpdateManager.relay self, :starred, t.first
     end
+
+    return undo
   end  
 
   def toggle_starred 
     t = cursor_thread or return
-    actually_toggle_starred t
+    undo = actually_toggle_starred t
+    UndoManager.register("starring/unstarring thread #{t.first.id}",undo)
     update_text_for_line curpos
     cursor_down
   end
 
   def multi_toggle_starred threads
-    threads.each { |t| actually_toggle_starred t }
+    undo = threads.map { |t| actually_toggle_starred t }
+    UndoManager.register("starring/unstarring #{threads.size} #{threads.size.pluralize 'thread'}",
+                         undo)
     regen_text
   end
 
   def actually_toggle_archived t
+    thread = t
+    pos = curpos
     if t.has_label? :inbox
       t.remove_label :inbox
+      undo = lambda {
+        thread.apply_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self,:unarchived, thread.first
+      }
       UpdateManager.relay self, :archived, t.first
     else
       t.apply_label :inbox
+      undo = lambda {
+        thread.remove_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self, :unarchived, thread.first
+      }
       UpdateManager.relay self, :unarchived, t.first
     end
+
+    return undo
   end
 
   def actually_toggle_spammed t
+    thread = t
     if t.has_label? :spam
+      undo = lambda {
+        thread.apply_label :spam
+        self.hide_thread thread
+        UpdateManager.relay self,:spammed, thread.first
+      }
       t.remove_label :spam
+      add_or_unhide t.first
       UpdateManager.relay self, :unspammed, t.first
     else
+      undo = lambda {
+        thread.remove_label :spam
+        add_or_unhide thread.first
+        UpdateManager.relay self,:unspammed, thread.first
+      }
       t.apply_label :spam
+      hide_thread t
       UpdateManager.relay self, :spammed, t.first
     end
+
+    return undo
   end
 
   def actually_toggle_deleted t
     if t.has_label? :deleted
+      undo = lambda {
+        t.apply_label :deleted
+        hide_thread t
+        UpdateManager.relay self, :deleted, t.first
+      }
       t.remove_label :deleted
+      add_or_unhide t.first
       UpdateManager.relay self, :undeleted, t.first
     else
+      undo = lambda {
+        t.remove_label :deleted
+        add_or_unhide t.first
+        UpdateManager.relay self, :undeleted, t.first
+      }
       t.apply_label :deleted
+  hide_thread t
       UpdateManager.relay self, :deleted, t.first
     end
+
+    return undo
   end
 
   def toggle_archived 
     t = cursor_thread or return
-    actually_toggle_archived t
+    undo = [actually_toggle_archived(t), lambda {self.update_text_for_line curpos}]
+    UndoManager.register("deleting/undeleting thread #{t.first.id}",undo)
     update_text_for_line curpos
   end
 
   def multi_toggle_archived threads
-    threads.each { |t| actually_toggle_archived t }
+    undo = threads.map { |t| actually_toggle_archived t}
+    UndoManager.register("deleting/undeleting #{threads.size} #{threads.size.pluralize 'thread'}",
+                         undo << lambda {self.regen_text})
     regen_text
   end
 
@@ -352,10 +422,9 @@ EOS
   ## see deleted or spam emails, and when you undelete or unspam them
   ## you also want them to disappear immediately.
   def multi_toggle_spam threads
-    threads.each do |t|
-      actually_toggle_spammed t
-      hide_thread t 
-    end
+    undo = threads.map{ |t| actually_toggle_spammed t}
+    UndoManager.register("marking/unmarking #{threads.size} #{threads.size.pluralize 'thread'} as spam",
+                         undo <<  lambda {self.regen_text})
     regen_text
   end
 
@@ -366,10 +435,9 @@ EOS
 
   ## see comment for multi_toggle_spam
   def multi_toggle_deleted threads
-    threads.each do |t|
-      actually_toggle_deleted t
-      hide_thread t 
-    end
+    undo = threads.map{ |t| actually_toggle_deleted t}
+    UndoManager.register("deleting/undeleting #{threads.size} #{threads.size.pluralize 'thread'}",
+                         undo << lambda {regen_text})
     regen_text
   end
 
@@ -378,11 +446,18 @@ EOS
     multi_kill [t]
   end
 
+  ## m-m-m-m-MULTI-KILL
   def multi_kill threads
-    threads.each do |t|
+    undo = threads.map do |t|
       t.apply_label :killed
       hide_thread t
+      thread = t
+      lambda { thread.remove_label :killed
+        add_or_unhide thread.first
+      }
     end
+    UndoManager.register("killing #{threads.size} #{threads.size.pluralize 'thread'}",
+                         undo << lambda {regen_text})
     regen_text
     BufferManager.flash "#{threads.size.pluralize 'Thread'} killed."
   end
@@ -453,6 +528,10 @@ EOS
   def edit_labels
     thread = cursor_thread or return
     speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+    old_labels = thread.labels
+    pos = curpos
+
     keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
 
     user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
@@ -461,6 +540,15 @@ EOS
     thread.labels = keepl + user_labels
     user_labels.each { |l| LabelManager << l }
     update_text_for_line curpos
+
+    undo = lambda{
+      thread.labels = old_labels
+      update_text_for_line pos
+      UpdateManager.relay self, :labeled, thread.first
+    }
+
+    UndoManager.register("labeling thread #{thread.first.id}", undo)
+
     UpdateManager.relay self, :labeled, thread.first
   end
 
@@ -471,7 +559,8 @@ EOS
     user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
     hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
     if hl.empty?
-      threads.each do |t|
+      undo = threads.map do |t|
+        old_labels = t.labels
         user_labels.each do |(l, to_remove)|
           if to_remove
             t.remove_label l
@@ -479,8 +568,16 @@ EOS
             t.apply_label l
           end
         end
+        ## UpdateManager or some other regresh mechanism?
+        UpdateManager.relay self, :labeled, t.first
+        lambda do
+          t.labels = old_labels
+          UpdateManager.relay self, :labeled, t.first
+        end
       end
       user_labels.each { |(l,_)| LabelManager << l }
+      UndoManager.register("labeling #{threads.size} #{threads.size.pluralize 'thread'}",
+                          undo << lambda { regen_text})
     else
       BufferManager.flash "'#{hl}' is a reserved label!"
     end
@@ -753,7 +850,6 @@ protected
       (t.labels - @hidden_labels).map { |label| [:label_color, "+#{label} "] } +
       [[:snippet_color, snippet]
     ]
-
   end
 
   def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
index 5027218c49d66318b309a062c1d222d81699d3a1..9a10ca67ee63e72bf8fb5456366a4f14e814c437 100644 (file)
@@ -40,7 +40,7 @@ EOS
   end
 
   def buffer
-    b, new = BufferManager.spawn_unless_exists("<poll for new messages>", :hidden => true) { PollMode.new }
+    b, new = BufferManager.spawn_unless_exists("poll for new messages", :hidden => true, :system => true) { PollMode.new }
     b
   end
 
@@ -86,7 +86,7 @@ EOS
       Index.usual_sources.each do |source|
 #        yield "source #{source} is done? #{source.done?} (cur_offset #{source.cur_offset} >= #{source.end_offset})"
         begin
-          yield "Loading from #{source}... " unless source.done? || source.has_errors?
+          yield "Loading from #{source}... " unless source.done? || (source.respond_to?(:has_errors?) && source.has_errors?)
         rescue SourceError => e
           Redwood::log "problem getting messages from #{source}: #{e.message}"
           Redwood::report_broken_sources :force_to_top => true
@@ -149,6 +149,8 @@ EOS
 
         begin
           m = Message.new :source => source, :source_info => offset, :labels => labels
+          m.load_from_source!
+
           if m.source_marked_read?
             m.remove_label :unread
             labels.delete :unread
@@ -157,7 +159,7 @@ EOS
           docid, entry = Index.load_entry_for_id m.id
           HookManager.run "before-add-message", :message => m
           m = yield(m, offset, entry) or next if block_given?
-          Index.sync_message m, docid, entry, opts
+          times = Index.sync_message m, false, docid, entry, opts
           UpdateManager.relay self, :added, m unless entry
         rescue MessageFormatError => e
           Redwood::log "ignoring erroneous message at #{source}##{offset}: #{e.message}"
index 6510aae8a738dba0b3d81acb617312a1e1334bec..91cd71f4da2461034719cced8990b243f8cf2364 100644 (file)
@@ -99,7 +99,49 @@ class Source
     end
   end
 
+  ## read a raw email header from a filehandle (or anything that responds to
+  ## #gets), and turn it into a hash of key-value pairs.
+  ##
+  ## WARNING! THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have
+  ## a significant effect on Sup's processing speed of email from ALL sources.
+  ## Little things like string interpolation, regexp interpolation, += vs <<,
+  ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO!
+  def self.parse_raw_email_header f
+    header = {}
+    last = nil
+
+    while(line = f.gets)
+      case line
+      ## these three can occur multiple times, and we want the first one
+      when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2
+      ## mark this guy specially. not sure why i care.
+      when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2
+      when /^\r*$/; break
+      else
+        if last
+          header[last] << " " unless header[last].empty?
+          header[last] << line.strip
+        end
+      end
+    end
+
+    %w(subject from to cc bcc).each do |k|
+      v = header[k] or next
+      next unless Rfc2047.is_encoded? v
+      header[k] = begin
+        Rfc2047.decode_to $encoding, v
+      rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
+        #Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
+        v
+      end
+    end
+    header
+  end
+
 protected
+
+  ## convenience function
+  def parse_raw_email_header f; self.class.parse_raw_email_header f end
   
   def Source.expand_filesystem_uri uri
     uri.gsub "~", File.expand_path("~")
diff --git a/lib/sup/undo.rb b/lib/sup/undo.rb
new file mode 100644 (file)
index 0000000..250433d
--- /dev/null
@@ -0,0 +1,42 @@
+module Redwood
+
+## Implements a single undo list for the Sup instance
+##
+## The basic idea is to keep a list of lambdas to undo
+## things. When an action is called (such as 'archive'),
+## a lambda is registered with UndoManager that will
+## undo the archival action
+
+class UndoManager
+  include Singleton
+
+  def initialize
+    @@actionlist = []
+    self.class.i_am_the_instance self
+  end
+
+  def register desc, actions
+    actions = [actions] unless actions.is_a?Array
+    raise StandardError, "when would I need to undo 'nothing?'" unless actions.length > 0
+    Redwood::log "registering #{actions.length} actions: #{desc}"
+    @@actionlist.push({:desc => desc, :actions => actions})
+  end
+
+  def undo
+    unless @@actionlist.length == 0 then
+      actionset = @@actionlist.pop
+      Redwood::log "undoing #{actionset[:desc]}..."
+      actionset[:actions].each{|action|
+        action.call
+      }
+      BufferManager.flash "undid #{actionset[:desc]}"
+    else
+      BufferManager.flash "nothing more to undo"
+    end
+  end
+
+  def clear
+    @@actionlist = []
+  end
+end
+end
index f3afa31e51a648da767c56e18cfb6fcaca635fe8..83790c5dcaeb26ff716bb7b87537373eb8a79def 100644 (file)
@@ -26,7 +26,7 @@ class DummySource < Source
   end
 
   def load_header offset
-    MBox::read_header StringIO.new(raw_header(offset))
+    Source.parse_raw_email_header StringIO.new(raw_header(offset))
   end
   
   def load_message offset
@@ -53,13 +53,6 @@ class DummySource < Source
       yield f.gets
     end
   end
-
-  # FIXME: this one was not mentioned in the source documentation, but
-  # it's still required
-  def has_errors?
-
-  end
-
 end
 
 end
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
new file mode 100644 (file)
index 0000000..7368d81
--- /dev/null
@@ -0,0 +1,107 @@
+#!/usr/bin/ruby
+
+require 'test/unit'
+require 'sup'
+require 'stringio'
+
+include Redwood
+
+class TestMBoxParsing < Test::Unit::TestCase
+  def setup
+  end
+
+  def teardown
+  end
+
+  def test_normal_headers
+    h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+To: Sally <sally@sally.com>
+EOS
+
+    assert_equal "Bob <bob@bob.com>", h["from"]
+    assert_equal "Sally <sally@sally.com>", h["to"]
+    assert_nil h["message-id"]
+  end
+
+  def test_multiline
+    h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+Subject: one two three
+  four five six
+To: Sally <sally@sally.com>
+References: <seven>
+  <eight>
+Seven: Eight
+EOS
+
+    assert_equal "one two three four five six", h["subject"]
+    assert_equal "Sally <sally@sally.com>", h["to"]
+    assert_equal "<seven> <eight>", h["references"]
+  end
+
+  def test_ignore_spacing
+    variants = [
+      "Subject:one two  three   end\n",
+      "Subject:    one two  three   end\n",
+      "Subject:   one two  three   end    \n",
+    ]
+    variants.each do |s|
+      h = Source.parse_raw_email_header StringIO.new(s)
+      assert_equal "one two  three   end", h["subject"]
+    end
+  end
+
+  def test_message_id_ignore_spacing
+    variants = [
+      "Message-Id:     <one@bob.com>       \n",
+      "Message-Id:<one@bob.com>       \n",
+    ]
+    variants.each do |s|
+      h = Source.parse_raw_email_header StringIO.new(s)
+      assert_equal "<one@bob.com>", h["message-id"]
+    end
+  end
+
+  def test_blank_lines
+    h = Source.parse_raw_email_header StringIO.new("")
+    assert_equal nil, h["message-id"]
+  end
+
+  def test_empty_headers
+    variants = [
+      "Message-Id:       \n",
+      "Message-Id:\n",
+    ]
+    variants.each do |s|
+      h = Source.parse_raw_email_header StringIO.new(s)
+      assert_equal "", h["message-id"]
+    end
+  end
+
+  def test_detect_end_of_headers
+    h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+
+To: a dear friend
+EOS
+  assert_equal "Bob <bob@bob.com>", h["from"]
+  assert_nil h["to"]
+
+  h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+\r
+To: a dear friend
+EOS
+  assert_equal "Bob <bob@bob.com>", h["from"]
+  assert_nil h["to"]
+
+  h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+\r\n\r
+To: a dear friend
+EOS
+  assert_equal "Bob <bob@bob.com>", h["from"]
+  assert_nil h["to"]
+  end
+end
diff --git a/test/test_mbox_parsing.rb b/test/test_mbox_parsing.rb
deleted file mode 100644 (file)
index 32687e5..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/usr/bin/ruby
-
-require 'test/unit'
-require 'sup'
-require 'stringio'
-
-include Redwood
-
-class TestMBoxParsing < Test::Unit::TestCase
-  def setup
-  end
-
-  def teardown
-  end
-
-  def test_normal_headers
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-To: Sally <sally@sally.com>
-EOS
-
-    assert_equal "Bob <bob@bob.com>", h["From"]
-    assert_equal "Sally <sally@sally.com>", h["To"]
-    assert_nil h["Message-Id"]
-  end
-
-  ## this is shitty behavior in retrospect, but it's built in now.
-  def test_message_id_stripping
-    h = MBox.read_header StringIO.new("Message-Id: <one@bob.com>\n")
-    assert_equal "one@bob.com", h["Message-Id"]
-
-    h = MBox.read_header StringIO.new("Message-Id: one@bob.com\n")
-    assert_equal "one@bob.com", h["Message-Id"]
-  end
-
-  def test_multiline
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-Subject: one two three
-  four five six
-To: Sally <sally@sally.com>
-References: seven
-  eight
-Seven: Eight
-EOS
-
-    assert_equal "one two three four five six", h["Subject"]
-    assert_equal "Sally <sally@sally.com>", h["To"]
-    assert_equal "seven eight", h["References"]
-  end
-
-  def test_ignore_spacing
-    variants = [
-      "Subject:one two  three   end\n",
-      "Subject:    one two  three   end\n",
-      "Subject:   one two  three   end    \n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "one two  three   end", h["Subject"]
-    end
-  end
-
-  def test_message_id_ignore_spacing
-    variants = [
-      "Message-Id:     <one@bob.com>       \n",
-      "Message-Id:      one@bob.com        \n",
-      "Message-Id:<one@bob.com>       \n",
-      "Message-Id:one@bob.com       \n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "one@bob.com", h["Message-Id"]
-    end
-  end
-
-  def test_blank_lines
-    h = MBox.read_header StringIO.new("")
-    assert_equal nil, h["Message-Id"]
-  end
-
-  def test_empty_headers
-    variants = [
-      "Message-Id:       \n",
-      "Message-Id:\n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "", h["Message-Id"]
-    end
-  end
-
-  def test_detect_end_of_headers
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-
-  h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-
-  h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r\n\r
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-  end
-end
index e38ac5064ca999f8b95b6d70c93f9b05ed8796f4..0a7db454febd8c93238dacaa89590f6110222cc6 100644 (file)
@@ -511,7 +511,7 @@ EOS
 
     # Look at another header field whose first line was blank.
     list_unsubscribe = sup_message.list_unsubscribe
-    assert_equal(" <http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
+    assert_equal("<http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
                  "<mailto:monitor-list-request@widget.com?subject=unsubscribe>",
                  list_unsubscribe)