]> git.cworth.org Git - sup/commitdiff
Merge commit 'origin/various-mbox-fixes'
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 28 May 2009 14:31:01 +0000 (10:31 -0400)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 28 May 2009 14:31:01 +0000 (10:31 -0400)
Conflicts:

lib/sup/mbox.rb
test/test_mbox_parsing.rb

37 files changed:
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]
contrib/completion/_sup.zsh [new file with mode: 0644]
doc/NewUserGuide.txt
lib/sup.rb
lib/sup/buffer.rb
lib/sup/colormap.rb
lib/sup/draft.rb
lib/sup/imap.rb
lib/sup/index.rb
lib/sup/label.rb
lib/sup/logger.rb
lib/sup/maildir.rb
lib/sup/mbox.rb
lib/sup/mbox/loader.rb
lib/sup/message.rb
lib/sup/mode.rb
lib/sup/modes/buffer-list-mode.rb
lib/sup/modes/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/resume-mode.rb
lib/sup/modes/thread-index-mode.rb
lib/sup/poll.rb
lib/sup/source.rb
lib/sup/util.rb
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

diff --git a/bin/sup b/bin/sup
old mode 100644 (file)
new mode 100755 (executable)
index d8eee0e..0af3d11
--- a/bin/sup
+++ b/bin/sup
@@ -67,9 +67,9 @@ global_keymap = Keymap.new do |k|
   k.add :quit_now, "Quit Sup immediately", 'Q'
   k.add :help, "Show help", '?'
   k.add :roll_buffers, "Switch to next buffer", 'b'
-#  k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
+  k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
   k.add :kill_buffer, "Kill the current buffer", 'x'
-  k.add :list_buffers, "List all buffers", 'B'
+  k.add :list_buffers, "List all buffers", ';'
   k.add :list_contacts, "List contacts", 'C'
   k.add :redraw, "Redraw screen", :ctrl_l
   k.add :search, "Search all messages", '\\', 'F'
@@ -81,6 +81,36 @@ global_keymap = Keymap.new do |k|
   k.add :recall_draft, "Edit most recent draft message", 'R'
 end
 
+## the following magic enables wide characters when used with a ruby
+## ncurses.so that's been compiled against libncursesw. (note the w.) why
+## this works, i have no idea. much like pretty much every aspect of
+## dealing with curses.  cargo cult programming at its best.
+##
+## BSD users: if libc.so.6 is not found, try installing compat6x.
+require 'dl/import'
+module LibC
+  extend DL::Importable
+  setlocale_lib = case Config::CONFIG['arch']
+    when /darwin/; "libc.dylib"
+    when /cygwin/; "cygwin1.dll"
+    else; "libc.so.6"
+  end
+
+  Redwood::log "dynamically loading setlocale() from #{setlocale_lib}"
+  begin
+    dlload setlocale_lib
+    extern "void setlocale(int, const char *)"
+    Redwood::log "setting locale..."
+    LibC.setlocale(6, "")  # LC_ALL == 6
+  rescue RuntimeError => e
+    Redwood::log "cannot dlload setlocale(); ncurses wide character support probably broken."
+    Redwood::log "dlload error was #{e.class}: #{e.message}"
+    if Config::CONFIG['arch'] =~ /bsd/
+      Redwood::log "BSD variant detected. You may have to install a compat6x package to acquire libc."
+    end
+  end
+end
+
 def start_cursing
   Ncurses.initscr
   Ncurses.noecho
@@ -230,7 +260,7 @@ begin
     when :kill_buffer
       bm.kill_buffer_safely bm.focus_buf
     when :list_buffers
-      bm.spawn_unless_exists("Buffer List") { BufferListMode.new }
+      bm.spawn_unless_exists("buffer list", :system => true) { BufferListMode.new }
     when :list_contacts
       b, new = bm.spawn_unless_exists("Contact List") { ContactListMode.new }
       b.mode.load_in_background if new
@@ -241,7 +271,7 @@ begin
     when :search_unread
       SearchResultsMode.spawn_from_query "is:unread"
     when :list_labels
-      labels = LabelManager.listable_labels.map { |l| LabelManager.string_for l }
+      labels = LabelManager.all_labels.map { |l| LabelManager.string_for l }
       user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels
       unless user_label.nil?
         if user_label.empty?
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 91710d4..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,13 +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 = entry[:label].split(/\s+/).map { |x| x.intern } if entry
+      index_state = entry[:label].symbolistize if entry
 
       ## skip if we're operating on restored messages, and this one
       ## ain't.
@@ -153,7 +166,7 @@ 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
@@ -171,16 +184,16 @@ begin
         ## nothin! use default source labels
       end
 
-      if Time.now - last_info_time > 60
+      if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
         last_info_time = Time.now
         elapsed = last_info_time - start_time
         pctdone = source.respond_to?(:pct_done) ? source.pct_done : 100.0 * (source.cur_offset.to_f - source.start_offset).to_f / (source.end_offset - source.start_offset).to_f
         remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
-        $stderr.puts "## #{num_scanned} (#{pctdone}%) read; #{elapsed.to_time_s} elapsed; #{remaining.to_time_s} remaining"
+        $stderr.printf "## read %dm (about %.0f%%) @ %.1fm/s. %s elapsed, about %s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
       end
 
       if index_state.nil?
-        puts "Adding message #{source}##{offset} with state {#{m.labels * ', '}}" if opts[:verbose]
+        puts "Adding message #{source}##{offset} from #{m.from} with state {#{m.labels * ', '}}" if opts[:verbose]
         num_added += 1
       else
         puts "Updating message #{source}##{offset}, source #{entry[:source_id]} => #{source.id}, offset #{entry[:source_info]} => #{offset}, state {#{index_state * ', '}} => {#{m.labels * ', '}}" if opts[:verbose]
@@ -200,9 +213,7 @@ begin
   ## API.
   ##
   ## TODO: move this to Index, i suppose.
-
-
-  if target == :all || target == :changed
+  if (target == :all || target == :changed) && !opts[:start_at]
     $stderr.puts "Deleting missing messages from the index..."
     num_del, num_scanned = 0, 0
     sources.each do |source|
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 0beb77e..8a8152d
@@ -46,10 +46,12 @@ 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
@@ -102,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/contrib/completion/_sup.zsh b/contrib/completion/_sup.zsh
new file mode 100644 (file)
index 0000000..76870ca
--- /dev/null
@@ -0,0 +1,114 @@
+#compdef sup sup-add sup-config sup-dump sup-sync sup-sync-back sup-tweak-labels sup-recover-sources
+# vim: set et sw=2 sts=2 ts=2 ft=zsh :
+
+# TODO: sources completion: maildir://some/dir, mbox://some/file, ...
+#       for sup-add, sup-sync, sup-sync-back, sup-tweak-labels
+
+(( ${+functions[_sup_cmd]} )) ||
+_sup_cmd()
+{
+  _arguments -s : \
+    "(--list-hooks -l)"{--list-hooks,-l}"[list all hooks and descriptions, and quit]" \
+    "(--no-threads -n)"{--no-threads,-n}"[turn off threading]" \
+    "(--no-initial-poll -o)"{--no-initial-poll,-o}"[Don't poll for new messages when starting]" \
+    "(--search -s)"{--search,-s}"[search for this query upon startup]:Query: " \
+    "(--compose -c)"{--compose,-c}"[compose message to this recipient upon startup]:Email: " \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_add_cmd]} )) ||
+_sup_add_cmd()
+{
+  _arguments -s : \
+    "(--archive -a)"{--archive,-a}"[automatically archive all new messages from this source]" \
+    "(--unusual -u)"{--unusual,-u}"[do not automatically poll for new messages from this source]" \
+    "(--labels -l)"{--labels,-l}"[set of labels to apply to all messages from this source]:Labels: " \
+    "(--force-new -f)"{--force-new,-f}"[create a new account for this source, even if one already exists]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_config_cmd]} )) ||
+_sup_config_cmd()
+{
+  _arguments -s : \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_dump_cmd]} )) ||
+_sup_dump_cmd()
+{
+  _arguments -s : \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_recover_sources_cmd]} )) ||
+_sup_recover_sources_cmd()
+{
+  _arguments -s : \
+    "--archive[automatically archive all new messages from this source]" \
+    "--scan-num[number of messages to scan per source]:" \
+    "--unusual[do not automatically poll for new messages from this source]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_sync_cmd]} )) ||
+_sup_sync_cmd()
+{
+  # XXX Add only when --restore is given: (--restored -r)
+  #     Add only when --changed or--all are given: (--start-at -s)
+  _arguments -s : \
+    "--new[operate on new messages only]" \
+    "(--changed -c)"{--changed,-c}"[scan over the entire source for messages that have been deleted, altered, or moved from another source]" \
+    "(--restored -r)"{--restored,-r}"[operate only on those messages included in a dump file as specified by --restore which have changed state]" \
+    "(--all -a)"{--all,-a}"[operate on all messages in the source, regardless of newness or changedness]" \
+    "(--start-at -s)"{--start-at,-s}"[start at a particular offset]:Offset: " \
+    "--asis[if the message is already in the index, preserve its state, otherwise, use default source state]" \
+    "--restore[restore message state from a dump file created with sup-dump]:File:_file" \
+    "--discard[discard any message state in the index and use the default source state]" \
+    "(--archive -x)"{--archive,-x}"[mark messages as archived when using the default source state]" \
+    "(--read -e)"{--read,-e}"[mark messages as read when using the default source state]" \
+    "--extra-labels[apply these labels when using the default source state]:Labels: " \
+    "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+    "(--optimize -o)"{--optimize,-o}"[as the final operation, optimize the index]" \
+    "--all-sources[scan over all sources]" \
+    "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_sync_back_cmd]} )) ||
+_sup_sync_back_cmd()
+{
+  _arguments -s : \
+    "(--drop-deleted -d)"{--drop-deleted,-d}"[drop deleted messages]" \
+    "--move-deleted[move deleted messages to a local mbox file]:File:_file" \
+    "(--drop-spam -s)"{--drop-spam,-s}"[drop spam messages]" \
+    "--move-spam[move spam messages to a local mbox file]:File:_file" \
+    "--with-dotlockfile[specific dotlockfile location (mbox files only)]:File:_file" \
+    "--dont-use-dotlockfile[don't use dotlockfile to lock mbox files]" \
+    "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+    "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_tweak_labels_cmd]} )) ||
+_sup_tweak_labels_cmd()
+{
+  _arguments -s : \
+    "(--add -a)"{--add,-a}"[which labels to add to every message from the specified sources]:Labels: " \
+    "(--remove -r)"{--remove,-r}"[which labels to remove from every message from the specified sources]:Labels: " \
+    "--all-sources[scan over all sources]" \
+    "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+    "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+    "--version[show version information]" \
+    "(--help -h)"{--help,-h}"[show help]"
+}
+
+_call_function ret _${words[1]//-/_}_cmd
+return ret
+
index e4d49eef3bcb87aae0d8b83f37bb89c7896689f3..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 3afac5e012b0e9438434981fa2ebaae068ec9ea4..88eae7fc5037da797616b0301d8005d36eb1b6b4 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
index c9e34b3595c62b82b64350163e0d547e127f95eb..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
 
@@ -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!"
index 469ed0b91874342a8fde6787008aa625b9d4e576..38787cddc41374a9f9adf583160627a5dfb448e0 100644 (file)
@@ -11,7 +11,7 @@ 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"] },
@@ -46,7 +46,10 @@ class Colormap
     :completion_character => { :fg => "white", :bg => "default", :attrs => ["bold"] },
     :horizontal_selector_selected => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
     :horizontal_selector_unselected => { :fg => "cyan", :bg => "default" },
-    :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }
+    :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] },
+    :system_buf => { :fg => "blue", :bg => "default" },
+    :regular_buf => { :fg => "white", :bg => "default" },
+    :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
   }
   
   def initialize
index 35fac30ee8cc0915632958abb6579c6ede54ebfa..32266b5374eb3d67b336926a28e8fe75eecf30ce 100644 (file)
@@ -79,9 +79,7 @@ class DraftLoader < Source
   def fn_for_offset o; File.join(@dir, o.to_s); end
 
   def load_header offset
-    File.open fn_for_offset(offset) do |f|
-      return MBox::read_header(f)
-    end
+    File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f }
   end
   
   def load_message offset
index 4eb13f4ed619d3259df4894eec4a8b66c11c326e..7508c2c7b3e64d4fd39f7a8467a380d68128f6d0 100644 (file)
@@ -93,7 +93,7 @@ class IMAP < Source
   def == o; o.is_a?(IMAP) && o.uri == self.uri && o.username == self.username; end
 
   def load_header id
-    MBox::read_header StringIO.new(raw_header(id))
+    parse_raw_email_header StringIO.new(raw_header(id))
   end
 
   def load_message id
index 838d601347f1f86ffba21ac72e37c3e191ac95ba..0dae1e0933568e902f39247458d30870c47a26e9 100644 (file)
@@ -177,31 +177,31 @@ EOS
     end
   end
 
-  ## Syncs the message to the index: deleting if it's already there,
-  ## and adding either way. Index state will be determined by m.labels.
+  ## Syncs the message to the index, replacing any previous version.  adding
+  ## either way. Index state will be determined by the message's #labels
+  ## accessor.
   ##
-  ## docid and entry can be specified if they're already known.
-  def sync_message m, docid=nil, entry=nil, opts={}
-    docid, entry = load_entry_for_id m.id unless docid && entry
+  ## if need_load is false, docid and entry are assumed to be set to the
+  ## result of load_entry_for_id (which can be nil).
+  def sync_message m, need_load=true, docid=nil, entry=nil, opts={}
+    docid, entry = load_entry_for_id m.id if need_load
 
     raise "no source info for message #{m.id}" unless m.source && m.source_info
     @index_mutex.synchronize do
       raise "trying to delete non-corresponding entry #{docid} with index message-id #{@index[docid][:message_id].inspect} and parameter message id #{m.id.inspect}" if docid && @index[docid][:message_id] != m.id
     end
 
-    source_id = 
-      if m.source.is_a? Integer
-        m.source
-      else
-        m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
-      end
+    source_id = if m.source.is_a? Integer
+      m.source
+    else
+      m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
+    end
 
-    snippet = 
-      if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
-        ""
-      else
-        m.snippet
-      end
+    snippet = if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
+      ""
+    else
+      m.snippet
+    end
 
     ## write the new document to the index. if the entry already exists in the
     ## index, reuse it (which avoids having to reload the entry from the source,
@@ -226,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})"
@@ -261,15 +261,14 @@ EOS
       :refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
     }
 
-    @index_mutex.synchronize  do
+    @index_mutex.synchronize do
       @index.delete docid if docid
       @index.add_document d
     end
 
-    docid, entry = load_entry_for_id m.id
-    ## this hasn't been triggered in a long time. TODO: decide whether it's still a problem.
-    raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
-    true
+    ## this hasn't been triggered in a long time.
+    ## docid, entry = load_entry_for_id m.id
+    ## raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
   end
 
   def save_index fn=File.join(@dir, "ferret")
@@ -333,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
@@ -378,7 +377,7 @@ EOS
           unless messages.member?(mid)
             #Redwood::log "got #{mid} as a child of #{id}"
             messages[mid] ||= lambda { build_message docid }
-            refs = @index[docid][:refs].split(" ")
+            refs = @index[docid][:refs].split
             pending += refs.select { |id| !searched[id] }
           end
         end
@@ -389,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
@@ -409,14 +408,16 @@ EOS
         "date" => Time.at(doc[:date].to_i),
         "subject" => unwrap_subj(doc[:subject]),
         "from" => doc[:from],
-        "to" => doc[:to].split(/\s+/).join(", "), # reformat
+        "to" => doc[:to].split.join(", "), # reformat
         "message-id" => doc[:message_id],
-        "references" => doc[:refs].split(/\s+/).map { |x| "<#{x}>" }.join(" "),
+        "references" => doc[:refs].split.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
+      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
 
@@ -552,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"
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 9bd10ad5398d08414189f0fab2c81be8dd379f45..0d941b1d8ae341b86bcf6303e752c3c4bb1c36e5 100644 (file)
@@ -5,13 +5,8 @@ require "sup/rfc2047"
 
 module Redwood
 
-## some utility functions. actually these are not mbox-specific at all
-## and should be moved somewhere else.
-##
-## TODO: move functionality to somewhere better, like message.rb
 module MBox
   BREAK_RE = /^From \S+ (.+)$/
-  HEADER_RE = /\s*(.*?)\s*/
 
   def is_break_line? l
     l =~ BREAK_RE or return false
@@ -26,70 +21,5 @@ module MBox
     end
   end
   module_function :is_break_line?
-
-  def read_header f
-    header = {}
-    last = nil
-
-    ## i do it in this weird way because i am trying to speed things up
-    ## when scanning over large mbox files.
-    while(line = f.gets)
-      case line
-      ## these three can occur multiple times, and we want the first one
-      when /^(Delivered-To):#{HEADER_RE}$/i,
-        /^(X-Original-To):#{HEADER_RE}$/i,
-        /^(Envelope-To):#{HEADER_RE}$/i: header[last = $1] ||= $2
-
-      when /^(From):#{HEADER_RE}$/i,
-        /^(To):#{HEADER_RE}$/i,
-        /^(Cc):#{HEADER_RE}$/i,
-        /^(Bcc):#{HEADER_RE}$/i,
-        /^(Subject):#{HEADER_RE}$/i,
-        /^(Date):#{HEADER_RE}$/i,
-        /^(References):#{HEADER_RE}$/i,
-        /^(In-Reply-To):#{HEADER_RE}$/i,
-        /^(Reply-To):#{HEADER_RE}$/i,
-        /^(List-Post):#{HEADER_RE}$/i,
-        /^(List-Subscribe):#{HEADER_RE}$/i,
-        /^(List-Unsubscribe):#{HEADER_RE}$/i,
-        /^(Status):#{HEADER_RE}$/i,
-        /^(X-\S+):#{HEADER_RE}$/: header[last = $1] = $2
-      when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
-
-      when /^\r*$/: break
-      when /^\S+:/: last = nil # some other header we don't care about
-      else
-        header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
-      end
-    end
-
-    if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
-      header[mid_field] = $1
-    end
-
-    header.each do |k, v|
-      next unless Rfc2047.is_encoded? v
-      header[k] =
-        begin
-          Rfc2047.decode_to $encoding, v
-        rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
-          Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
-          v
-        end
-    end
-    header
-  end
-  
-  ## never actually called
-  def read_body f
-    body = []
-    f.each_line do |l|
-      break if is_break_line?(l)
-      body << l.chomp
-    end
-    body
-  end
-
-  module_function :read_header, :read_body
 end
 end
index 57d983db1660340a80fd08074313ee1bd29e02ad..ebb2aed675e171397b99e877023e24611cf119c7 100644 (file)
@@ -59,7 +59,7 @@ class Loader < Source
       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
@@ -80,12 +80,24 @@ class Loader < Source
     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
index 3c21a437dc664a6f6090630cdb24177389c28535..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,49 +60,42 @@ class Message
     ## why.
     @refs = []
 
-    parse_header(opts[:header] || @source.load_header(@source_info))
+    #parse_header(opts[:header] || @source.load_header(@source_info))
   end
 
   def parse_header header
-    header.keys.each { |k| header[k.downcase] = header[k] } # canonicalize
-
-    fakeid = nil
-    fakename = nil
-
-    @id =
-      if header["message-id"]
-        sanitize_message_id header["message-id"]
-      else
-        fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
-      end
+    @id = if header["message-id"]
+      mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
+      sanitize_message_id mid
+    else
+      id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
+      from = header["from"]
+      #Redwood::log "faking non-existent message-id for message from #{from}: #{id}"
+      id
+    end
     
-    @from =
-      if header["from"]
-        Person.from_address header["from"]
-      else
-        fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
-        Person.from_address fakename
-      end
-
-    Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
-    Redwood::log "faking from for message #@id: #{fakename}" if fakename
-
-    date = header["date"]
-    @date =
-      case date
-      when Time
-        date
-      when String
-        begin
-          Time.parse date
-        rescue ArgumentError => e
-          Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
-          Time.now
-        end
-      else
-        Redwood::log "faking date header for #{@id}"
+    @from = Person.from_address(if header["from"]
+      header["from"]
+    else
+      name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
+      #Redwood::log "faking non-existent sender for message #@id: #{name}"
+      name
+    end)
+
+    @date = case(date = header["date"])
+    when Time
+      date
+    when String
+      begin
+        Time.parse date
+      rescue ArgumentError => e
+        #Redwood::log "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})"
         Time.now
       end
+    else
+      #Redwood::log "faking non-existent date header for #{@id}"
+      Time.now
+    end
 
     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
     @to = Person.from_address_list header["to"]
@@ -130,7 +123,6 @@ class Message
     @list_subscribe = header["list-subscribe"]
     @list_unsubscribe = header["list-unsubscribe"]
   end
-  private :parse_header
 
   def add_ref ref
     @refs << ref
@@ -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
 
@@ -424,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
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 31aa8972766c34d17ae8f9cda5379b5363af559f..c91827938bbbbe0ef104d48cf02ff924af9e10f2 100644 (file)
@@ -12,7 +12,7 @@ class EditMessageMode < LineCursorMode
 
   FORCE_HEADERS = %w(From To Cc Bcc Subject)
   MULTI_HEADERS = %w(To Cc Bcc)
-  NON_EDITABLE_HEADERS = %w(Message-Id Date)
+  NON_EDITABLE_HEADERS = %w(Message-id Date)
 
   HookManager.register "signature", <<EOS
 Generates a message signature.
@@ -145,6 +145,8 @@ EOS
     !edited? || BufferManager.ask_yes_or_no("Discard message?")
   end
 
+  def unsaved?; edited? end
+
   def attach_file
     fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
     return unless fn
@@ -212,7 +214,7 @@ protected
 
   def parse_file fn
     File.open(fn) do |f|
-      header = MBox::read_header f
+      header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
       body = f.readlines.map { |l| l.chomp }
 
       header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
@@ -390,7 +392,7 @@ protected
 
       contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
       if contacts
-        text = contacts.map { |s| s.longname }.join(", ")
+        text = contacts.map { |s| s.full_address }.join(", ")
         @header[field] = parse_header field, text
         update
       end
index 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 b1c69fc1dc2f35fe1eea2350b21873e6c08470e9..e527542bd8266a6c0a38666955a2434f98d13516 100644 (file)
@@ -11,6 +11,8 @@ class ResumeMode < EditMessageMode
     super :header => header, :body => body, :have_signature => true
   end
 
+  def unsaved?; !@safe end
+
   def killable?
     return true if @safe
 
index cd9a0277689a9debba64003332345adc96d530fb..56dcdff2df5f139c0129c984b0f02696d6d2cd70 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", '#'
   end
 
@@ -79,6 +79,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
@@ -753,7 +754,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 220fee38c6acf8f1b9a8435046a4b40af50873b0..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
 
@@ -97,13 +97,13 @@ EOS
         numi = 0
         add_messages_from source do |m, offset, entry|
           ## always preserve the labels on disk.
-          m.labels = ((m.labels - [:unread, :inbox]) + entry[:label].split(/\s+/).map { |x| x.intern }).uniq 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 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 dbcffcc5951356dec2c533393ba100b4071b8eac..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
index e0fbf812527acb8536d3a3bb465f6c53ea8a0519..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
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 dbff2e2..0000000
+++ /dev/null
@@ -1,168 +0,0 @@
-#!/usr/bin/ruby
-
-require 'test/unit'
-require 'sup'
-require 'stringio'
-
-include Redwood
-
-class TestMBoxParsing < Test::Unit::TestCase
-  def setup
-  end
-
-  def teardown
-  end
-
-  def test_normal_headers
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-To: Sally <sally@sally.com>
-EOS
-
-    assert_equal "Bob <bob@bob.com>", h["From"]
-    assert_equal "Sally <sally@sally.com>", h["To"]
-    assert_nil h["Message-Id"]
-  end
-
-  ## this is shitty behavior in retrospect, but it's built in now.
-  def test_message_id_stripping
-    h = MBox.read_header StringIO.new("Message-Id: <one@bob.com>\n")
-    assert_equal "one@bob.com", h["Message-Id"]
-
-    h = MBox.read_header StringIO.new("Message-Id: one@bob.com\n")
-    assert_equal "one@bob.com", h["Message-Id"]
-  end
-
-  def test_multiline
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-Subject: one two three
-  four five six
-To: Sally <sally@sally.com>
-References: seven
-  eight
-Seven: Eight
-EOS
-
-    assert_equal "one two three four five six", h["Subject"]
-    assert_equal "Sally <sally@sally.com>", h["To"]
-    assert_equal "seven eight", h["References"]
-  end
-
-  def test_ignore_spacing
-    variants = [
-      "Subject:one two  three   end\n",
-      "Subject:    one two  three   end\n",
-      "Subject:   one two  three   end    \n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "one two  three   end", h["Subject"]
-    end
-  end
-
-  def test_message_id_ignore_spacing
-    variants = [
-      "Message-Id:     <one@bob.com>       \n",
-      "Message-Id:      one@bob.com        \n",
-      "Message-Id:<one@bob.com>       \n",
-      "Message-Id:one@bob.com       \n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "one@bob.com", h["Message-Id"]
-    end
-  end
-
-  def test_blank_lines
-    h = MBox.read_header StringIO.new("")
-    assert_equal nil, h["Message-Id"]
-  end
-
-  def test_empty_headers
-    variants = [
-      "Message-Id:       \n",
-      "Message-Id:\n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "", h["Message-Id"]
-    end
-  end
-
-  def test_detect_end_of_headers
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-
-  h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-
-  h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r\n\r
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-  end
-
-  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
index e38ac5064ca999f8b95b6d70c93f9b05ed8796f4..0a7db454febd8c93238dacaa89590f6110222cc6 100644 (file)
@@ -511,7 +511,7 @@ EOS
 
     # Look at another header field whose first line was blank.
     list_unsubscribe = sup_message.list_unsubscribe
-    assert_equal(" <http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
+    assert_equal("<http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
                  "<mailto:monitor-list-request@widget.com?subject=unsubscribe>",
                  list_unsubscribe)