]> git.cworth.org Git - sup/commitdiff
Merge branch 'attachments'
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 19 Jun 2008 18:11:12 +0000 (11:11 -0700)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 19 Jun 2008 18:11:12 +0000 (11:11 -0700)
12 files changed:
Rakefile
bin/sup
lib/sup.rb
lib/sup/keymap.rb
lib/sup/maildir.rb
lib/sup/mbox.rb
lib/sup/message.rb
lib/sup/modes/edit-message-mode.rb
lib/sup/modes/inbox-mode.rb
lib/sup/modes/scroll-mode.rb
lib/sup/util.rb
test/test_mbox_parsing.rb [new file with mode: 0644]

index 4bbfb53d1bedbf6c9aef654ef9374ddf794c238e..8b020df64837d13453c4f78e3ef90e0c476ef93e 100644 (file)
--- a/Rakefile
+++ b/Rakefile
@@ -24,7 +24,7 @@ Hoe.new('sup', version) do |p|
   p.url = p.paragraphs_of('README.txt', 0).first.split(/\n/)[2].gsub(/^\s+/, "")
   p.changes = p.paragraphs_of('History.txt', 0..0).join("\n\n")
   p.email = "wmorgan-sup@masanjin.net"
-  p.extra_deps = [['ferret', '>= 0.10.13'], ['ncurses', '>= 0.9.1'], ['rmail', '>= 0.17'], 'highline', 'net-ssh', ['trollop', '>= 1.7'], 'lockfile', 'mime-types', 'gettext']
+  p.extra_deps = [['ferret', '>= 0.10.13'], ['ncurses', '>= 0.9.1'], ['rmail', '>= 0.17'], 'highline', 'net-ssh', ['trollop', '>= 1.7'], 'lockfile', 'mime-types', 'gettext', 'fastthread']
 end
 
 rule 'ss?.png' => 'ss?-small.png' do |t|
diff --git a/bin/sup b/bin/sup
index 723b1ed1e850b5eebadaf5804484793cc0a07b3f..eb45461ee95e437293e05134033b5079d8088164 100644 (file)
--- a/bin/sup
+++ b/bin/sup
@@ -5,6 +5,7 @@ require 'ncurses'
 require 'curses'
 require 'fileutils'
 require 'trollop'
+require 'fastthread'
 require "sup"
 
 BIN_VERSION = "git"
@@ -21,7 +22,6 @@ EOS
   exit(-1)
 end
 
-$exceptions = []
 $opts = Trollop::options do
   version "sup v#{Redwood::VERSION}"
   banner <<EOS
@@ -224,7 +224,7 @@ begin
     SearchResultsMode.spawn_from_query $opts[:search]
   end
 
-  until $exceptions.nonempty? || SuicideManager.die?
+  until Redwood::exceptions.nonempty? || SuicideManager.die?
     c = Ncurses.nonblocking_getch
     next unless c
     bm.erase_flash
@@ -305,7 +305,7 @@ begin
 
   bm.kill_all_buffers if SuicideManager.die?
 rescue Exception => e
-  $exceptions << [e, "main"]
+  Redwood::record_exception e, "main"
 ensure
   unless $opts[:no_threads]
     PollManager.stop if PollManager.instantiated?
@@ -321,7 +321,7 @@ ensure
     Redwood::log "I've been ordered to commit seppuku. I obey!"
   end
 
-  if $exceptions.empty?
+  if Redwood::exceptions.empty?
     Redwood::log "no fatal errors. good job, william."
     Index.save
   else
@@ -331,9 +331,9 @@ ensure
   Index.unlock
 end
 
-unless $exceptions.empty?
+unless Redwood::exceptions.empty?
   File.open(File.join(BASE_DIR, "exception-log.txt"), "w") do |f|
-    $exceptions.each do |e, name|
+    Redwood::exceptions.each do |e, name|
       f.puts "--- #{e.class.name} from thread: #{name}"
       f.puts e.message, e.backtrace
     end
@@ -350,7 +350,7 @@ Sincerely,
 William
 ----------------------------------------------------------------
 EOS
-  $exceptions.each do |e, name|
+  Redwood::exceptions.each do |e, name|
     puts "--- #{e.class.name} from thread: #{name}"
     puts e.message, e.backtrace
   end
index 9e9026747bca29324fe44cf4b4df12c1d8e54ccc..5211eec291bbc2750c79f88c9e2e5248483bfd9b 100644 (file)
@@ -50,7 +50,18 @@ module Redwood
   YAML_DOMAIN = "masanjin.net"
   YAML_DATE = "2006-10-01"
 
-## record exceptions thrown in threads nicely
+  ## record exceptions thrown in threads nicely
+  @exceptions = []
+  @exception_mutex = Mutex.new
+
+  attr_reader :exceptions
+  def record_exception e, name
+    @exception_mutex.synchronize do
+      @exceptions ||= []
+      @exceptions << [e, name]
+    end
+  end
+
   def reporting_thread name
     if $opts[:no_threads]
       yield
@@ -59,14 +70,13 @@ module Redwood
         begin
           yield
         rescue Exception => e
-          $exceptions ||= []
-          $exceptions << [e, name]
-          raise
+          record_exception e, name
         end
       end
     end
   end
-  module_function :reporting_thread
+
+  module_function :reporting_thread, :record_exception, :exceptions
 
 ## one-stop shop for yamliciousness
   def save_yaml_obj object, fn, safe=false
index 3176415e84f6fd5e7b307f25ff2e801183a352a7..76c7139f45eb9478a627e49ac7c87eea81d0ee5d 100644 (file)
@@ -43,16 +43,10 @@ class Keymap
     when :home: "<home>"
     when :end: "<end>"
     when :enter, :return: "<enter>"
-    when :ctrl_l: "ctrl-l"
-    when :ctrl_g: "ctrl-g"
     when :tab: "tab"
     when " ": "<space>"
     else
-      if k.is_a?(String) && k.length == 1
-        k
-      else
-        raise ArgumentError, "unknown key name \"#{k}\""
-      end
+      Curses::keyname(keysym_to_keycode(k))
     end
   end
 
index 620e8e2ad51ad6fe45f975237de13a3d2a95cc97..74a3e02554c7eed68ce29de41c71e1417b669fed 100644 (file)
@@ -12,14 +12,14 @@ class Maildir < Source
   SCAN_INTERVAL = 30 # seconds
 
   ## remind me never to use inheritance again.
-  yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
-  def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[]
+  yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels, :mtimes
+  def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[], mtimes={}
     super uri, last_date, usual, archived, id
     uri = URI(Source.expand_filesystem_uri(uri))
 
     raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
     raise ArgumentError, "maildir URI cannot have a host: #{uri.host}" if uri.host
-    raise ArgumentError, "mbox URI must have a path component" unless uri.path
+    raise ArgumentError, "maildir URI must have a path component" unless uri.path
 
     @dir = uri.path
     @labels = (labels || []).freeze
@@ -27,6 +27,11 @@ class Maildir < Source
     @ids_to_fns = {}
     @last_scan = nil
     @mutex = Mutex.new
+    #the mtime from the subdirs in the maildir with the unix epoch as default.
+    #these are used to determine whether scanning the directory for new mail
+    #is a worthwhile effort
+    @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }.merge(mtimes || {})
+    @dir_ids = { 'cur' => [], 'new' => [] }
   end
 
   def file_path; @dir end
@@ -79,21 +84,34 @@ class Maildir < Source
     return unless @ids.empty? || opts[:rescan]
     return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
 
-    Redwood::log "scanning maildir..."
-    cdir = File.join(@dir, 'cur')
-    ndir = File.join(@dir, 'new')
-    
-    raise FatalSourceError, "#{cdir} not a directory" unless File.directory? cdir
-    raise FatalSourceError, "#{ndir} not a directory" unless File.directory? ndir
+    initial_poll = @ids.empty?
 
+    Redwood::log "scanning maildir #@dir..."
     begin
-      @ids, @ids_to_fns = [], {}
-      (Dir[File.join(cdir, "*")] + Dir[File.join(ndir, "*")]).map do |fn|
-        id = make_id fn
-        @ids << id
-        @ids_to_fns[id] = fn
+      @mtimes.each_key do |d|
+       subdir = File.join(@dir, d)
+       raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
+
+       mtime = File.mtime subdir
+
+       #only scan the dir if the mtime is more recent (or we haven't polled
+       #since startup)
+       if @mtimes[d] < mtime || initial_poll
+         @mtimes[d] = mtime
+         @dir_ids[d] = []
+         Dir[File.join(subdir, '*')].map do |fn|
+           id = make_id fn
+           @dir_ids[d] << id
+           @ids_to_fns[id] = fn
+         end
+       else
+         Redwood::log "no poll on #{d}.  mtime on indicates no new messages."
+       end
       end
-      @ids.sort!
+      @ids = @dir_ids.values.flatten.uniq.sort!
+      #remove old id to fn mappings...hopefully this doesn't actually change
+      #anything...normally, we'll add to this list but never remove mail.
+      @ids_to_fns.delete_if { |k, v| !@ids.include?(k) }
     rescue SystemCallError, IOError => e
       raise FatalSourceError, "Problem scanning Maildir directories: #{e.message}."
     end
@@ -145,8 +163,11 @@ class Maildir < Source
 private
 
   def make_id fn
+    #doing this means 1 syscall instead of 2 (File.mtime, File.size).
+    #makes a noticeable difference on nfs.
+    stat = File.stat(fn)
     # use 7 digits for the size. why 7? seems nice.
-    sprintf("%d%07d", File.mtime(fn), File.size(fn) % 10000000).to_i
+    sprintf("%d%07d", stat.mtime, stat.size % 10000000).to_i
   end
 
   def with_file_for id
index f267b3b03aebbe846e7b3b08148cf7c72797a8a8..49489541a0b3ea9be3a46883c472951ce589191e 100644 (file)
@@ -11,6 +11,7 @@ module Redwood
 ## TODO: move functionality to somewhere better, like message.rb
 module MBox
   BREAK_RE = /^From \S+/
+  HEADER_RE = /\s*(.*?\S)\s*/
 
   def read_header f
     header = {}
@@ -20,26 +21,26 @@ module MBox
     ## when scanning over large mbox files.
     while(line = f.gets)
       case line
-      when /^(From):\s*(.*?)\s*$/i,
-        /^(To):\s*(.*?)\s*$/i,
-        /^(Cc):\s*(.*?)\s*$/i,
-        /^(Bcc):\s*(.*?)\s*$/i,
-        /^(Subject):\s*(.*?)\s*$/i,
-        /^(Date):\s*(.*?)\s*$/i,
-        /^(References):\s*(.*?)\s*$/i,
-        /^(In-Reply-To):\s*(.*?)\s*$/i,
-        /^(Reply-To):\s*(.*?)\s*$/i,
-        /^(List-Post):\s*(.*?)\s*$/i,
-        /^(List-Subscribe):\s*(.*?)\s*$/i,
-        /^(List-Unsubscribe):\s*(.*?)\s*$/i,
-        /^(Status):\s*(.*?)\s*$/i: header[last = $1] = $2
-      when /^(Message-Id):\s*(.*?)\s*$/i: header[mid_field = 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: header[last = $1] = $2
+      when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
 
       ## these next three can occur multiple times, and we want the
       ## first one
-      when /^(Delivered-To):\s*(.*)$/i,
-        /^(X-Original-To):\s*(.*)$/i,
-        /^(Envelope-To):\s*(.*)$/i: header[last = $1] ||= $2
+      when /^(Delivered-To):#{HEADER_RE}$/i,
+        /^(X-Original-To):#{HEADER_RE}$/i,
+        /^(Envelope-To):#{HEADER_RE}$/i: header[last = $1] ||= $2
 
       when /^\r*$/: break
       when /^\S+:/: last = nil # some other header we don't care about
index 5fc91def4914d14927c35cefa91c74b28d97b0e2..2cf97c8ce482c69097d6b8f014d06ff0be6060c5 100644 (file)
@@ -149,7 +149,18 @@ class Message
     @source.fn_for_offset @source_info
   end
 
-  def sanitize_message_id mid; mid.gsub(/\s+/, "")[0..254] end
+  ## sanitize message ids by removing spaces and non-ascii characters.
+  ## also, truncate to 255 characters. all these steps are necessary
+  ## to make ferret happy. of course, we probably fuck up a couple
+  ## valid message ids as well. as long as we're consistent, this
+  ## should be fine, though.
+  ##
+  ## also, mostly the message ids that are changed by this belong to
+  ## spam email.
+  ##
+  ## an alternative would be to SHA1 or MD5 all message ids on a regular basis.
+  ## don't tempt me.
+  def sanitize_message_id mid; mid.gsub(/(\s|[^\000-\177])+/, "")[0..254] end
 
   def save index
     return unless @dirty
index 88debb28126c635205ec87e4fcd7504465415928..cc6e7af4a482d46dfefd74f5034846c81035620d 100644 (file)
@@ -23,7 +23,7 @@ Variables:
   from_email: the email part of the From: line, or nil if empty
 Return value:
   A string (multi-line ok) containing the text of the signature, or nil to
-  use the default signature.
+  use the default signature, or :none for no signature.
 EOS
 
   HookManager.register "before-edit", <<EOS
@@ -122,7 +122,7 @@ EOS
     @file = Tempfile.new "sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}"
     @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
     @file.puts
-    @file.puts @body
+    @file.puts @body.join("\n")
     @file.close
 
     editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi"
@@ -213,7 +213,7 @@ protected
   def parse_file fn
     File.open(fn) do |f|
       header = MBox::read_header f
-      body = f.readlines
+      body = f.readlines.map { |l| l.chomp }
 
       header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
       header.each { |k, v| header[k] = parse_header k, v }
@@ -304,7 +304,7 @@ protected
   def build_message date
     m = RMail::Message.new
     m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
-    m.body = @body.join
+    m.body = @body.join("\n")
     m.body += sig_lines.join("\n") unless $config[:edit_signature]
     ## body must end in a newline or GPG signatures will be WRONG!
     m.body += "\n" unless m.body =~ /\n\Z/
@@ -365,7 +365,7 @@ EOS
     end
 
     f.puts
-    f.puts sanitize_body(@body.join)
+    f.puts sanitize_body(@body.join("\n"))
     f.puts sig_lines if full unless $config[:edit_signature]
   end  
 
@@ -408,7 +408,7 @@ private
   end
 
   def top_posting?
-    @body.join =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
+    @body.join("\n") =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
   end
 
   def sig_lines
@@ -417,6 +417,8 @@ private
 
     ## first run the hook
     hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
+
+    return [] if hook_sig == :none
     return ["", "-- "] + hook_sig.split("\n") if hook_sig
 
     ## no hook, do default signature generation based on config.yaml
index f156b69fa55fc774619cebcee136a5a575cb5d95..559892d5655f07b80a1186e114950156220a13c5 100644 (file)
@@ -6,6 +6,7 @@ class InboxMode < ThreadIndexMode
   register_keymap do |k|
     ## overwrite toggle_archived with archive
     k.add :archive, "Archive thread (remove from inbox)", 'a'
+    k.add :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
   end
 
   def initialize
@@ -38,6 +39,23 @@ class InboxMode < ThreadIndexMode
     regen_text
   end
 
+  def read_and_archive
+    return unless cursor_thread
+    cursor_thread.remove_label :unread
+    cursor_thread.remove_label :inbox
+    hide_thread cursor_thread
+    regen_text
+  end
+
+  def multi_read_and_archive threads
+    threads.each do |t|
+      t.remove_label :unread
+      t.remove_label :inbox
+      hide_thread t
+    end
+    regen_text
+  end
+
   def handle_unarchived_update sender, m
     add_or_unhide m
   end
index 922bf92b46169f4d5e0dde916312c10588e0d2ec..66c098be06b43d343ac7b0e120589d71b3ea20f7 100644 (file)
@@ -15,12 +15,14 @@ class ScrollMode < Mode
   COL_JUMP = 2
 
   register_keymap do |k|
-    k.add :line_down, "Down one line", :down, 'j', 'J'
-    k.add :line_up, "Up one line", :up, 'k', 'K'
+    k.add :line_down, "Down one line", :down, 'j', 'J', "\C-e"
+    k.add :line_up, "Up one line", :up, 'k', 'K', "\C-y"
     k.add :col_left, "Left one column", :left, 'h'
     k.add :col_right, "Right one column", :right, 'l'
-    k.add :page_down, "Down one page", :page_down, ' '
-    k.add :page_up, "Up one page", :page_up, 'p', :backspace
+    k.add :page_down, "Down one page", :page_down, ' ', "\C-f"
+    k.add :page_up, "Up one page", :page_up, 'p', :backspace, "\C-b"
+    k.add :half_page_down, "Down one half page", "\C-d"
+    k.add :half_page_up, "Up one half page", "\C-u"
     k.add :jump_to_start, "Jump to top", :home, '^', '1'
     k.add :jump_to_end, "Jump to bottom", :end, '$', '0'
     k.add :jump_to_left, "Jump to the left", '['
@@ -85,15 +87,16 @@ class ScrollMode < Mode
     continue_search_in_buffer
   end
 
-  ## subclasses can override these two!
+  ## subclasses can override these three!
   def search_goto_pos line, leftcol, rightcol
-    jump_to_line line
+    search_goto_line line
 
     if rightcol > self.rightcol # if it's occluded...
       jump_to_col [rightcol - buffer.content_width + 1, 0].max # move right
     end
   end
   def search_start_line; @topline end
+  def search_goto_line line; jump_to_line line end
 
   def col_left
     return unless @leftcol > 0
@@ -130,6 +133,8 @@ class ScrollMode < Mode
   def line_up;  jump_to_line @topline - 1; end
   def page_down; jump_to_line @topline + buffer.content_height - @slip_rows; end
   def page_up; jump_to_line @topline - buffer.content_height + @slip_rows; end
+  def half_page_down; jump_to_line @topline + buffer.content_height / 2; end
+  def half_page_up; jump_to_line @topline - buffer.content_height / 2; end
   def jump_to_start; jump_to_line 0; end
   def jump_to_end; jump_to_line lines - buffer.content_height; end
 
index ceaf0b8dabc8a83ea451005e9b27bc109823caf1..9909022ffa9b0b1c5796f4fbdff925087dec2519 100644 (file)
@@ -108,7 +108,9 @@ class Module
   def defer_all_other_method_calls_to obj
     class_eval %{
       def method_missing meth, *a, &b; @#{obj}.send meth, *a, &b; end
-      def respond_to? meth; @#{obj}.respond_to?(meth); end
+      def respond_to?(m, include_private = false)
+        @#{obj}.respond_to?(m, include_private)
+      end
     }
   end
 end
@@ -527,7 +529,9 @@ class Recoverable
   def to_yaml x; __pass :to_yaml, x; end
   def is_a? c; @o.is_a? c; end
 
-  def respond_to? m; @o.respond_to? m end
+  def respond_to?(m, include_private=false)
+    @o.respond_to?(m, include_private)
+  end
 
   def __pass m, *a, &b
     begin
diff --git a/test/test_mbox_parsing.rb b/test/test_mbox_parsing.rb
new file mode 100644 (file)
index 0000000..070b152
--- /dev/null
@@ -0,0 +1,114 @@
+#!/usr/bin/ruby
+
+require 'test/unit'
+require 'sup'
+require 'stringio'
+
+include Redwood
+
+class TestMessage < 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_ignore_empty_lines
+    variants = [
+      "",
+      "Message-Id:       \n",
+      "Message-Id:\n",
+    ]
+    variants.each do |s|
+      h = MBox.read_header StringIO.new(s)
+      assert_nil 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