sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 1af11cb1d6f6686bc0b4cd1b416b411c2c9dafb4
parent 4bf989f99f4edf59b26b0a72ca5345895acb1018
Author: William Morgan <wmorgan-sup@masanjin.net>
Date:   Tue, 22 Jan 2008 12:21:46 -0800

Merge branch 'quote-detection'

Diffstat:
M lib/sup/imap.rb | 85 ++++++++++++++++++++++++++++++++++++++++---------------------------------------
M lib/sup/message-chunks.rb | 1 +
M lib/sup/message.rb | 23 +++++++++++++----------
M lib/sup/modes/reply-mode.rb | 15 ++++++++++++++-
M lib/sup/modes/scroll-mode.rb | 3 ++-
M lib/sup/modes/text-mode.rb | 5 +++--
M lib/sup/modes/thread-index-mode.rb | 2 +-
M lib/sup/modes/thread-view-mode.rb | 48 +++++++++++++++++++++++++++++++++---------------
8 files changed, 110 insertions(+), 72 deletions(-)
diff --git a/lib/sup/imap.rb b/lib/sup/imap.rb
@@ -5,39 +5,39 @@ require 'time'
 require 'rmail'
 require 'cgi'
 
-## fucking imap fucking sucks. what the FUCK kind of committee of
-## dunces designed this shit.
+## fucking imap fucking sucks. what the FUCK kind of committee of dunces
+## designed this shit.
 ##
 ## imap talks about 'unique ids' for messages, to be used for
-## cross-session identification. great---just what sup needs! except
-## it turns out the uids can be invalidated every time the
-## 'uidvalidity' value changes on the server, and 'uidvalidity' can
-## change without 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 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", guys!
+## cross-session identification. great---just what sup needs! except it
+## turns out the uids can be invalidated every time the 'uidvalidity'
+## value changes on the server, and 'uidvalidity' can change without
+## 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
+## 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",
+## guys!
 ##
 ## so right now i'm using the 'internal date' and the size of each
 ## message to uniquely identify it, and i scan over the entire mailbox
 ## each time i open it to map those things to message ids. that can be
-## slow for large mailboxes, and we'll just have to hope that there
-## are no collisions. ho ho! a perfectly reasonable solution!
+## slow for large mailboxes, and we'll just have to hope that there are
+## no collisions. ho ho! a perfectly reasonable solution!
 ##
 ## and here's another thing. check out RFC2060 2.2.2 paragraph 5:
 ##
-##   A client MUST be prepared to accept any server response at all times.
-##   This includes server data that was not requested.
+##   A client MUST be prepared to accept any server response at all
+##   times.  This includes server data that was not requested.
 ##
-## yeah. that totally makes a lot of sense. and once again, the idiocy
-## of the spec actually happens in practice. you'll request flags for
-## one message, and get it interspersed with a random bunch of flags
-## for some other messages, including a different set of flags for the
-## same message! totally ok by the imap spec. totally retarded by any
-## other metric.
+## yeah. that totally makes a lot of sense. and once again, the idiocy of
+## the spec actually happens in practice. you'll request flags for one
+## message, and get it interspersed with a random bunch of flags for some
+## other messages, including a different set of flags for the same
+## message! totally ok by the imap spec. totally retarded by any other
+## metric.
 ##
 ## fuck you, imap committee. you managed to design something nearly as
 ## shitty as mbox but goddamn THIRTY YEARS LATER.
@@ -205,11 +205,11 @@ private
   def unsafe_connect
     say "Connecting to IMAP server #{host}:#{port}..."
 
-    ## apparently imap.rb does a lot of threaded stuff internally and
-    ## if an exception occurs, it will catch it and re-raise it on the
-    ## calling thread. but i can't seem to catch that exception, so
-    ## i've resorted to initializing it in its own thread. surely
-    ## there's a better way.
+    ## apparently imap.rb does a lot of threaded stuff internally and if
+    ## an exception occurs, it will catch it and re-raise it on the
+    ## calling thread. but i can't seem to catch that exception, so i've
+    ## resorted to initializing it in its own thread. surely there's a
+    ## better way.
     exception = nil
     ::Thread.new do
       begin
@@ -217,9 +217,9 @@ private
         @imap = Net::IMAP.new host, port, ssl?
         say "Logging in..."
 
-        ## although RFC1730 claims that "If an AUTHENTICATE command
-        ## fails with a NO response, the client may try another", in
-        ## practice it seems like they can also send a BAD response.
+        ## although RFC1730 claims that "If an AUTHENTICATE command fails
+        ## with a NO response, the client may try another", in practice
+        ## it seems like they can also send a BAD response.
         begin
           raise Net::IMAP::NoResponseError unless @imap.capability().member? "AUTH=CRAM-MD5"
           @imap.authenticate 'CRAM-MD5', @username, @password
@@ -271,19 +271,20 @@ private
     result = fetch(imap_id, (fields + ['RFC822.SIZE', 'INTERNALDATE']).uniq).first
     got_id = make_id result
 
-    ## I've turned off the following sanity check because Microsoft Exchange fails it.
-    ## Exchange actually reports two different INTERNALDATEs for the exact same message
-    ## when queried at different points in time.
+    ## I've turned off the following sanity check because Microsoft
+    ## Exchange fails it.  Exchange actually reports two different
+    ## INTERNALDATEs for the exact same message when queried at different
+    ## points in time.
     ##
-    ##
-    ## RFC2060 defines the semantics of INTERNALDATE for messages that arrive
-    ## via SMTP for via various IMAP commands, but states that "All other
-    ## cases are implementation defined.". Great, thanks guys, yet another
-    ## useless field.
+    ## RFC2060 defines the semantics of INTERNALDATE for messages that
+    ## arrive via SMTP for via various IMAP commands, but states that
+    ## "All other cases are implementation defined.". Great, thanks guys,
+    ## yet another useless field.
     ## 
-    ## Of course no OTHER imap server I've encountered returns DIFFERENT values for
-    ## the SAME message. But it's Microsoft; what do you expect? If their programmers
-    ## were any good they'd be working at Google.
+    ## Of course no OTHER imap server I've encountered returns DIFFERENT
+    ## values for the SAME message. But it's Microsoft; what do you
+    ## expect? If their programmers were any good they'd be working at
+    ## Google.
 
     # raise OutOfSyncSourceError, "IMAP message mismatch: requested #{id}, got #{got_id}." unless got_id == id
 
diff --git a/lib/sup/message-chunks.rb b/lib/sup/message-chunks.rb
@@ -82,6 +82,7 @@ EOS
                           :sibling_types => sibling_types
         end
 
+      @lines = nil
       if text
         @lines = text.gsub("\r\n", "\n").gsub(/\t/, "        ").gsub(/\r/, "").split("\n")
         @quotable = true
diff --git a/lib/sup/message.rb b/lib/sup/message.rb
@@ -29,7 +29,7 @@ class Message
 
   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
-  QUOTE_START_PATTERN = /(^\s*Excerpts from)|(^\s*In message )|(^\s*In article )|(^\s*Quoting )|((wrote|writes|said|says)\s*:\s*$)/
+  QUOTE_START_PATTERN = /\w.*:$/
   SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
 
   MAX_SIG_DISTANCE = 15 # lines from the end
@@ -60,25 +60,28 @@ class Message
 
   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
-        returning("sup-faked-" + Digest::MD5.hexdigest(raw_header)) do |id|
-          Redwood::log "faking message-id for message from #@from: #{id}"
-        end
+        fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
       end
     
     @from =
       if header["from"]
         PersonManager.person_for header["from"]
       else
-        name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
-        Redwood::log "faking from for message #@id: #{name}"
-        PersonManager.person_for name
+        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
@@ -417,7 +420,7 @@ private
       when :text
         newstate = nil
 
-        if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && (nextline =~ QUOTE_PATTERN || nextline =~ QUOTE_START_PATTERN))
+        if line =~ QUOTE_PATTERN || (line =~ QUOTE_START_PATTERN && nextline =~ QUOTE_PATTERN)
           newstate = :quote
         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
           newstate = :sig
@@ -436,7 +439,7 @@ private
       when :quote
         newstate = nil
 
-        if line =~ QUOTE_PATTERN || line =~ QUOTE_START_PATTERN #|| line =~ /^\s*$/
+        if line =~ QUOTE_PATTERN || (line =~ /^\s*$/ && nextline =~ QUOTE_PATTERN)
           chunk_lines << line
         elsif line =~ SIG_PATTERN && (lines.length - i) < MAX_SIG_DISTANCE
           newstate = :sig
diff --git a/lib/sup/modes/reply-mode.rb b/lib/sup/modes/reply-mode.rb
@@ -10,6 +10,14 @@ class ReplyMode < EditMessageMode
     :user => "Customized"
   }
 
+  HookManager.register "quoteline", <<EOS
+Generates a quote line "On 1/4/2007, Joe Bloggs wrote:".
+Variables:
+      message: A message object representing the message being replied to
+Return value:
+  A string containing the text of the quote line (can be multi-line)
+EOS
+
   def initialize message
     @m = message
 
@@ -115,11 +123,16 @@ protected
   end
 
   def reply_body_lines m
-    lines = ["Excerpts from #{@m.from.name}'s message of #{@m.date}:"] + m.quotable_body_lines.map { |l| "> #{l}" }
+    quoteline = HookManager.run("quoteline", :message => m) || default_quoteline(m)
+    lines = quoteline.split("\n") + m.quotable_body_lines.map { |l| "> #{l}" }
     lines.pop while lines.last =~ /^\s*$/
     lines
   end
 
+  def default_quoteline m
+    "Excerpts from #{@m.from.name}'s message of #{@m.date}:"
+  end
+
   def handle_new_text new_header, new_body
     old_header = @headers[@type_selector.val]
     if new_header.size != old_header.size || old_header.any? { |k, v| new_header[k] != v }
diff --git a/lib/sup/modes/scroll-mode.rb b/lib/sup/modes/scroll-mode.rb
@@ -12,7 +12,7 @@ class ScrollMode < Mode
 
   attr_reader :status, :topline, :botline, :leftcol
 
-  COL_JUMP = 2
+  COL_JUMP = 4
 
   register_keymap do |k|
     k.add :line_down, "Down one line", :down, 'j', 'J'
@@ -101,6 +101,7 @@ class ScrollMode < Mode
   end
 
   def jump_to_col col
+    col = col - (col % COL_JUMP)
     buffer.mark_dirty unless @leftcol == col
     @leftcol = col
   end
diff --git a/lib/sup/modes/text-mode.rb b/lib/sup/modes/text-mode.rb
@@ -7,15 +7,16 @@ class TextMode < ScrollMode
     k.add :pipe, "Pipe to process", '|'
   end
 
-  def initialize text=""
+  def initialize text="", filename=nil
     @text = text
+    @filename = filename
     update_lines
     buffer.mark_dirty if buffer
     super()
   end
   
   def save_to_disk
-    fn = BufferManager.ask_for_filename :filename, "Save to file: "
+    fn = BufferManager.ask_for_filename :filename, "Save to file: ", @filename
     save_to_file(fn) { |f| f.puts text } if fn
   end
 
diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb
@@ -91,7 +91,7 @@ EOS
       mode = ThreadViewMode.new t, @hidden_labels, self
       BufferManager.spawn t.subj, mode
       BufferManager.draw_screen
-      mode.jump_to_first_open
+      mode.jump_to_first_open true
       BufferManager.draw_screen # lame TODO: make this unnecessary
       ## the first draw_screen is needed before topline and botline
       ## are set, and the second to show the cursor having moved
diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread-view-mode.rb
@@ -34,6 +34,7 @@ EOS
     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'
     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
+    k.add :align_current_message, "Align current message in buffer", 'z'
     k.add :toggle_starred, "Star or unstar message", '*'
     k.add :toggle_new, "Toggle unread/read status of message", 'N'
 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
@@ -274,27 +275,34 @@ EOS
     end
   end
 
-  def jump_to_first_open
+  def jump_to_first_open loose_alignment=false
     m = @message_lines[0] or return
     if @layout[m].state != :closed
-      jump_to_message m
+      jump_to_message m, loose_alignment
     else
-      jump_to_next_open
+      jump_to_next_open loose_alignment
     end
   end
 
-  def jump_to_next_open
+  def jump_to_next_open loose_alignment=false
     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
-    m = @message_lines[curpos] or return
+    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+    return unless m
     while nextm = @layout[m].next
       break if @layout[nextm].state != :closed
       m = nextm
     end
-    jump_to_message nextm if nextm
+    jump_to_message nextm, loose_alignment if nextm
   end
 
-  def jump_to_prev_open
+  def align_current_message
     m = @message_lines[curpos] or return
+    jump_to_message m
+  end
+
+  def jump_to_prev_open loose_alignment=false
+    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
+    return unless m
     ## jump to the top of the current message if we're in the body;
     ## otherwise, to the previous message
     
@@ -304,22 +312,32 @@ EOS
         break if @layout[prevm].state != :closed
         m = prevm
       end
-      jump_to_message prevm if prevm
+      jump_to_message prevm, loose_alignment if prevm
     else
-      jump_to_message m
+      jump_to_message m, loose_alignment
     end
   end
 
-  def jump_to_message m
+  def jump_to_message m, loose_alignment=false
     l = @layout[m]
     left = l.depth * INDENT_SPACES
     right = left + l.width
 
-    ## jump to the top line unless both top and bottom fit in the current view
-    jump_to_line l.top unless l.top >= topline && l.top <= botline && l.bot >= topline && l.bot <= botline
+    ## jump to the top line
+    if loose_alignment
+      jump_to_line [l.top - 3, 0].max # give 3 lines of top context
+    else
+      jump_to_line l.top
+    end
 
-    ## jump to the left columns unless both left and right fit in the current view
-    jump_to_col left unless left >= leftcol && left <= rightcol && right >= leftcol && right <= rightcol
+    ## jump to the left column
+    if loose_alignment
+      ## try and give 4 columns of left context, but not if it means that
+      ## the right of the message is truncated.
+      jump_to_col [[left - 4, rightcol - l.width - 1].min, 0].max
+    else
+      jump_to_col left
+    end
 
     ## either way, move the cursor to the first line
     set_cursor_pos l.top
@@ -629,7 +647,7 @@ private
     BufferManager.erase_flash
     BufferManager.completely_redraw_screen
     unless success
-      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s)
+      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s, chunk.filename)
       BufferManager.flash "Couldn't execute view command, viewing as text."
     end
   end