]> git.cworth.org Git - sup/commitdiff
Merge commit 'origin/undo-manager'
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 28 May 2009 14:45:06 +0000 (10:45 -0400)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 28 May 2009 14:47:35 +0000 (10:47 -0400)
Conflicts:

lib/sup/modes/thread-index-mode.rb

62 files changed:
.gitignore
CONTRIBUTORS
History.txt
README.txt
Rakefile
ReleaseNotes
bin/sup [changed mode: 0644->0755]
bin/sup-add [changed mode: 0644->0755]
bin/sup-config [changed mode: 0644->0755]
bin/sup-dump [changed mode: 0644->0755]
bin/sup-recover-sources [changed mode: 0644->0755]
bin/sup-sync [changed mode: 0644->0755]
bin/sup-sync-back [changed mode: 0644->0755]
bin/sup-tweak-labels [changed mode: 0644->0755]
bugs/issue-2673f091c15dd90222a59621a1842d4ef0a743f7.yaml [new file with mode: 0644]
bugs/issue-60d86dd32054533a6206f698033ec668af6a7574.yaml
bugs/issue-aae5ae6378afa9bd2a8e1b15d28ba7ccef867791.yaml
contrib/completion/_sup.zsh [new file with mode: 0644]
doc/Hooks.txt
doc/NewUserGuide.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/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-chunks.rb
lib/sup/message.rb
lib/sup/mode.rb
lib/sup/modes/buffer-list-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/label-list-mode.rb
lib/sup/modes/reply-mode.rb
lib/sup/modes/resume-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/source.rb
lib/sup/textfield.rb
lib/sup/util.rb
release-script.txt
sup-files.rb [new file with mode: 0644]
sup-version.rb [new file with mode: 0644]
sup.gemspec [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
www/index.html

index 37775a6a86633f9f5481da9768a486427e1576f8..b8d3bcd9db2d341bdbfd2ac740281e33f5efa77c 100644 (file)
@@ -1,3 +1,5 @@
 # i use vi
 *.swp
 .ditz-config
+# i use emacs
+*~
index a6e5d4d319291f1532bb1ec2f8476b5405b954ed..56f553fe333252beb2cc07a8646344838db72542 100644 (file)
@@ -1,14 +1,17 @@
 William Morgan <wmorgan-sup at the masanjin dot nets>
 Ismo Puustinen <ismo at the iki dot fis>
+Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
 Marcus Williams <marcus-sup at the bar-coded dot nets>
 Lionel Ott <white.magic at the gmx dot des>
-Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
+Mark Alexander <marka at the pobox dot coms>
+Christopher Warrington <chrisw at the rice dot edus>
 Marc Hartstein <marc.hartstein at the alum.vassar dot edus>
 Ben Walton <bwalton at the artsci.utoronto dot cas>
 Grant Hollingworth <grant at the antiflux dot orgs>
+Steve Goldman <sgoldman at the tower-research dot coms>
+Decklin Foster <decklin at the red-bean dot coms>
 Jeff Balogh <its.jeff.balogh at the gmail dot coms>
-Christopher Warrington <chrisw at the rice dot edus>
 Giorgio Lando <patroclo7 at the gmail dot coms>
-Decklin Foster <decklin at the red-bean dot coms>
+Israel Herraiz <israel.herraiz at the gmail dot coms>
 Ian Taylor <ian at the lorf dot orgs>
-Sam Roberts <sroberts at the uniserve dot coms>
+Rich Lane <rlane at the club.cc.cmu dot edus>
index b717655cdbd615a889f93f7409e38485f822d7d5..2813c6448459e7e76938a63d043e6c8e509e6031 100644 (file)
@@ -1,3 +1,15 @@
+== 0.7 / 2009-03-16
+* Ferret index corruption issues fixed (hopefully!)
+* Text entry now scrolls to the right on overflow, i.e. is actually usable
+* Ctrl-C now asks user if Sup should die ungracefully
+* Add a limit:<int> search operator to limit the number of results
+* Added a --query option to sup-tweak-labels
+* Added a new hook: shutdown
+* Automatically add self as recipient on crypted sent messages
+* Read in X-Foo headers
+* Added global keybinding 'U' shows only unread messages
+* As always, many bugfixes and tweaks
+
 == 0.6 / 2008-08-04
 * new hooks: mark-as-spam, reply-to, reply-from
 * configurable colors. finally!
index 05349d787e9d3aee6086fb118f0f5ad61f59447f..0437de9f0c91b87b300c99077a88d7df82162100 100644 (file)
@@ -108,7 +108,7 @@ Current limitations which will be fixed:
 
 == INSTALL:
 
-* gem install sup -y
+* gem install sup
 
 == PROBLEMS:
 
@@ -116,7 +116,7 @@ See FAQ.txt for some common problems and their solutions.
 
 == LICENSE:
 
-Copyright (c) 2006, 2007 William Morgan.
+Copyright (c) 2006--2009 William Morgan.
 
 This program is free software; you can redistribute it and/or
 modify it under the terms of the GNU General Public License
index 52ab3808005b9676458f424ddfc52fffa4260a8a..3b1d9f4fa82953e74a5d8af946591f6468749003 100644 (file)
--- a/Rakefile
+++ b/Rakefile
@@ -1,39 +1,8 @@
-require 'rubygems'
-require 'hoe'
-$:.unshift 'lib' # force loading from ./lib/ if it exists
-require 'sup'
-
-## remove hoe dependency entirely
-class Hoe
-  def extra_dev_deps; @extra_dev_deps.reject { |x| x[0] == "hoe" } end
-end
-
-## allow people who use development versions by running "rake gem"
-## and installing the resulting gem it to be able to do this. (gem
-## versions must be in dotted-digit notation only and can be passed
-## with the REL environment variable to "rake gem").
-if ENV['REL']
-  version = ENV['REL']
-else
-  version = Redwood::VERSION == "git" ? "999" : Redwood::VERSION
-end
-Hoe.new('sup', version) do |p|
-  p.rubyforge_name = 'sup'
-  p.author = "William Morgan"
-  p.summary = 'A console-based email client with the best features of GMail, mutt, and emacs. Features full text search, labels, tagged operations, multiple buffers, recent contacts, and more.'
-  p.description = p.paragraphs_of('README.txt', 2..9).join("\n\n")
-  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', 'fastthread']
-end
-
-rule 'ss?.png' => 'ss?-small.png' do |t|
-end
-
 ## is there really no way to make a rule for this?
 WWW_FILES = %w(www/index.html README.txt doc/Philosophy.txt doc/FAQ.txt doc/NewUserGuide.txt www/main.css)
 
+rule 'ss?.png' => 'ss?-small.png' do |t|
+end
 SCREENSHOTS = FileList["www/ss?.png"]
 SCREENSHOTS_SMALL = []
 SCREENSHOTS.each do |fn|
@@ -59,3 +28,13 @@ task :upload_report do |t|
   sh "ditz html ditz"
   sh "rsync -essh -cavz ditz wmorgan@rubyforge.org:/var/www/gforge-projects/sup/"
 end
+
+task :gem do |t|
+  sh "gem1.8 build sup.gemspec"
+end
+
+task :tarball do |t|
+  require "sup-files"
+  require "sup-version"
+  sh "tar cfvz sup-#{SUP_VERSION}.tgz #{SUP_FILES.join(' ')}"
+end
index 923715cf52177a01e15a55294eb7cd270fb0eb3f..a965fa0ec099b1ab7cfa3afa21ee5c64d97ad07a 100644 (file)
@@ -1,3 +1,14 @@
+Release 0.7:
+
+The big win in this release is that Ferret index corruption issues should now
+be fixed, thanks to an extensive programming of locking and
+thread-safety-adding.
+
+The other nice change is that text entry will now scroll to the right upon
+overflow, thanks to some arcane Curses magic that Steve Goldman discovered.
+
+As always, this release includes many other bugfixes and enhancements.
+
 Release 0.6:
 
 Message attachment searchability automatically takes effect on new messages,
diff --git a/bin/sup b/bin/sup
old mode 100644 (file)
new mode 100755 (executable)
index 124af75..0af3d11
--- a/bin/sup
+++ b/bin/sup
@@ -45,6 +45,14 @@ No variables.
 No return value.
 EOS
 
+Redwood::HookManager.register "shutdown", <<EOS 
+Executes when sup is shutting down. May be run when sup is crashing,
+so don\'t do anything too important. Run before the label, contacts,
+and people are saved.
+No variables.
+No return value.
+EOS
+
 if $opts[:list_hooks]
   Redwood::HookManager.print_hooks
   exit
@@ -57,11 +65,11 @@ module Redwood
 global_keymap = Keymap.new do |k|
   k.add :quit_ask, "Quit Sup, but ask first", 'q'
   k.add :quit_now, "Quit Sup immediately", 'Q'
-  k.add :help, "Show help", 'H', '?'
+  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'
@@ -73,11 +81,42 @@ 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
   Ncurses.cbreak
   Ncurses.stdscr.keypad 1
+  Ncurses.use_default_colors
   Ncurses.curs_set 0
   Ncurses.start_color
   $cursing = true
@@ -181,7 +220,16 @@ begin
   end
 
   until Redwood::exceptions.nonempty? || SuicideManager.die?
-    c = Ncurses.nonblocking_getch
+    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
     bm.erase_flash
 
@@ -195,7 +243,6 @@ begin
       rescue InputSequenceAborted
         :nothing
       end
-
     case action
     when :quit_now
       break if bm.kill_all_buffers_safely
@@ -213,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
@@ -224,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?
@@ -271,6 +318,8 @@ ensure
     Index.stop_lock_update_thread
   end
 
+  HookManager.run "shutdown"
+
   Redwood::finish
   stop_cursing
   Redwood::log "stopped cursing"
old mode 100644 (file)
new mode 100755 (executable)
old mode 100644 (file)
new mode 100755 (executable)
old mode 100644 (file)
new mode 100755 (executable)
old mode 100644 (file)
new mode 100755 (executable)
old mode 100644 (file)
new mode 100755 (executable)
index ac5caf6..9c342d2
@@ -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
@@ -100,7 +102,7 @@ restored_state =
     IO.foreach opts[:restore] do |l|
       l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}"
       mid, labels = $1, $2
-      dump[mid] = labels.split(" ").map { |x| x.intern }
+      dump[mid] = labels.symbolistize
     end
     $stderr.puts "Read #{dump.size} entries from dump file."
     dump
@@ -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,18 +141,22 @@ 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.
       next if target == :changed && entry && entry[:source_id].to_i == source.id && entry[:source_info].to_i == offset
 
       ## get the state currently in the index
-      index_state =
-        if entry
-          entry[:label].split(/\s+/).map { |x| x.intern }
-        else
-          nil
-        end
+      index_state = entry[:label].symbolistize if entry
 
       ## skip if we're operating on restored messages, and this one
       ## ain't.
@@ -158,12 +166,12 @@ begin
       ## to default source state modification flags.
       m.labels -= [:inbox] if opts[:archive]
       m.labels -= [:unread] if opts[:read]
-      m.labels += opts[:extra_labels].split(/\s*,\s*/).map { |x| x.intern } if opts[:extra_labels]
+      m.labels += opts[:extra_labels].strip.split(/\s*,\s*/).map { |x| x.intern } if opts[:extra_labels]
 
       ## assign message labels based on the operation we're performing
       case op
       when :asis
-        m.labels = index_state if index_state
+        m.labels = ((m.labels - [:unread, :inbox]) + index_state).uniq if index_state
       when :restore
         ## if the entry exists on disk
         if restored_state[m.id]
@@ -176,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]
@@ -205,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|
old mode 100644 (file)
new mode 100755 (executable)
index 4216cf9..4f1387e
@@ -109,7 +109,7 @@ EOS
       num_scanned += 1
 
       if entry
-        labels = entry[:label].split.map { |x| x.intern }.to_boolean_h
+        labels = entry[:label].symbolistize.to_boolean_h
 
         if labels.member? :deleted
           if opts[:drop_deleted]
old mode 100644 (file)
new mode 100755 (executable)
index 21e766c..8a8152d
@@ -39,16 +39,19 @@ Options:
 EOS
   opt :add, "One or more labels (comma-separated) to add to every message from the specified sources", :type => String
   opt :remove, "One or more labels (comma-separated) to remove from every message from the specified sources, if those labels are present", :type => String
+  opt :query, "A Sup search query", :type => String
 
   text <<EOS
 
 Other options:
 EOS
   opt :verbose, "Print message ids as they're processed."
+  opt :very_verbose, "Print message names and subjects as they're processed."
   opt :all_sources, "Scan over all sources.", :short => :none
   opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n"
   opt :version, "Show version information", :short => :none
 end
+opts[:verbose] = true if opts[:very_verbose]
 
 add_labels = (opts[:add] || "").split(",").map { |l| l.intern }.uniq
 remove_labels = (opts[:remove] || "").split(",").map { |l| l.intern }.uniq
@@ -70,12 +73,16 @@ begin
   end.map { |s| s.id }
   Trollop::die "nothing to do: no sources" if source_ids.empty?
 
-  query = "+(" + source_ids.map { |id| "source_id:#{id}" }.join(" ") + ")"
+  query = "+(" + source_ids.map { |id| "source_id:#{id}" }.join(" OR ") + ")"
   if add_labels.empty?
     ## if all we're doing is removing labels, we can further restrict the
     ## query to only messages with those labels
     query += " +(" + remove_labels.map { |l| "label:#{l}" }.join(" ") + ")"
   end
+  query += ' ' + opts[:query] if opts[:query]
+
+  qobj, opts = Redwood::Index.parse_user_query_string query
+  query = Redwood::Index.build_query opts.merge(:qobj => qobj)
 
   results = index.ferret.search query, :limit => :all
   num_total = results.total_hits
@@ -97,7 +104,9 @@ begin
 
     unless m.labels.sort_by { |s| s.to_s } == old_labels.sort_by { |s| s.to_s }
       num_changed += 1
+      puts "From #{m.from}, subject: #{m.subj}" if opts[:very_verbose]
       puts "#{m.id}: {#{old_labels.join ','}} => {#{m.labels.join ','}}" if opts[:verbose]
+      puts if opts[:very_verbose]
       index.sync_message m unless opts[:dry_run]
     end
 
diff --git a/bugs/issue-2673f091c15dd90222a59621a1842d4ef0a743f7.yaml b/bugs/issue-2673f091c15dd90222a59621a1842d4ef0a743f7.yaml
new file mode 100644 (file)
index 0000000..df14160
--- /dev/null
@@ -0,0 +1,19 @@
+--- !ditz.rubyforge.org,2008-03-06/issue 
+title: make sup-sync-back work on IMAP folders
+desc: ""
+type: :feature
+component: sup-sync-back
+release: 
+reporter: William Morgan <wmorgan-sup@masanjin.net>
+status: :unstarted
+disposition: 
+creation_time: 2008-10-14 01:14:08.690909 Z
+references: []
+
+id: 2673f091c15dd90222a59621a1842d4ef0a743f7
+log_events: 
+- - 2008-10-14 01:14:09.898338 Z
+  - William Morgan <wmorgan-sup@masanjin.net>
+  - created
+  - ""
+git_branch: 
index 7fb616c443f782ebbfc5424b86dc5bf61f45167d..2830fff20e94bfd86be16632b4ba334ad37184f9 100644 (file)
@@ -5,8 +5,8 @@ type: :bugfix
 component: indexing
 release: 
 reporter: William Morgan <wmorgan-sup@masanjin.net>
-status: :unstarted
-disposition: 
+status: :closed
+disposition: :fixed
 creation_time: 2008-04-25 19:28:51.369257 Z
 references: []
 
@@ -20,4 +20,10 @@ log_events:
   - William Morgan <wmorgan-sup@masanjin.net>
   - unassigned from release 0.6
   - ""
+- - 2008-11-21 14:23:17.566852 Z
+  - Nicolas Pouillard <nicolas.pouillard@gmail.com>
+  - closed with disposition fixed
+  - |-
+    Loading options was not given to load_thread_for_message in
+    ThreadIndexMode.add_or_unhide.
 git_branch: 
index cd3820da3e481d82c284661f3561f81fff8603f0..83cc00a4a4d1d1560b858c8809c5cc22ea5f32c4 100644 (file)
@@ -5,8 +5,8 @@ type: :bugfix
 component: sup
 release: 
 reporter: William Morgan <wmorgan-sup@masanjin.net>
-status: :unstarted
-disposition: 
+status: :closed
+disposition: :fixed
 creation_time: 2008-05-19 23:42:25.910550 Z
 references: []
 
@@ -20,4 +20,8 @@ log_events:
   - William Morgan <wmorgan-sup@masanjin.net>
   - unassigned from release 0.6
   - ""
+- - 2008-11-22 16:31:27.450146 Z
+  - Nicolas Pouillard <nicolas.pouillard@gmail.com>
+  - closed with disposition fixed
+  - This mapping and the PersonManager are now removed.
 git_branch: 
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 a97b2a3c2131198f097981f588c203d48b742eff..df5726e21844f0b4bc494acad2a708132e6c254d 100644 (file)
@@ -12,8 +12,28 @@ class or method definitions, just the executable code itself.
 
 Information passes from Sup to the hook code via Ruby variables
 (actually method calls), and from the hook code back to Sup via a
-return value. Each hook description lists the variables and return
-value expected, if any.
+return value. The values of variables persists across calls to the
+same hook, but is NOT available to other hooks. To make the value of a
+variable available to other hooks, use the get and set methods.  Each
+hook description lists the variables and return value expected, if
+any.
+
+The following special functions are available to hooks:
+* say msg
+  Displays the string msg to the user at the bottom of the screen.
+* log msg
+  Adds the string msg to the log, which the user can access via the
+  buffer list.
+* ask_yes_or_no question
+  Prompts the user with the string question for a yes or no
+  response. Returns true if the user answered yes, false otherwise.
+* get key
+  Gets the cross-hook value associated with key (which is typically a
+  string). If there is no value for a given key, nil is returned.
+* set key value
+  Sets the cross-hook value associated with key to value. key is
+  typically a string, while value can be whatever type it needs to be,
+  including nil.
 
 Some example hooks:
 
@@ -36,3 +56,19 @@ mime-decode:
       `/usr/bin/w3m -dump -T #{content_type} '#{filename}'`
     end
   end
+
+startup:
+  ## runs a background task
+  @bgtask_pid = fork
+  if @bgtask_pid
+    set 'bgtask_pid' @bgtask_pid
+    Process.detach(@bgtask_pid) # so we don't have to wait on it when we go to kill it
+  else
+    exec "background-task args 2&>1 >> /tmp/logfile"
+  end
+
+after-poll:
+  ## kills the background task after the first poll
+  @bgtask_pid = get 'bgtask_pid'
+  Process.kill("TERM", @bgtask_pid) unless @bgtask_pid == nil
+  set 'bgtask_pid' nil
\ No newline at end of file
index e4d49eef3bcb87aae0d8b83f37bb89c7896689f3..6ec8be9457ab6fd006b3c50672d3377a43769151 100644 (file)
@@ -1,6 +1,6 @@
 Welcome to Sup! Here's how to get started.
 
-First, try running 'sup'. Since this is your first time, you'll be
+First, try running `sup`. Since this is your first time, you'll be
 confronted with a mostly blank screen, and a notice at the bottom that
 you have no new messages. That's because Sup doesn't hasn't loaded
 anything into its index yet, and has no idea where to look for them
@@ -28,21 +28,21 @@ talks only to the index (stored locally on disk). When you view a
 thread, Sup requests the full content of all the messages from the
 source.
 
-The easiest way to set up all your sources is to run "sup-config".
+The easiest way to set up all your sources is to run `sup-config`.
 This will interactively walk you through some basic configuration,
 prompt you for all the sources you need, and optionally import
 messages from them. Sup-config uses two other tools, sup-add and
 sup-sync, to load messages into the index. In the future you may make
 use of these tools directly (see below).
 
-Once you've run sup-config, you're ready to run 'sup'. You should see
+Once you've run sup-config, you're ready to run `sup`. You should see
 the most recent unarchived messages appear in your inbox.
 Congratulations, you've got Sup working!
 
 If you're coming from the world of traditional MUAs, there are a
 couple differences you should be aware of at this point. First, Sup
 has no folders. Instead, you organize and find messages by a
-combination of search and labels (knowns as 'tags' everywhere else in
+combination of search and labels (known as "tags" everywhere else in
 the world). Search and labels are an integral part of Sup because in
 Sup, rather than viewing the contents of a folder, you view the
 results of a search. I mentioned above that your inbox is, by
@@ -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
@@ -136,7 +136,7 @@ special labels (Draft, Starred, Sent, Spam, etc.). Highlight a label
 and press enter to view all the messages with that label.
 
 What you just did was actually a specific search. For a general search,
-press "\" (backslash---forward slash is used for in-buffer search,
+press '\' (backslash---forward slash is used for in-buffer search,
 following console conventions). Now type in your query (again, Ctrl-G to
 cancel at any point.) You can just type in arbitrary text, which will be
 matched on a per-word basis against the bodies of all email in the
@@ -183,18 +183,18 @@ offsets will be wrong.
 
 That's the bad news. The good news is that Sup is pretty good at being
 able to detect this type of situation, and fixing it is just a matter
-of running sup-sync --changed on the source. Sup will even tell you
+of running `sup-sync --changed` on the source. Sup will even tell you
 how to invoke sup-sync when it detects a problem. This is a
 complication you will almost certainly run in to if you use both Sup
 and another MUA on the same source, so it's good to be aware of it.
 
 Have fun, and let me know if you have any problems!
 
-Appending A: sup-add and sup-sync
+Appendix A: sup-add and sup-sync
 ---------------------------------
 
 Instead of using sup-config to add a new source, you can manually run
-'sup-add' with a URI pointing to it. The URI should be of the form:
+`sup-add` with a URI pointing to it. The URI should be of the form:
 - mbox://path/to/a/filename, for an mbox file on disk.
 - maildir://path/to/a/filename, for a maildir directory on disk.
 - imap://imap.server/folder for an unsecure IMAP folder.
@@ -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 eda673b94b76bb24f660c0300aaf335a5c3726f4..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
@@ -53,7 +40,6 @@ module Redwood
   COLOR_FN   = File.join(BASE_DIR, "colors.yaml")
   SOURCE_FN  = File.join(BASE_DIR, "sources.yaml")
   LABEL_FN   = File.join(BASE_DIR, "labels.txt")
-  PERSON_FN  = File.join(BASE_DIR, "people.txt")
   CONTACT_FN = File.join(BASE_DIR, "contacts.txt")
   DRAFT_DIR  = File.join(BASE_DIR, "drafts")
   SENT_FN    = File.join(BASE_DIR, "sent.mbox")
@@ -115,7 +101,6 @@ module Redwood
   end
 
   def start
-    Redwood::PersonManager.new Redwood::PERSON_FN
     Redwood::SentManager.new Redwood::SENT_FN
     Redwood::ContactManager.new Redwood::CONTACT_FN
     Redwood::LabelManager.new Redwood::LABEL_FN
@@ -131,7 +116,6 @@ module Redwood
   def finish
     Redwood::LabelManager.save if Redwood::LabelManager.instantiated?
     Redwood::ContactManager.save if Redwood::ContactManager.instantiated?
-    Redwood::PersonManager.save if Redwood::PersonManager.instantiated?
     Redwood::BufferManager.deinstantiate! if Redwood::BufferManager.instantiated?
   end
 
@@ -222,6 +206,7 @@ else
     :confirm_no_attachments => true,
     :confirm_top_posting => true,
     :discard_snippets_from_encrypted_messages => false,
+    :default_attachment_save_dir => "",
   }
   begin
     FileUtils.mkdir_p Redwood::BASE_DIR
index f8ac0fcaf672ace7e2b72e4e666e2b56d7c6fc19..6f86129ca5d8997b0572f38512ba84a691ee4a7c 100644 (file)
@@ -5,8 +5,8 @@ class Account < Person
 
   def initialize h
     raise ArgumentError, "no name for account" unless h[:name]
-    raise ArgumentError, "no name for email" unless h[:name]
-    super h[:name], h[:email], 0, true
+    raise ArgumentError, "no email for account" unless h[:email]
+    super h[:name], h[:email]
     @sendmail = h[:sendmail]
     @signature = h[:signature]
   end
@@ -42,7 +42,6 @@ class AccountManager
     hash[:alternates] ||= []
 
     a = Account.new hash
-    PersonManager.register a
     @accounts[a] = true
 
     if default
index ebc3587bb44ea547ce2ef665fdf893b10296e23e..6f0acf9852ea51d4b45125d035476051ed42d2f4 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
 
@@ -433,7 +436,7 @@ EOS
       prefix, target = partial.split_on_commas_with_remainder
       target ||= prefix.pop || ""
       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
-      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
+      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
     end
   end
 
@@ -478,13 +481,15 @@ 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
 
     return unless answer
 
-    user_labels = answer.split(/\s+/).map { |l| l.intern }
+    user_labels = answer.symbolistize
     user_labels.each do |l|
       if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
         BufferManager.flash "'#{l}' is a reserved label!"
@@ -501,12 +506,12 @@ EOS
     recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
     contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
 
-    completions = (recent + contacts).flatten.uniq.sort
+    completions = (recent + contacts).flatten.uniq
     completions += HookManager.run("extra-contact-addresses") || []
     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
 
     if answer
-      answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
+      answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
     end
   end
 
index 070d1bac5cf918a653dfdf1729170773150ca8fc..38787cddc41374a9f9adf583160627a5dfb448e0 100644 (file)
@@ -11,42 +11,45 @@ class Colormap
                    Curses::COLOR_YELLOW, Curses::COLOR_BLUE,
                    Curses::COLOR_MAGENTA, Curses::COLOR_CYAN,
                    Curses::COLOR_WHITE, Curses::COLOR_DEFAULT]
-  NUM_COLORS = 15
+  NUM_COLORS = (CURSES_COLORS.size - 1) * (CURSES_COLORS.size - 1)
 
   DEFAULT_COLORS = {
     :status => { :fg => "white", :bg => "blue", :attrs => ["bold"] },
-    :index_old => { :fg => "white", :bg => "black" },
-    :index_new => { :fg => "white", :bg => "black", :attrs => ["bold"] },
-    :index_starred => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
-    :index_draft => { :fg => "red", :bg => "black", :attrs => ["bold"] },
-    :labellist_old => { :fg => "white", :bg => "black" },
-    :labellist_new => { :fg => "white", :bg => "black", :attrs => ["bold"] },
-    :twiddle => { :fg => "blue", :bg => "black" },
-    :label => { :fg => "yellow", :bg => "black" },
+    :index_old => { :fg => "white", :bg => "default" },
+    :index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
+    :index_starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
+    :index_draft => { :fg => "red", :bg => "default", :attrs => ["bold"] },
+    :labellist_old => { :fg => "white", :bg => "default" },
+    :labellist_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
+    :twiddle => { :fg => "blue", :bg => "default" },
+    :label => { :fg => "yellow", :bg => "default" },
     :message_patina => { :fg => "black", :bg => "green" },
     :alternate_patina => { :fg => "black", :bg => "blue" },
     :missing_message => { :fg => "black", :bg => "red" },
-    :attachment => { :fg => "cyan", :bg => "black" },
-    :cryptosig_valid => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
-    :cryptosig_unknown => { :fg => "cyan", :bg => "black" },
+    :attachment => { :fg => "cyan", :bg => "default" },
+    :cryptosig_valid => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
+    :cryptosig_unknown => { :fg => "cyan", :bg => "default" },
     :cryptosig_invalid => { :fg => "yellow", :bg => "red", :attrs => ["bold"] },
-    :generic_notice_patina => { :fg => "cyan", :bg => "black" },
-    :quote_patina => { :fg => "yellow", :bg => "black" },
-    :sig_patina => { :fg => "yellow", :bg => "black" },
-    :quote => { :fg => "yellow", :bg => "black" },
-    :sig => { :fg => "yellow", :bg => "black" },
-    :to_me => { :fg => "green", :bg => "black" },
-    :starred => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
+    :generic_notice_patina => { :fg => "cyan", :bg => "default" },
+    :quote_patina => { :fg => "yellow", :bg => "default" },
+    :sig_patina => { :fg => "yellow", :bg => "default" },
+    :quote => { :fg => "yellow", :bg => "default" },
+    :sig => { :fg => "yellow", :bg => "default" },
+    :to_me => { :fg => "green", :bg => "default" },
+    :starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
     :starred_patina => { :fg => "yellow", :bg => "green", :attrs => ["bold"] },
     :alternate_starred_patina => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] },
-    :snippet => { :fg => "cyan", :bg => "black" },
-    :option => { :fg => "white", :bg => "black" },
-    :tagged => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
-    :draft_notification => { :fg => "red", :bg => "black", :attrs => ["bold"] },
-    :completion_character => { :fg => "white", :bg => "black", :attrs => ["bold"] },
-    :horizontal_selector_selected => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
-    :horizontal_selector_unselected => { :fg => "cyan", :bg => "black" },
-    :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }
+    :snippet => { :fg => "cyan", :bg => "default" },
+    :option => { :fg => "white", :bg => "default" },
+    :tagged => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
+    :draft_notification => { :fg => "red", :bg => "default", :attrs => ["bold"] },
+    :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"] },
+    :system_buf => { :fg => "blue", :bg => "default" },
+    :regular_buf => { :fg => "white", :bg => "default" },
+    :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
   }
   
   def initialize
index b0c272e8ece056b825ede520312f49d35de7d23c..51fd0e978c902f17485c8fcd81f20cc766b74157 100644 (file)
@@ -17,7 +17,7 @@ class ContactManager
       IO.foreach(fn) do |l|
         l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}"
         aalias, addr = $1, $2
-        p = PersonManager.person_for addr, :definitive => true
+        p = Person.from_address addr
         @p2a[p] = aalias
         @a2p[aalias] = p unless aalias.nil? || aalias.empty?
       end
index 0003fec7bc60314dd14d89387d25ae886a7972c6..8ec277b862603fb23d94a60d3fa19fd60ee0fd0f 100644 (file)
@@ -53,7 +53,7 @@ class CryptoManager
     payload_fn.write format_payload(payload)
     payload_fn.close
 
-    recipient_opts = to.map { |r| "--recipient '<#{r}>'" }.join(" ")
+    recipient_opts = (to + [ from ] ).map { |r| "--recipient '<#{r}>'" }.join(" ")
     sign_opts = sign ? "--sign --local-user '#{from}'" : ""
     gpg_output = run_gpg "--output - --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}"
     raise Error, (gpg_output || "gpg command failed: #{cmd}") unless $?.success?
index 21de1513a18a71330a8a3ce0679de68e69f86b8c..32266b5374eb3d67b336926a28e8fe75eecf30ce 100644 (file)
@@ -32,7 +32,10 @@ class DraftManager
 
   def discard m
     docid, entry = Index.load_entry_for_id m.id
-    raise ArgumentError, "can't find entry for draft: #{m.id.inspect}" unless entry
+    unless entry
+      Redwood::log "can't find entry for draft: #{m.id.inspect}. You probably already discarded it."
+      return
+    end
     raise ArgumentError, "not a draft: source id #{entry[:source_id].inspect}, should be #{DraftManager.source_id.inspect} for #{m.id.inspect} / docno #{docid}" unless entry[:source_id].to_i == DraftManager.source_id
     Index.drop_entry docid
     File.delete @source.fn_for_offset(entry[:source_info])
@@ -76,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 94949195d31090b72de33480318d819e081fc02f..0a0a2f672fca775e4bb792896537d2ec2535fac3 100644 (file)
@@ -52,6 +52,14 @@ class HookManager
       end
     end
 
+    def get tag
+      HookManager.tags[tag]
+    end
+
+    def set tag, value
+      HookManager.tags[tag] = value
+    end
+
     def __binding 
       binding
     end
@@ -68,12 +76,15 @@ class HookManager
     @hooks = {}
     @descs = {}
     @contexts = {}
-    
+    @tags = {}
+
     Dir.mkdir dir unless File.exists? dir
 
     self.class.i_am_the_instance self
   end
 
+  attr_reader :tags
+
   def run name, locals={}
     hook = hook_for(name) or return
     context = @contexts[hook] ||= HookContext.new(name)
index 1d369767c43106fdb19737244f172adbba8f644c..7508c2c7b3e64d4fd39f7a8467a380d68128f6d0 100644 (file)
@@ -5,6 +5,9 @@ require 'time'
 require 'rmail'
 require 'cgi'
 
+## TODO: remove synchronized method protector calls; use a Monitor instead
+## (ruby's reentrant mutex)
+
 ## fucking imap fucking sucks. what the FUCK kind of committee of dunces
 ## designed this shit.
 ##
@@ -15,7 +18,7 @@ require 'cgi'
 ## restriction. it can change any time you log in. it can change EVERY
 ## time you log in. of course the imap spec "strongly recommends" that it
 ## never change, but there's nothing to stop people from just setting it
-## to the current timestamp, and in fact that's exactly what the one imap
+## to the current timestamp, and in fact that's EXACTLY what the one imap
 ## server i have at my disposal does. thus the so-called uids are
 ## absolutely useless and imap provides no cross-session way of uniquely
 ## identifying a message. but thanks for the "strong recommendation",
@@ -90,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
@@ -114,20 +117,37 @@ class IMAP < Source
   end
   synchronized :raw_message
 
+  def mark_as_deleted ids
+    ids = [ids].flatten # accept single arguments
+    unsynchronized_scan_mailbox
+    imap_ids = ids.map { |i| @imap_state[i] && @imap_state[i][:id] }.compact
+    return if imap_ids.empty?
+    @imap.store imap_ids, "+FLAGS", [:Deleted]
+  end
+  synchronized :mark_as_deleted
+
+  def expunge
+    @imap.expunge
+    unsynchronized_scan_mailbox true
+    true
+  end
+  synchronized :expunge
+
   def connect
     return if @imap
     safely { } # do nothing!
   end
   synchronized :connect
 
-  def scan_mailbox
-    return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
+  def scan_mailbox force=false
+    return if !force && @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
     last_id = safely do
       @imap.examine mailbox
       @imap.responses["EXISTS"].last
     end
     @last_scan = Time.now
 
+    @ids = [] if force
     return if last_id == @ids.length
 
     range = (@ids.length + 1) .. last_id
@@ -259,7 +279,7 @@ private
     %w(RFC822.SIZE INTERNALDATE).each do |w|
       raise FatalSourceError, "requested data not in IMAP response: #{w}" unless imap_stuff.attr[w]
     end
-    
+
     msize, mdate = imap_stuff.attr['RFC822.SIZE'] % 10000000, Time.parse(imap_stuff.attr["INTERNALDATE"])
     sprintf("%d%07d", mdate.to_i, msize).to_i
   end
index 235a18cb0d7993c306d58f0b0e6168ca4b6ec7c5..0dae1e0933568e902f39247458d30870c47a26e9 100644 (file)
@@ -2,6 +2,8 @@
 
 require 'fileutils'
 require 'ferret'
+require 'fastthread'
+
 begin
   require 'chronic'
   $have_chronic = true
@@ -23,12 +25,18 @@ class Index
 
   include Singleton
 
+  ## these two accessors should ONLY be used by single-threaded programs.
+  ## otherwise you will have a naughty ferret on your hands.
   attr_reader :index
   alias ferret index
+
   def initialize dir=BASE_DIR
+    @index_mutex = Monitor.new
+
     @dir = dir
     @sources = {}
     @sources_dirty = false
+    @source_mutex = Monitor.new
 
     wsa = Ferret::Analysis::WhiteSpaceAnalyzer.new false
     sa = Ferret::Analysis::StandardAnalyzer.new [], true
@@ -122,17 +130,19 @@ EOS
   end
 
   def add_source source
-    raise "duplicate source!" if @sources.include? source
-    @sources_dirty = true
-    max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id }
-    source.id ||= (max || 0) + 1
-    ##source.id += 1 while @sources.member? source.id
-    @sources[source.id] = source
+    @source_mutex.synchronize do
+      raise "duplicate source!" if @sources.include? source
+      @sources_dirty = true
+      max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id }
+      source.id ||= (max || 0) + 1
+      ##source.id += 1 while @sources.member? source.id
+      @sources[source.id] = source
+    end
   end
 
   def sources
     ## favour the inbox by listing non-archived sources first
-    @sources.values.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten
+    @source_mutex.synchronize { @sources.values }.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten
   end
 
   def source_for uri; sources.find { |s| s.is_source_for? uri }; end
@@ -141,51 +151,57 @@ EOS
   def load_index dir=File.join(@dir, "ferret")
     if File.exists? dir
       Redwood::log "loading index..."
-      @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
-      Redwood::log "loaded index of #{@index.size} messages"
+      @index_mutex.synchronize do
+        @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
+        Redwood::log "loaded index of #{@index.size} messages"
+      end
     else
       Redwood::log "creating index..."
-      field_infos = Ferret::Index::FieldInfos.new :store => :yes
-      field_infos.add_field :message_id, :index => :untokenized
-      field_infos.add_field :source_id
-      field_infos.add_field :source_info
-      field_infos.add_field :date, :index => :untokenized
-      field_infos.add_field :body
-      field_infos.add_field :label
-      field_infos.add_field :attachments
-      field_infos.add_field :subject
-      field_infos.add_field :from
-      field_infos.add_field :to
-      field_infos.add_field :refs
-      field_infos.add_field :snippet, :index => :no, :term_vector => :no
-      field_infos.create_index dir
-      @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
+      @index_mutex.synchronize do
+        field_infos = Ferret::Index::FieldInfos.new :store => :yes
+        field_infos.add_field :message_id, :index => :untokenized
+        field_infos.add_field :source_id
+        field_infos.add_field :source_info
+        field_infos.add_field :date, :index => :untokenized
+        field_infos.add_field :body
+        field_infos.add_field :label
+        field_infos.add_field :attachments
+        field_infos.add_field :subject
+        field_infos.add_field :from
+        field_infos.add_field :to
+        field_infos.add_field :refs
+        field_infos.add_field :snippet, :index => :no, :term_vector => :no
+        field_infos.create_index dir
+        @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
+      end
     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
-    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
+    @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,
@@ -210,7 +226,7 @@ EOS
     ## but merge in the labels.
     if entry[:source_id] && entry[:source_info] && entry[:label] &&
       ((entry[:source_id].to_i > source_id) || (entry[:source_info].to_i < m.source_info))
-      labels = (entry[:label].split(/\s+/).map { |l| l.intern } + m.labels).uniq
+      labels = (entry[:label].symbolistize + m.labels).uniq
       #Redwood::log "found updated version of message #{m.id}: #{m.subj}"
       #Redwood::log "previous version was at #{entry[:source_id].inspect}:#{entry[:source_info].inspect}, this version at #{source_id.inspect}:#{m.source_info.inspect}"
       #Redwood::log "merged labels are #{labels.inspect} (index #{entry[:label].inspect}, message #{m.labels.inspect})"
@@ -230,19 +246,29 @@ EOS
       :snippet => snippet, # always override
       :label => labels.uniq.join(" "),
       :attachments => (entry[:attachments] || m.attachments.uniq.join(" ")),
-      :from => (entry[:from] || (m.from ? m.from.indexable_content : "")),
-      :to => (entry[:to] || (m.to + m.cc + m.bcc).map { |x| x.indexable_content }.join(" ")),
+
+      ## always override :from and :to.
+      ## older versions of Sup would often store the wrong thing in the index
+      ## (because they were canonicalizing email addresses, resulting in the
+      ## wrong name associated with each.) the correct address is read from
+      ## the original header when these messages are opened in thread-view-mode,
+      ## so this allows people to forcibly update the address in the index by
+      ## marking those threads for saving.
+      :from => (m.from ? m.from.indexable_content : ""),
+      :to => (m.to + m.cc + m.bcc).map { |x| x.indexable_content }.join(" "),
+
       :subject => (entry[:subject] || wrap_subj(Message.normalize_subj(m.subj))),
       :refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
     }
 
-    @index.delete docid if docid
-    @index.add_document d
-    
-    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
+    @index_mutex.synchronize do
+      @index.delete docid if docid
+      @index.add_document d
+    end
+
+    ## 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")
@@ -250,32 +276,37 @@ EOS
   end
 
   def contains_id? id
-    @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0
+    @index_mutex.synchronize { @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0 }
   end
-  def contains? m; contains_id? m.id; end
-  def size; @index.size; end
+  def contains? m; contains_id? m.id end
+  def size; @index_mutex.synchronize { @index.size } end
+  def empty?; size == 0 end
 
   ## you should probably not call this on a block that doesn't break
   ## rather quickly because the results can be very large.
   EACH_BY_DATE_NUM = 100
   def each_id_by_date opts={}
-    return if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
+    return if empty? # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
     query = build_query opts
     offset = 0
     while true
-      results = @index.search(query, :sort => "date DESC", :limit => EACH_BY_DATE_NUM, :offset => offset)
+      limit = (opts[:limit])? [EACH_BY_DATE_NUM, opts[:limit] - offset].min : EACH_BY_DATE_NUM
+      results = @index_mutex.synchronize { @index.search query, :sort => "date DESC", :limit => limit, :offset => offset }
       Redwood::log "got #{results.total_hits} results for query (offset #{offset}) #{query.inspect}"
-      results.hits.each { |hit| yield @index[hit.doc][:message_id], lambda { build_message hit.doc } }
-      break if offset >= results.total_hits - EACH_BY_DATE_NUM
-      offset += EACH_BY_DATE_NUM
+      results.hits.each do |hit|
+        yield @index_mutex.synchronize { @index[hit.doc][:message_id] }, lambda { build_message hit.doc }
+      end
+      break if opts[:limit] and offset >= opts[:limit] - limit
+      break if offset >= results.total_hits - limit
+      offset += limit
     end
   end
 
   def num_results_for opts={}
-    return 0 if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
+    return 0 if empty? # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
 
     q = build_query opts
-    index.search(q, :limit => 1).total_hits
+    @index_mutex.synchronize { @index.search(q, :limit => 1).total_hits }
   end
 
   ## yield all messages in the thread containing 'm' by repeatedly
@@ -301,7 +332,7 @@ EOS
 
       q = Ferret::Search::BooleanQuery.new true
       sq = Ferret::Search::PhraseQuery.new(:subject)
-      wrap_subj(Message.normalize_subj(m.subj)).split(/\s+/).each do |t|
+      wrap_subj(Message.normalize_subj(m.subj)).split.each do |t|
         sq.add_term t
       end
       q.add_query sq, :must
@@ -309,10 +340,10 @@ EOS
 
       q = build_query :qobj => q
 
-      p1 = @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] }
+      p1 = @index_mutex.synchronize { @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] } }
       Redwood::log "found #{p1.size} results for subject query #{q}"
 
-      p2 = @index.search(q.to_s, :limit => :all).hits.map { |hit| @index[hit.doc][:message_id] }
+      p2 = @index_mutex.synchronize { @index.search(q.to_s, :limit => :all).hits.map { |hit| @index[hit.doc][:message_id] } }
       Redwood::log "found #{p2.size} results in string form"
 
       pending = (pending + p1 + p2).uniq
@@ -335,18 +366,20 @@ EOS
 
       num_queries += 1
       killed = false
-      @index.search_each(q, :limit => :all) do |docid, score|
-        break if opts[:limit] && messages.size >= opts[:limit]
-        if @index[docid][:label].split(/\s+/).include?("killed") && opts[:skip_killed]
-          killed = true
-          break
-        end
-        mid = @index[docid][:message_id]
-        unless messages.member?(mid)
-          #Redwood::log "got #{mid} as a child of #{id}"
-          messages[mid] ||= lambda { build_message docid }
-          refs = @index[docid][:refs].split(" ")
-          pending += refs.select { |id| !searched[id] }
+      @index_mutex.synchronize do
+        @index.search_each(q, :limit => :all) do |docid, score|
+          break if opts[:limit] && messages.size >= opts[:limit]
+          if @index[docid][:label].split(/\s+/).include?("killed") && opts[:skip_killed]
+            killed = true
+            break
+          end
+          mid = @index[docid][:message_id]
+          unless messages.member?(mid)
+            #Redwood::log "got #{mid} as a child of #{id}"
+            messages[mid] ||= lambda { build_message docid }
+            refs = @index[docid][:refs].split
+            pending += refs.select { |id| !searched[id] }
+          end
         end
       end
     end
@@ -355,7 +388,7 @@ EOS
       Redwood::log "thread for #{m.id} is killed, ignoring"
       false
     else
-      Redwood::log "ran #{num_queries} queries to build thread of #{messages.size + 1} messages for #{m.id}: #{m.subj}" if num_queries > 0
+      Redwood::log "ran #{num_queries} queries to build thread of #{messages.size} messages for #{m.id}: #{m.subj}" if num_queries > 0
       messages.each { |mid, builder| yield mid, builder }
       true
     end
@@ -363,36 +396,46 @@ EOS
 
   ## builds a message object from a ferret result
   def build_message docid
-    doc = @index[docid]
-    source = @sources[doc[:source_id].to_i]
-    #puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})"
-    raise "invalid source #{doc[:source_id]}" unless source
-
-    fake_header = {
-      "date" => Time.at(doc[:date].to_i),
-      "subject" => unwrap_subj(doc[:subject]),
-      "from" => doc[:from],
-      "to" => doc[:to].split(/\s+/).join(", "), # reformat
-      "message-id" => doc[:message_id],
-      "references" => doc[:refs].split(/\s+/).map { |x| "<#{x}>" }.join(" "),
-    }
-
-    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
+    @index_mutex.synchronize do
+      doc = @index[docid]
+
+      source = @source_mutex.synchronize { @sources[doc[:source_id].to_i] }
+      raise "invalid source #{doc[:source_id]}" unless source
+
+      #puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})"
+
+      fake_header = {
+        "date" => Time.at(doc[:date].to_i),
+        "subject" => unwrap_subj(doc[:subject]),
+        "from" => doc[:from],
+        "to" => doc[:to].split.join(", "), # reformat
+        "message-id" => doc[:message_id],
+        "references" => doc[:refs].split.map { |x| "<#{x}>" }.join(" "),
+      }
+
+      m = Message.new :source => source, :source_info => doc[:source_info].to_i,
+                  :labels => doc[:label].symbolistize,
+                  :snippet => doc[:snippet]
+      m.parse_header fake_header
+      m
+    end
   end
 
   def fresh_thread_id; @next_thread_id += 1; end
   def wrap_subj subj; "__START_SUBJECT__ #{subj} __END_SUBJECT__"; end
   def unwrap_subj subj; subj =~ /__START_SUBJECT__ (.*?) __END_SUBJECT__/ && $1; end
 
-  def drop_entry docno; @index.delete docno; end
+  def drop_entry docno; @index_mutex.synchronize { @index.delete docno } end
 
   def load_entry_for_id mid
-    results = @index.search(Ferret::Search::TermQuery.new(:message_id, mid))
-    return if results.total_hits == 0
-    docid = results.hits[0].doc
-    [docid, @index[docid]]
+    @index_mutex.synchronize do
+      results = @index.search Ferret::Search::TermQuery.new(:message_id, mid)
+      return if results.total_hits == 0
+      docid = results.hits[0].doc
+      entry = @index[docid]
+      entry_dup = entry.fields.inject({}) { |h, f| h[f] = entry[f]; h }
+      [docid, entry_dup]
+    end
   end
 
   def load_contacts emails, h={}
@@ -408,16 +451,18 @@ EOS
     Redwood::log "contact search: #{q}"
     contacts = {}
     num = h[:num] || 20
-    @index.search_each(q, :sort => "date DESC", :limit => :all) do |docid, score|
-      break if contacts.size >= num
-      #Redwood::log "got message #{docid} to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}"
-      f = @index[docid][:from]
-      t = @index[docid][:to]
-
-      if AccountManager.is_account_email? f
-        t.split(" ").each { |e| contacts[PersonManager.person_for(e)] = true }
-      else
-        contacts[PersonManager.person_for(f)] = true
+    @index_mutex.synchronize do
+      @index.search_each q, :sort => "date DESC", :limit => :all do |docid, score|
+        break if contacts.size >= num
+        #Redwood::log "got message #{docid} to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}"
+        f = @index[docid][:from]
+        t = @index[docid][:to]
+
+        if AccountManager.is_account_email? f
+          t.split(" ").each { |e| contacts[Person.from_address(e)] = true }
+        else
+          contacts[Person.from_address(f)] = true
+        end
       end
     end
 
@@ -426,15 +471,17 @@ EOS
 
   def load_sources fn=Redwood::SOURCE_FN
     source_array = (Redwood::load_yaml_obj(fn) || []).map { |o| Recoverable.new o }
-    @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
-    @sources_dirty = false
+    @source_mutex.synchronize do
+      @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
+      @sources_dirty = false
+    end
   end
 
   def has_any_from_source_with_label? source, label
     q = Ferret::Search::BooleanQuery.new
     q.add_query Ferret::Search::TermQuery.new("source_id", source.id.to_s), :must
     q.add_query Ferret::Search::TermQuery.new("label", label.to_s), :must
-    index.search(q, :limit => 1).total_hits > 0
+    @index_mutex.synchronize { @index.search(q, :limit => 1).total_hits > 0 }
   end
 
 protected
@@ -506,7 +553,7 @@ protected
       subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
         break if chronic_failure
         field, datestr = $1, ($3 || $4)
-        realdate = Chronic.parse(datestr, :guess => false, :context => :none)
+        realdate = Chronic.parse(datestr, :guess => false, :context => :past)
         if realdate
           case field
           when "after"
@@ -526,6 +573,18 @@ protected
       end
       subs = nil if chronic_failure
     end
+
+    ## limit:42 restrict the search to 42 results
+    subs = subs.gsub(/\blimit:(\S+)\b/) do
+      lim = $1
+      if lim =~ /^\d+$/
+        extraopts[:limit] = lim.to_i
+        ''
+      else
+        BufferManager.flash "Can't understand limit #{lim.inspect}!"
+        subs = nil
+      end
+    end
     
     if subs
       [@qparser.parse(subs), extraopts]
@@ -555,16 +614,18 @@ protected
   end
 
   def save_sources fn=Redwood::SOURCE_FN
-    if @sources_dirty || @sources.any? { |id, s| s.dirty? }
-      bakfn = fn + ".bak"
-      if File.exists? fn
+    @source_mutex.synchronize do
+      if @sources_dirty || @sources.any? { |id, s| s.dirty? }
+        bakfn = fn + ".bak"
+        if File.exists? fn
+          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
         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
-      File.chmod 0600, fn
+      @sources_dirty = false
     end
-    @sources_dirty = false
   end
 end
 
index 70a26ead5409e2b63a0edef48cb2a9bb4ef4cc6a..47d632baafaa58e2dfa10111172b64e08b9f9f55 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 ]
 
@@ -22,31 +19,34 @@ class LabelManager
         []
       end
     @labels = {}
+    @new_labels = {}
     @modified = false
     labels.each { |t| @labels[t] = true }
 
     self.class.i_am_the_instance self
   end
 
-  ## all listable (just user-defined at the moment) labels, ordered
+  def new_label? l; @new_labels.include?(l) end
+
+  ## 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
 
   ## reverse the label->string mapping, for convenience!
   def string_for l
     if RESERVED_LABELS.include? l
-      l.to_s.ucfirst
+      l.to_s.capitalize
     else
       l.to_s
     end
@@ -66,12 +66,13 @@ class LabelManager
     t = t.intern unless t.is_a? Symbol
     unless @labels.member?(t) || RESERVED_LABELS.member?(t)
       @labels[t] = true
+      @new_labels[t] = true
       @modified = true
     end
   end
 
   def delete t
-    if @labels.delete t
+    if @labels.delete(t)
       @modified = true
     end
   end
@@ -79,6 +80,7 @@ class LabelManager
   def save
     return unless @modified
     File.open(@fn, "w") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
+    @new_labels = {}
   end
 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 49489541a0b3ea9be3a46883c472951ce589191e..0d941b1d8ae341b86bcf6303e752c3c4bb1c36e5 100644 (file)
@@ -5,77 +5,21 @@ 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)\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
-      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):#{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
-      else
-        header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
-      end
-    end
-
-    if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
-      header[mid_field] = $1
+  BREAK_RE = /^From \S+ (.+)$/
+
+  def is_break_line? l
+    l =~ BREAK_RE or return false
+    time = $1
+    begin
+      ## hack -- make Time.parse fail when trying to substitute values from Time.now
+      Time.parse time, 0
+      true
+    rescue NoMethodError
+      Redwood::log "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
+      false
     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
+  module_function :is_break_line?
 end
 end
index 7fe912900e16d477fc7d690873d92e9c623f48c2..ebb2aed675e171397b99e877023e24611cf119c7 100644 (file)
@@ -9,7 +9,7 @@ class Loader < Source
   attr_accessor :labels
 
   ## uri_or_fp is horrific. need to refactor.
-  def initialize uri_or_fp, start_offset=nil, usual=true, archived=false, id=nil, labels=[]
+  def initialize uri_or_fp, start_offset=0, usual=true, archived=false, id=nil, labels=[]
     @mutex = Mutex.new
     @labels = ((labels || []) - LabelManager::RESERVED_LABELS).uniq.freeze
 
@@ -56,10 +56,10 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       l = @f.gets
-      unless l =~ BREAK_RE
+      unless MBox::is_break_line? l
         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? || MBox::is_break_line?(l = @f.gets)
+        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
 
@@ -108,7 +119,7 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       yield @f.gets
-      until @f.eof? || (l = @f.gets) =~ BREAK_RE
+      until @f.eof? || MBox::is_break_line?(l = @f.gets)
         yield l
       end
     end
@@ -129,7 +140,7 @@ class Loader < Source
         ## 2. at the beginning of an mbox separator (in all other
         ##    cases).
 
-        l = @f.gets or raise "next while at EOF"
+        l = @f.gets or return nil
         if l =~ /^\s*$/ # case 1
           returned_offset = @f.tell
           @f.gets # now we're at a BREAK_RE, so skip past it
@@ -139,7 +150,7 @@ class Loader < Source
         end
 
         while(line = @f.gets)
-          break if line =~ BREAK_RE
+          break if MBox::is_break_line? line
           next_offset = @f.tell
         end
       end
index ba8b84641f294d3820896ddb53f06dd2169c76b9..1bf779612f4c135d00b8343efb4ee8971efe32d6 100644 (file)
@@ -41,9 +41,14 @@ end
 
 module Redwood
 module Chunk
+  WRAP_LEN = 80 # wrap messages and text attachments at this width
+
   class Attachment
     HookManager.register "mime-decode", <<EOS
-Executes when decoding a MIME attachment.
+Decodes a MIME attachment into text form. The text will be displayed
+directly in Sup. For attachments that you wish to use a separate program
+to view (e.g. images), you should use the mime-view hook instead.
+
 Variables:
    content_type: the content-type of the message
        filename: the filename of the attachment as saved to disk
@@ -55,13 +60,22 @@ Return value:
 EOS
 
     HookManager.register "mime-view", <<EOS
-Executes when viewing a MIME attachment, i.e., launching a separate
-viewer program.
+Views a non-text MIME attachment. This hook allows you to run
+third-party programs for attachments that require such a thing (e.g.
+images). To instead display a text version of the attachment directly in
+Sup, use the mime-decode hook instead.
+
+Note that by default (at least on systems that have a run-mailcap command),
+Sup uses the default mailcap handler for the attachment's MIME type. If
+you want a particular behavior to be global, you may wish to change your
+mailcap instead.
+
 Variables:
    content_type: the content-type of the attachment
        filename: the filename of the attachment as saved to disk
 Return value:
-  True if the viewing was successful, false otherwise.
+  True if the viewing was successful, false otherwise. If false, calling
+  /usr/bin/run-mailcap will be tried.
 EOS
 #' stupid ruby-mode
 
@@ -95,6 +109,7 @@ EOS
       @lines = nil
       if text
         @lines = text.gsub("\r\n", "\n").gsub(/\t/, "        ").gsub(/\r/, "").split("\n")
+        @lines = lines.map {|l| l.chomp.wrap WRAP_LEN}.flatten
         @quotable = true
       end
     end
@@ -105,7 +120,7 @@ EOS
       if expandable?
         "Attachment: #{filename} (#{lines.length} lines)"
       else
-        "Attachment: #{filename} (#{content_type})"
+        "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
       end
     end
 
@@ -116,7 +131,7 @@ 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}' > /dev/null 2> /dev/null"
+      cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}' 2>/dev/null"
       Redwood::log "running: #{cmd.inspect}"
       system cmd
       $? == 0
@@ -143,7 +158,6 @@ EOS
   end
 
   class Text
-    WRAP_LEN = 80 # wrap at this width
 
     attr_reader :lines
     def initialize lines
@@ -206,7 +220,7 @@ EOS
     def inlineable?; false end
     def quotable?; false end
     def expandable?; true end
-    def initial_state; :open end
+    def initial_state; :closed end
     def viewable?; false end
 
     def patina_color; :generic_notice_patina_color end
index 2cf97c8ce482c69097d6b8f014d06ff0be6060c5..6dd1f7dfa3fa67ed9dd70a39460fa0d08ec73537 100644 (file)
@@ -13,8 +13,8 @@ class MessageFormatError < StandardError; end
 ## specific module that would detect and link to /ruby-talk:\d+/
 ## sequences in the text of an email. (how sweet would that be?)
 ##
-## this class cathces all source exceptions. if the underlying source throws
-## an error, it is caught and handled.
+## this class catches all source exceptions. if the underlying source
+## throws an error, it is caught and handled.
 
 class Message
   SNIPPET_LEN = 80
@@ -50,7 +50,7 @@ class Message
     @snippet = opts[:snippet]
     @snippet_contains_encrypted_content = false
     @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
-    @labels = [] + (opts[:labels] || [])
+    @labels = (opts[:labels] || []).to_set_of_symbols
     @dirty = false
     @encrypted = false
     @chunks = nil
@@ -60,54 +60,47 @@ 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.each { |k, v| header[k.downcase] = v }
-
-    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"]
-        PersonManager.person_for header["from"]
-      else
-        fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
-        PersonManager.person_for 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 = PersonManager.people_for header["to"]
-    @cc = PersonManager.people_for header["cc"]
-    @bcc = PersonManager.people_for header["bcc"]
+    @to = Person.from_address_list header["to"]
+    @cc = Person.from_address_list header["cc"]
+    @bcc = Person.from_address_list header["bcc"]
 
     ## before loading our full header from the source, we can actually
     ## have some extra refs set by the UI. (this happens when the user
@@ -117,10 +110,10 @@ class Message
     @refs = (@refs + refs).uniq
     @replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
 
-    @replyto = PersonManager.person_for header["reply-to"]
+    @replyto = Person.from_address header["reply-to"]
     @list_address =
       if header["list-post"]
-        @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
+        @list_address = Person.from_address header["list-post"].gsub(/^<mailto:|>$/, "")
       else
         nil
       end
@@ -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
@@ -172,7 +164,7 @@ class Message
   def has_label? t; @labels.member? t; end
   def add_label t
     return if @labels.member? t
-    @labels.push t
+    @labels = (@labels + [t]).to_set_of_symbols
     @dirty = true
   end
   def remove_label t
@@ -186,7 +178,7 @@ class Message
   end
 
   def labels= l
-    @labels = l
+    @labels = l.to_set_of_symbols
     @dirty = true
   end
 
@@ -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 =
@@ -391,8 +384,9 @@ private
     elsif m.header.content_type == "message/rfc822"
       payload = RMail::Parser.read(m.body)
       from = payload.header.from.first
-      from_person = from ? PersonManager.person_for(from.format) : nil
-      [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
+      from_person = from ? Person.from_address(from.format) : nil
+      [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
+        message_to_chunks(payload, encrypted)
     else
       filename =
         ## first, paw through the headers looking for a filename
@@ -422,7 +416,8 @@ private
         # Lowercase the filename because searches are easier that way 
         @attachments.push filename.downcase unless filename =~ /^sup-attachment-/
         add_label :attachment unless filename =~ /^sup-attachment-/
-        [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
+        content_type = m.header.content_type || "application/unknown" # sometimes RubyMail gives us nil
+        [Chunk::Attachment.new(content_type, filename, m, sibling_types)]
 
       ## otherwise, it's body text
       else
@@ -433,11 +428,10 @@ private
   end
 
   def self.convert_from body, charset
-    charset = "utf-8" if charset =~ /UTF_?8/i
     begin
       raise MessageFormatError, "RubyMail decode returned a null body" unless body
       return body unless charset
-      Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
+      Iconv.easy_decode($encoding, charset, body)
     rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
       Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
       File.open(File.join(BASE_DIR,"unable-to-decode.txt"), "w") { |f| f.write body }
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 e728fe5bb597cd832b84eaee2d6b11e8114822a0..04d5922381b728de2034c9dbda3b8089d78d7ccc 100644 (file)
@@ -5,7 +5,6 @@ class ComposeMode < EditMessageMode
     header = {}
     header["From"] = (opts[:from] || AccountManager.default_account).full_address
     header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
-    header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
     header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
     header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
     header["Subject"] = opts[:subj] if opts[:subj]
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 cc6e7af4a482d46dfefd74f5034846c81035620d..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 }
@@ -321,8 +323,8 @@ protected
 
     ## do whatever crypto transformation is necessary
     if @crypto_selector && @crypto_selector.val != :none
-      from_email = PersonManager.person_for(@header["From"]).email
-      to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| PersonManager.person_for(p).email }
+      from_email = Person.from_address(@header["From"]).email
+      to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
 
       m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
     end
@@ -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
@@ -412,7 +414,7 @@ private
   end
 
   def sig_lines
-    p = PersonManager.person_for(@header["From"])
+    p = Person.from_address(@header["From"])
     from_email = p && p.email
 
     ## first run the hook
index 132b654066a8f107e2e52d7d2622c722e1e65ea6..53287c1156611dc835ab87c571a27b52d7c092b6 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 }
 
@@ -65,7 +65,14 @@ protected
 
     @labels = []
     counts.map do |label, string, total, unread|
-      if total == 0 && !LabelManager::RESERVED_LABELS.include?(label)
+      ## if we've done a search and there are no messages for this label, we can delete it from the
+      ## list. BUT if it's a brand-new label, the user may not have sync'ed it to the index yet, so
+      ## don't delete it in this case.
+      ##
+      ## this is all a hack. what should happen is:
+      ##   TODO make the labelmanager responsible for label counts
+      ## and then it can listen to labeled and unlabeled events, etc.
+      if total == 0 && !LabelManager::RESERVED_LABELS.include?(label) && !LabelManager.new_label?(label)
         Redwood::log "no hits for label #{label}, deleting"
         LabelManager.delete label
         next
index 4e08e8e9158bead401d437d4933d1a3ed0c42b81..6b9f55933a6ef967fc221f8b31a6f209e48bbe84 100644 (file)
@@ -53,22 +53,33 @@ EOS
     hook_reply_from = HookManager.run "reply-from", :message => @m
 
     ## sanity check that selection is a Person (or we'll fail below)
-    ## don't check that it's an Account, though; assume they know what they're doing.
+    ## don't check that it's an Account, though; assume they know what they're
+    ## doing.
     if hook_reply_from && !(hook_reply_from.is_a? Person)
-        Redwood::log "reply-from returned non-Person, using default from."
-        hook_reply_from = nil
+      Redwood::log "reply-from returned non-Person, using default from."
+      hook_reply_from = nil
     end
 
-    from =
-      if hook_reply_from
-        hook_reply_from
-      elsif @m.recipient_email && AccountManager.is_account_email?(@m.recipient_email)
-        PersonManager.person_for(@m.recipient_email)
-      elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
-        b
-      else
-        AccountManager.default_account
-      end
+    ## determine the from address of a reply.
+    ## if we have a value from a hook, use it.
+    from = if hook_reply_from
+      hook_reply_from
+    ## otherwise, if the original email had an envelope-to header, try and use
+    ## it, and look up the corresponding name form the list of accounts.
+    ##
+    ## this is for the case where mail is received from a mailing lists (so the
+    ## To: is the list id itself). if the user subscribes via a particular
+    ## alias, we want to use that alias in the reply.
+    elsif @m.recipient_email && (a = AccountManager.account_for(@m.recipient_email))
+      Person.new a.name, @m.recipient_email
+    ## otherwise, try and find an account somewhere in the list of to's
+    ## and cc's.
+    elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
+      b
+    ## if all else fails, use the default
+    else
+      AccountManager.default_account
+    end
 
     ## now, determine to: and cc: addressess. we ignore reply-to for list
     ## messages because it's typically set to the list address, which we
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 6152666d2e8398e1e76a1d9085b7ac7838965993..e766e2efc7e1a4d261eaf03dc479bc4d7043ec3b 100644 (file)
@@ -42,7 +42,7 @@ 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
@@ -69,6 +69,8 @@ EOS
 
     UpdateManager.register self
 
+    @save_thread_mutex = Mutex.new
+
     @last_load_more_size = nil
     to_load_more do |size|
       next if @last_load_more_size == 0
@@ -78,6 +80,7 @@ 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
@@ -455,14 +458,25 @@ EOS
     BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
   end
 
-  def save
-    dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
-    return if dirty_threads.empty?
+  def save background=true
+    if background
+      Redwood::reporting_thread("saving thread") { actually_save }
+    else
+      actually_save
+    end
+  end
 
-    BufferManager.say("Saving threads...") do |say_id|
-      dirty_threads.each_with_index do |t, i|
-        BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
-        t.save Index
+  def actually_save
+    @save_thread_mutex.synchronize do
+      BufferManager.say("Saving contacts...") { ContactManager.instance.save }
+      dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
+      next if dirty_threads.empty?
+
+      BufferManager.say("Saving threads...") do |say_id|
+        dirty_threads.each_with_index do |t, i|
+          BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
+          t.save Index
+        end
       end
     end
   end
@@ -476,7 +490,7 @@ EOS
       sleep 0.1 # TODO: necessary?
       BufferManager.erase_flash
     end
-    save
+    save false
     super
   end
 
@@ -493,9 +507,14 @@ EOS
   end
 
   def tag_matching
-    query = BufferManager.ask :search, "tag threads matching: "
+    query = BufferManager.ask :search, "tag threads matching (regex): "
     return if query.nil? || query.empty?
-    query = /#{query}/i
+    query = begin
+      /#{query}/i
+    rescue RegexpError => e
+      BufferManager.flash "error interpreting '#{query}': #{e.message}"
+      return
+    end
     @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
     regen_text
   end
@@ -528,10 +547,11 @@ EOS
   end
 
   def multi_edit_labels threads
-    user_labels = BufferManager.ask_for_labels :add_labels, "Add labels: ", [], @hidden_labels
+    user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
     return unless user_labels
-    
-    hl = user_labels.select { |l| @hidden_labels.member? l }
+
+    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 }
     unless hl.empty?
       BufferManager.flash "'#{hl}' is a reserved label!"
       return
@@ -540,10 +560,13 @@ EOS
     old_labels = threads.map { |t| t.labels.dup }
 
     threads.each do |t|
-      user_labels.each do |l|
-        t.apply_label l
-        LabelManager << l
-        UpdateManager.relay self, :labeled, t.first
+      user_labels.each do |(l, to_remove)|
+        if to_remove
+          t.remove_label l
+        else
+          t.apply_label l
+          LabelManager << l
+        end
       end
     end
 
@@ -664,7 +687,7 @@ protected
   def add_or_unhide m
     @ts_mutex.synchronize do
       if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
-        @ts.load_thread_for_message m
+        @ts.load_thread_for_message m, @load_thread_opts
       end
 
       @hidden_threads.delete @ts.thread_for(m)
@@ -824,7 +847,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 5f578d3d1621967f7007176cc76154821a8e0272..f27f00d81ec28bb7563dac53e2ccfd406ae93e2f 100644 (file)
@@ -30,6 +30,7 @@ EOS
     k.add :activate_chunk, "Expand/collapse or activate item", :enter
     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
     k.add :edit_draft, "Edit draft", 'e'
+    k.add :send_draft, "Send draft", 'y'
     k.add :edit_labels, "Edit or add labels for a thread", 'l'
     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
     k.add :jump_to_next_open, "Jump to next open message", 'n'
@@ -148,7 +149,7 @@ EOS
   def subscribe_to_list
     m = @message_lines[curpos] or return
     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
     else
       BufferManager.flash "Can't find List-Subscribe header for this message."
     end
@@ -157,7 +158,7 @@ EOS
   def unsubscribe_from_list
     m = @message_lines[curpos] or return
     if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
     else
       BufferManager.flash "Can't find List-Unsubscribe header for this message."
     end
@@ -259,7 +260,8 @@ EOS
     chunk = @chunk_lines[curpos] or return
     case chunk
     when Chunk::Attachment
-      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", chunk.filename
+      default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
+      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
     else
       m = @message_lines[curpos]
@@ -283,6 +285,18 @@ EOS
     end
   end
 
+  def send_draft
+    m = @message_lines[curpos] or return
+    if m.is_draft?
+      mode = ResumeMode.new m
+      BufferManager.spawn "Send message", mode
+      BufferManager.kill_buffer self.buffer
+      mode.send_message
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
   def jump_to_first_open loose_alignment=false
     m = @message_lines[0] or return
     if @layout[m].state != :closed
@@ -642,7 +656,7 @@ private
       [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
     when Message
       message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
-        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : [])
+        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
 
     else
       raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
index fb58f23889d53503bce539407b2583767b40a49c..c4f40a523e77907590d6eb1e546a37905fef6576 100644 (file)
@@ -1,62 +1,9 @@
 module Redwood
 
-class PersonManager
-  include Singleton
-
-  def initialize fn
-    @fn = fn
-    @@people = {}
-
-    ## read in stored people
-    IO.readlines(fn).map do |l|
-      l =~ /^(.*)?:\s+(\d+)\s+(.*)$/ or next
-      email, time, name = $1, $2, $3
-      @@people[email] = Person.new name, email, time, false
-    end if File.exists? fn
-
-    self.class.i_am_the_instance self
-  end
-
-  def save
-    File.open(@fn, "w") do |f|
-      @@people.each do |email, p|
-        next if p.email == p.name
-        next if p.name =~ /=/ # drop rfc2047-encoded, and lots of other useless emails. definitely a heuristic.
-        f.puts "#{p.email}: #{p.timestamp} #{p.name}"
-      end
-    end
-  end
-
-  def self.people_for s, opts={}
-    return [] if s.nil?
-    s.split_on_commas.map { |ss| self.person_for ss, opts }
-  end
-
-  def self.person_for s, opts={}
-    p = Person.from_address(s) or return nil
-    p.definitive = true if opts[:definitive]
-    register p
-  end
-  
-  def self.register p
-    oldp = @@people[p.email]
-
-    if oldp.nil? || p.better_than?(oldp)
-      @@people[p.email] = p
-    end
-
-    @@people[p.email].touch!
-    @@people[p.email]
-  end
-end
-
-## don't create these by hand. rather, go through personmanager, to
-## ensure uniqueness and overriding.
 class Person 
-  attr_accessor :name, :email, :timestamp
-  bool_accessor :definitive
+  attr_accessor :name, :email
 
-  def initialize name, email, timestamp=0, definitive=false
+  def initialize name, email
     raise ArgumentError, "email can't be nil" unless email
     
     if name
@@ -67,26 +14,10 @@ class Person
     end
 
     @email = email.gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ").downcase
-    @definitive = definitive
-    @timestamp = timestamp
-  end
-
-  ## heuristic: whether the name attached to this email is "real", i.e. 
-  ## we should bother to store it.
-  def generic?
-    @email =~ /no\-?reply/
-  end
-
-  def better_than? o
-    return false if o.definitive? || generic?
-    return true if definitive?
-    o.name.nil? || (name && name.length > o.name.length && name =~ /[a-z]/)
   end
 
   def to_s; "#@name <#@email>" end
 
-  def touch!; @timestamp = Time.now.to_i end
-
 #   def == o; o && o.email == email; end
 #   alias :eql? :==
 #   def hash; [name, email].hash; end
@@ -146,8 +77,20 @@ class Person
     return nil if s.nil?
 
     ## try and parse an email address and name
-    name, email =
-      case s
+    name, email = case s
+      when /(.+?) ((\S+?)@\S+) \3/
+        ## ok, this first match cause is insane, but bear with me.  email
+        ## addresses are stored in the to/from/etc fields of the index in a
+        ## weird format: "name address first-part-of-address", i.e.  spaces
+        ## separating those three bits, and no <>'s. this is the output of
+        ## #indexable_content. here, we reverse-engineer that format to extract
+        ## a valid address.
+        ##
+        ## we store things this way to allow searches on a to/from/etc field to
+        ## match any of those parts. a more robust solution would be to store a
+        ## separate, non-indexed field with the proper headers. but this way we
+        ## save precious bits, and it's backwards-compatible with older indexes.
+        [$1, $2]
       when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/
         a, b = $1, $2
         [a.gsub('\"', '"'), b]
@@ -162,6 +105,12 @@ class Person
     Person.new name, email
   end
 
+  def self.from_address_list ss
+    return [] if ss.nil?
+    ss.split_on_commas.map { |s| self.from_address s }
+  end
+
+  ## see comments in self.from_address
   def indexable_content
     [name, email, email.split(/@/).first].join(" ")
   end
index 87cf9963796286f73a92f87478c207e23300cfa0..fb4abb2526771422579020407bb0ba671391485a 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
@@ -97,13 +97,13 @@ EOS
         numi = 0
         add_messages_from source do |m, offset, entry|
           ## always preserve the labels on disk.
-          m.labels = entry[:label].split(/\s+/).map { |x| x.intern } if entry
+          m.labels = ((m.labels - [:unread, :inbox]) + entry[:label].symbolistize).uniq if entry
           yield "Found message at #{offset} with labels {#{m.labels * ', '}}"
           unless entry
             num += 1
-            from_and_subj << [m.from.longname, m.subj]
+            from_and_subj << [m.from && m.from.longname, m.subj]
             if m.has_label?(:inbox) && ([:spam, :deleted, :killed] & m.labels).empty?
-              from_and_subj_inbox << [m.from.longname, m.subj]
+              from_and_subj_inbox << [m.from && m.from.longname, m.subj]
               numi += 1 
             end
           end
@@ -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 947de022bc41715c641585e7fe73b4405aac7b6e..fcd5cf071262565ef0594ac736b5c758d44710d5 100644 (file)
@@ -52,13 +52,8 @@ module Rfc2047
         # WORD.
       end
 
-      charset = "utf-8" if charset =~ /UTF_?8/i
-
-      # Convert:
-      #
-      # Remember - Iconv.open(to, from)!
       begin
-        text = Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
+        Iconv.easy_decode(target, charset, text)
       rescue Iconv::InvalidCharacter
         text
       end
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("~")
index 479c609e146913c4e1391ef13740ef50a779f8da..c748c7a653a6a3405a089c2cfe4149a17ba82de4 100644 (file)
@@ -33,7 +33,7 @@ class TextField
     @w, @y, @x, @width = window, y, x, width
     @question = question
     @completion_block = block
-    @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 0, 0
+    @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 256, 0
     @form = Ncurses::Form.new_form [@field]
     @value = default
     Ncurses::Form.post_form @form
index 9909022ffa9b0b1c5796f4fbdff925087dec2519..c26b4dbe9a3fe5207d34be9067394d571c36c979 100644 (file)
@@ -188,11 +188,6 @@ class String
     ret
   end
 
-  ## one of the few things i miss from perl
-  def ucfirst
-    self[0 .. 0].upcase + self[1 .. -1]
-  end
-
   ## a very complicated regex found on teh internets to split on
   ## commas, unless they occurr within double quotes.
   def split_on_commas
@@ -276,6 +271,11 @@ class String
   def normalize_whitespace
     gsub(/\t/, "    ").gsub(/\r/, "")
   end
+
+  ## takes a space-separated list of words, and returns an array of symbols.
+  ## typically used in Sup for translating Ferret's representation of a list
+  ## of labels (a string) to an array of label symbols.
+  def symbolistize; split.map { |x| x.intern } end
 end
 
 class Numeric
@@ -403,6 +403,10 @@ class Array
 
   def last= e; self[-1] = e end
   def nonempty?; !empty? end
+
+  def to_set_of_symbols
+    map { |x| x.is_a?(Symbol) ? x : x.intern }.uniq
+  end
 end
 
 class Time
@@ -617,3 +621,21 @@ class FinishLine
     @m.synchronize { !@over && @over = true }
   end
 end
+
+class Iconv
+  def self.easy_decode target, charset, text
+    return text if charset =~ /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i
+    charset = case charset
+                when /UTF[-_ ]?8/i: "utf-8"
+                when /(iso[-_ ])?latin[-_ ]?1$/i: "ISO-8859-1"
+                when /iso[-_ ]?8859[-_ ]?15/i: 'ISO-8859-15'
+                when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i: "utf-7"
+                else charset
+              end
+
+    # Convert:
+    #
+    # Remember - Iconv.open(to, from)!
+    Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
+  end
+end
index b1f09dd5953839e85076a72050a4acadefb02ea8..3b5b5694e9dd30b0391c1ab84f2c3791194b92f4 100644 (file)
@@ -14,9 +14,9 @@ vi www/index.html # and include CONTRIBUTORS
 git checkout -b release-<releasename>
 vi lib/sup.rb bin/sup # and bump BOTH version numbers
 # ... git add, commit, etc
-rake release VERSION=<releasename>
+rake gem
+rake tarball
+# manually upload
 git publish-branch
 rake upload_webpage
 rake upload_report
-git checkout next
-git merge master
diff --git a/sup-files.rb b/sup-files.rb
new file mode 100644 (file)
index 0000000..6acf7c0
--- /dev/null
@@ -0,0 +1,11 @@
+SUP_LIB_DIRS = %w(lib lib/sup lib/sup/modes lib/sup/mbox)
+SUP_EXECUTABLES = %w(sup sup-add sup-config sup-dump sup-recover-sources sup-sync sup-sync-back sup-tweak-labels)
+SUP_EXTRA_FILES = %w(CONTRIBUTORS README.txt LICENSE History.txt ReleaseNotes)
+SUP_FILES =
+  SUP_EXTRA_FILES +
+  SUP_EXECUTABLES.map { |f| "bin/#{f}" } +
+  SUP_LIB_DIRS.map { |d| Dir["#{d}/*.rb"] }.flatten
+
+if $0 == __FILE__ # if executed from commandline
+  puts SUP_FILES
+end
diff --git a/sup-version.rb b/sup-version.rb
new file mode 100644 (file)
index 0000000..1a10ed2
--- /dev/null
@@ -0,0 +1,15 @@
+## allow people who use development versions by running "rake gem"
+## and installing the resulting gem it to be able to do this. (gem
+## versions must be in dotted-digit notation only and can be passed
+## with the REL environment variable to "rake gem").
+SUP_VERSION = if ENV['REL']
+  ENV['REL']
+else
+  $:.unshift 'lib' # force loading from ./lib/ if it exists
+  require 'sup'
+  if Redwood::VERSION == "git"
+    "999"
+  else
+    Redwood::VERSION
+  end
+end
diff --git a/sup.gemspec b/sup.gemspec
new file mode 100644 (file)
index 0000000..506d8ad
--- /dev/null
@@ -0,0 +1,30 @@
+$:.push "lib"
+
+require "sup-files"
+require "sup-version"
+
+Gem::Specification.new do |s|
+  s.name = %q{sup}
+  s.version = SUP_VERSION
+  s.date = Time.now.to_s
+  s.authors = ["William Morgan"]
+  s.email = %q{wmorgan-sup@masanjin.net}
+  s.summary = %q{A console-based email client with the best features of GMail, mutt, and emacs. Features full text search, labels, tagged operations, multiple buffers, recent contacts, and more.}
+  s.homepage = %q{http://sup.rubyforge.org/}
+  s.description = %q{Sup is a console-based email client for people with a lot of email. It supports tagging, very fast full-text search, automatic contact-list management, and more. If you're the type of person who treats email as an extension of your long-term memory, Sup is for you.  Sup makes it easy to: - Handle massive amounts of email.  - Mix email from different sources: mbox files (even across different machines), Maildir directories, IMAP folders, POP accounts, and GMail accounts.  - Instantaneously search over your entire email collection. Search over body text, or use a query language to combine search predicates in any way.  - Handle multiple accounts. Replying to email sent to a particular account will use the correct SMTP server, signature, and from address.  - Add custom code to handle certain types of messages or to handle certain types of text within messages.  - Organize email with user-defined labels, automatically track recent contacts, and much more!  The goal of Sup is to become the email client of choice for nerds everywhere.}
+  s.files = SUP_FILES
+  s.executables = SUP_EXECUTABLES
+
+  s.add_dependency "ferret", ">= 0.11.6"
+  s.add_dependency "ncurses", ">= 0.9.1"
+  s.add_dependency "rmail", ">= 0.17"
+  s.add_dependency "highline"
+  s.add_dependency "net-ssh"
+  s.add_dependency "trollop", ">= 1.12"
+  s.add_dependency "lockfile"
+  s.add_dependency "mime-types", "~> 1"
+  s.add_dependency "gettext"
+  s.add_dependency "fastthread"
+
+  puts s.files
+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..91cf7c7
--- /dev/null
@@ -0,0 +1,157 @@
+#!/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
+
+  def test_from_line_splitting
+    l = MBox::Loader.new StringIO.new(<<EOS)
+From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello there friend. How are you?
+
+From sea to shining sea
+
+From bob@bob.com I get only spam.
+
+From bob@bob.com   
+
+From bob@bob.com
+
+(that second one has spaces at the endj
+
+This is the end of the email.
+EOS
+    offset, labels = l.next
+    assert_equal 0, offset
+    offset, labels = l.next
+    assert_nil offset
+  end
+
+  def test_more_from_line_splitting
+    l = MBox::Loader.new StringIO.new(<<EOS)
+From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello there friend. How are you?
+
+From bob@bob.com Mon Apr 27 12:56:19 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello again! Would you like to buy my products?
+EOS
+    offset, labels = l.next
+    assert_not_nil offset
+
+    offset, labels = l.next
+    assert_not_nil offset
+
+    offset, labels = l.next
+    assert_nil offset
+  end
+end
diff --git a/test/test_mbox_parsing.rb b/test/test_mbox_parsing.rb
deleted file mode 100644 (file)
index 3a6c2f8..0000000
+++ /dev/null
@@ -1,114 +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_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
index c88777349dc85683c085d6e54898a165ea1ce6d5..0a7db454febd8c93238dacaa89590f6110222cc6 100644 (file)
@@ -29,11 +29,6 @@ module Redwood
 class TestMessage < Test::Unit::TestCase
   
   def setup
-    person_file = StringIO.new("")
-    # this is a singleton
-    if not PersonManager.instantiated?
-      @person_manager = PersonManager.new(person_file)
-    end
   end
   
   def teardown
@@ -428,6 +423,100 @@ EOS
 
   end
   
+  def test_blank_header_lines
+
+    message = <<EOS
+Return-Path: <monitor-list-bounces@widget.com>
+X-Original-To: nobody@localhost
+Delivered-To: nobody@localhost.eng.widget.com
+Received: from localhost (localhost.localdomain [127.0.0.1])
+       by soquel.eng.widget.com (Postfix) with ESMTP id 609BC13C0DB1
+       for <nobody@localhost>; Thu, 19 Mar 2009 13:43:21 -0700 (PDT)
+MIME-Version: 1.0
+Received: from pa-excas-vip.widget.com [10.16.67.200]
+       by localhost with IMAP (fetchmail-6.2.5)
+       for nobody@localhost (single-drop); Thu, 19 Mar 2009 13:43:21 -0700 (PDT)
+Received: from pa-exht01.widget.com (10.113.81.167) by pa-excaht11.widget.com
+ (10.113.81.197) with Microsoft SMTP Server (TLS) id 8.1.311.2; Thu, 19 Mar
+ 2009 13:42:30 -0700
+Received: from mailman2.widget.com (10.16.64.159) by pa-exht01.widget.com
+ (10.113.81.167) with Microsoft SMTP Server id 8.1.336.0; Thu, 19 Mar 2009
+ 13:42:30 -0700
+Received: by mailman2.widget.com (Postfix)     id 47095AE30856; Thu, 19 Mar 2009
+ 13:42:29 -0700 (PDT)
+Received: from countchocula.widget.com (localhost.localdomain [127.0.0.1])     by
+ mailman2.widget.com (Postfix) with ESMTP id 5F782ABC5948;     Thu, 19 Mar 2009
+ 13:42:28 -0700 (PDT)
+Received: from mailhost4.widget.com (mailhost4.widget.com [10.16.67.124])      by
+ mailman2.widget.com (Postfix) with ESMTP id 6CDCCABC5948      for
+ <monitor-list@mailman2.widget.com>;   Thu, 19 Mar 2009 13:42:26 -0700 (PDT)
+Received: by mailhost4.widget.com (Postfix)    id 2364AC9AC4; Thu, 19 Mar 2009
+ 13:42:26 -0700 (PDT)
+Received: from pa-exht01.widget.com (pa-exht01.widget.com [10.113.81.167])     by
+ mailhost4.widget.com (Postfix) with ESMTP id 17A68C9AC3       for
+ <monitor-list@widget.com>; Thu, 19 Mar 2009 13:42:26 -0700 (PDT)
+Received: from PA-EXMBX04.widget.com ([10.113.81.142]) by pa-exht01.widget.com
+       ([10.113.81.167]) with mapi; Thu, 19 Mar 2009 13:42:26 -0700
+From: Some User <someuser@widget.com>
+To: "monitor-list@widget.com" <monitor-list@widget.com>
+Sender: "monitor-list-bounces@widget.com" <monitor-list-bounces@widget.com>
+Date: Thu, 19 Mar 2009 13:42:25 -0700
+Subject: Looking for a mac
+Thread-Topic: Looking for a mac
+Thread-Index: AQHJqNM1xIqqjNRWuUCUBaxzPFK5eQ==
+Message-ID:
+ <D3C12B2AD838B44DA9D6B2CA334246D011E72A73A4@PA-EXMBX04.widget.com>
+List-Help: <mailto:monitor-list-request@widget.com?subject=help>
+List-Subscribe: <http://mailman2.widget.com/mailman/listinfo/monitor-list>,
+       <mailto:monitor-list-request@widget.com?subject=subscribe>
+List-Unsubscribe:
+ <http://mailman2.widget.com/mailman/listinfo/monitor-list>,
+       <mailto:monitor-list-request@widget.com?subject=unsubscribe>
+Accept-Language: en-US
+Content-Language: en-US
+X-MS-Exchange-Organization-AuthAs: Anonymous
+X-MS-Exchange-Organization-AuthSource: pa-exht01.widget.com
+X-MS-Has-Attach:
+X-Auto-Response-Suppress: All
+X-MS-TNEF-Correlator:
+acceptlanguage: en-US
+delivered-to: monitor-list@widget.com
+errors-to: monitor-list-bounces@widget.com
+list-id: engineering monitor related <monitor-list.widget.com>
+x-mailman-version: 2.1.8
+x-beenthere: monitor-list@widget.com
+x-original-to: monitor-list@mailman2.widget.com
+list-post: <mailto:monitor-list@widget.com>
+list-archive: <http://mailman2.widget.com/pipermail/monitor-list>
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: quoted-printable
+
+Hi all,
+
+    Just wondering if anybody can lend me a mac to reproduce PR 384931 ?
+    Thanks.
+
+Michael=
+EOS
+
+    source = DummySource.new("sup-test://test_blank_header_lines")
+    source.messages = [ message ]
+    source_info = 0
+
+    sup_message = Message.new( {:source => source, :source_info => source_info } )
+
+    # See how well parsing the message ID went.
+    id = sup_message.id
+    assert_equal("D3C12B2AD838B44DA9D6B2CA334246D011E72A73A4@PA-EXMBX04.widget.com", id)
+
+    # 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>, " +
+                 "<mailto:monitor-list-request@widget.com?subject=unsubscribe>",
+                 list_unsubscribe)
+
+  end
+
   # TODO: test different error cases, malformed messages etc.
 
   # TODO: test different quoting styles, see that they are all divided
index 2e3f1e693a48fbb099e7951fc043e4777cb8ebab..bdf40e97822db4102aefc836d8505dd0044570b5 100644 (file)
                <h2>Status</h2>
 
                <p>
-               The current version of Sup is 0.6, released 2008-08-03. This is a
+               The current version of Sup is 0.7, released 2009-03-25. This is a
                beta release. It supports mbox, mbox over ssh, IMAP, IMAPS, and Maildir mailstores.
                </p>
 
-        <p>Issue and release status is available on the <a href="ditz/">Sup ditz page</a>.</p>
+        <!-- <p>Issue and release status is available on the <a href="ditz/">Sup ditz page</a>.</p> -->
+        <p>Sup news can often be see on <a href="http://all-thing.net/label/sup/">William's blog</a>.</p>
 
                <h2>Getting it</h2>
 
                <p>
                Sup is brought to you by <a href="http://cs.stanford.edu/~ruby/">William Morgan</a> and the following honorable contributors:
         <ul>
-          Ismo Puustinen &lt;ismo at the iki dot fis&gt;
-          Marcus Williams &lt;marcus-sup at the bar-coded dot nets&gt;
-          Lionel Ott &lt;white.magic at the gmx dot des&gt;
-          Nicolas Pouillard &lt;nicolas.pouillard at the gmail dot coms&gt;
-          Marc Hartstein &lt;marc.hartstein at the alum.vassar dot edus&gt;
-          Ben Walton &lt;bwalton at the artsci.utoronto dot cas&gt;
-          Grant Hollingworth &lt;grant at the antiflux dot orgs&gt;
-          Jeff Balogh &lt;its.jeff.balogh at the gmail dot coms&gt;
-          Christopher Warrington &lt;chrisw at the rice dot edus&gt;
-          Giorgio Lando &lt;patroclo7 at the gmail dot coms&gt;
-          Decklin Foster &lt;decklin at the red-bean dot coms&gt;
-          Ian Taylor &lt;ian at the lorf dot orgs&gt;
+<li>William Morgan &lt;wmorgan-sup at the masanjin dot nets&gt;</li>
+<li>Ismo Puustinen &lt;ismo at the iki dot fis&gt;</li>
+<li>Nicolas Pouillard &lt;nicolas.pouillard at the gmail dot coms&gt;</li>
+<li>Marcus Williams &lt;marcus-sup at the bar-coded dot nets&gt;</li>
+<li>Lionel Ott &lt;white.magic at the gmx dot des&gt;</li>
+<li>Christopher Warrington &lt;chrisw at the rice dot edus&gt;</li>
+<li>Marc Hartstein &lt;marc.hartstein at the alum.vassar dot edus&gt;</li>
+<li>Ben Walton &lt;bwalton at the artsci.utoronto dot cas&gt;</li>
+<li>Grant Hollingworth &lt;grant at the antiflux dot orgs&gt;</li>
+<li>Steve Goldman &lt;sgoldman at the tower-research dot coms&gt;</li>
+<li>Decklin Foster &lt;decklin at the red-bean dot coms&gt;</li>
+<li>Jeff Balogh &lt;its.jeff.balogh at the gmail dot coms&gt;</li>
+<li>Giorgio Lando &lt;patroclo7 at the gmail dot coms&gt;</li>
+<li>Israel Herraiz &lt;israel.herraiz at the gmail dot coms&gt;</li>
+<li>Ian Taylor &lt;ian at the lorf dot orgs&gt;</li>
+<li>Rich Lane &lt;rlane at the club.cc.cmu dot edus&gt;</li>
         </ul>
         </p>