lib/sup/tagger.rb
lib/sup/textfield.rb
lib/sup/thread.rb
+lib/sup/undo.rb
lib/sup/update.rb
lib/sup/util.rb
- Unix-centrism in MIME attachment handling and in sendmail
invocation.
-- Several obvious missing features, like undo, filters / saved
+- Several obvious missing features, like filters / saved
searches, message annotations, etc.
== SYNOPSYS:
## nothin! use default source labels
end
+ 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.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} from #{m.from} with state {#{m.labels * ', '}}" if opts[:verbose]
num_added += 1
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
+ docs = Redwood::Index.run_query query
+ num_total = docs.size
$stderr.puts "Found #{num_total} documents across #{source_ids.length} sources. Scanning..."
num_changed = num_scanned = 0
last_info_time = start_time = Time.now
- results.hits.each do |hit|
+ docs.each do |id|
num_scanned += 1
- id = hit.doc
m = index.build_message id
old_labels = m.labels.clone
Redwood::PollManager.new
Redwood::SuicideManager.new Redwood::SUICIDE_FN
Redwood::CryptoManager.new
+ Redwood::UndoManager.new
end
def finish
require "sup/draft"
require "sup/poll"
require "sup/crypto"
+require "sup/undo"
require "sup/horizontal-selector"
require "sup/modes/line-cursor-mode"
require "sup/modes/help-mode"
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"] },
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
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
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,
: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")
"references" => doc[:refs].split.map { |x| "<#{x}>" }.join(" "),
}
- Message.new :source => source, :source_info => doc[:source_info].to_i,
+ m = Message.new :source => source, :source_info => doc[:source_info].to_i,
:labels => doc[:label].symbolistize,
- :snippet => doc[:snippet], :header => fake_header
+ :snippet => doc[:snippet]
+ m.parse_header fake_header
+ m
end
end
@index_mutex.synchronize { @index.search(q, :limit => 1).total_hits > 0 }
end
+ ## takes a user query string and returns the list of docids for messages
+ ## that match the query.
+ ##
+ ## messages can then be loaded from the index with #build_message.
+ ##
+ ## raises a ParseError if the parsing failed.
+ def run_query query
+ qobj, opts = Redwood::Index.parse_user_query_string query
+ query = Redwood::Index.build_query opts.merge(:qobj => qobj)
+ results = @index.search query, :limit => (opts[:limit] || :all)
+ results.hits.map { |hit| hit.doc }
+ end
+
protected
- ## do any specialized parsing
- ## returns nil and flashes error message if parsing failed
+ class ParseError < StandardError; end
+
+ ## parse a query string from the user. returns a query object and a set of
+ ## extra flags; both of these are meant to be passed to #build_query.
+ ##
+ ## raises a ParseError if something went wrong.
def parse_user_query_string s
extraopts = {}
end
if $have_chronic
- chronic_failure = false
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 => :past)
+ realdate = Chronic.parse datestr, :guess => false, :context => :past
if realdate
case field
when "after"
"date:(<= #{sprintf "%012d", realdate.end.to_i}) date:(>= #{sprintf "%012d", realdate.begin.to_i})"
end
else
- BufferManager.flash "Can't understand date #{datestr.inspect}!"
- chronic_failure = true
+ raise ParseError, "can't understand date #{datestr.inspect}"
end
end
- subs = nil if chronic_failure
end
## limit:42 restrict the search to 42 results
extraopts[:limit] = lim.to_i
''
else
- BufferManager.flash "Can't understand limit #{lim.inspect}!"
- subs = nil
+ raise ParseError, "non-numeric limit #{lim.inspect}"
end
end
- if subs
+ begin
[@qparser.parse(subs), extraopts]
- else
- nil
+ rescue Ferret::QueryParser::QueryParseException => e
+ raise ParseError, e.message
end
end
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
module Redwood
-## some utility functions. actually these are not mbox-specific at all
-## and should be moved somewhere else.
-##
-## TODO: move functionality to somewhere better, like message.rb
module MBox
- BREAK_RE = /^From \S+/
- HEADER_RE = /\s*(.*?)\s*/
-
- def read_header f
- header = {}
- last = nil
-
- ## i do it in this weird way because i am trying to speed things up
- ## when scanning over large mbox files.
- while(line = f.gets)
- case line
- ## these three can occur multiple times, and we want the first one
- when /^(Delivered-To):#{HEADER_RE}$/i,
- /^(X-Original-To):#{HEADER_RE}$/i,
- /^(Envelope-To):#{HEADER_RE}$/i: header[last = $1] ||= $2
-
- when /^(From):#{HEADER_RE}$/i,
- /^(To):#{HEADER_RE}$/i,
- /^(Cc):#{HEADER_RE}$/i,
- /^(Bcc):#{HEADER_RE}$/i,
- /^(Subject):#{HEADER_RE}$/i,
- /^(Date):#{HEADER_RE}$/i,
- /^(References):#{HEADER_RE}$/i,
- /^(In-Reply-To):#{HEADER_RE}$/i,
- /^(Reply-To):#{HEADER_RE}$/i,
- /^(List-Post):#{HEADER_RE}$/i,
- /^(List-Subscribe):#{HEADER_RE}$/i,
- /^(List-Unsubscribe):#{HEADER_RE}$/i,
- /^(Status):#{HEADER_RE}$/i,
- /^(X-\S+):#{HEADER_RE}$/: header[last = $1] = $2
- when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
-
- when /^\r*$/: break
- when /^\S+:/: last = nil # some other header we don't care about
- else
- header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
- end
- end
-
- if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
- header[mid_field] = $1
+ 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
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
@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
@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
@mutex.synchronize do
@f.seek cur_offset
string = ""
- until @f.eof? || (l = @f.gets) =~ BREAK_RE
+ until @f.eof? || MBox::is_break_line?(l = @f.gets)
string << l
end
self.cur_offset += string.length
@mutex.synchronize do
@f.seek offset
until @f.eof? || (l = @f.gets) =~ /^\r*$/
- ret += l
+ ret << l
end
end
ret
def raw_message offset
ret = ""
- each_raw_message_line(offset) { |l| ret += l }
+ each_raw_message_line(offset) { |l| ret << l }
ret
end
@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
## 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
end
while(line = @f.gets)
- break if line =~ BREAK_RE
+ break if MBox::is_break_line? line
next_offset = @f.tell
end
end
## 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"]
@list_subscribe = header["list-subscribe"]
@list_unsubscribe = header["list-unsubscribe"]
end
- private :parse_header
def add_ref ref
@refs << ref
## 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
[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 =
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.
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 }
def archive
return unless cursor_thread
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+ UndoManager.register "archiving thread" do
+ thread.apply_label :inbox
+ add_or_unhide thread.first
+ end
+
cursor_thread.remove_label :inbox
hide_thread cursor_thread
regen_text
end
def multi_archive threads
+ UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
+ threads.map do |t|
+ t.apply_label :inbox
+ add_or_unhide t.first
+ end
+ regen_text
+ end
+
threads.each do |t|
t.remove_label :inbox
hide_thread t
def read_and_archive
return unless cursor_thread
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+ UndoManager.register "reading and archiving thread" do
+ thread.apply_label :inbox
+ thread.apply_label :unread
+ add_or_unhide thread.first
+ end
+
cursor_thread.remove_label :unread
cursor_thread.remove_label :inbox
hide_thread cursor_thread
end
def multi_read_and_archive threads
+ old_labels = threads.map { |t| t.labels.dup }
+
threads.each do |t|
t.remove_label :unread
t.remove_label :inbox
hide_thread t
end
regen_text
+
+ UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
+ threads.zip(old_labels).each do |t, l|
+ t.labels = l
+ add_or_unhide t.first
+ end
+ regen_text
+ end
+
end
def handle_unarchived_update sender, m
mode = SearchResultsMode.new qobj, extraopts
BufferManager.spawn "search: \"#{short_text}\"", mode
mode.load_threads :num => mode.buffer.content_height
- rescue Ferret::QueryParser::QueryParseException => e
- BufferManager.flash "Couldn't parse query."
+ rescue Index::ParseError => e
+ BufferManager.flash "Problem: #{e.message}!"
end
end
end
k.add :tag_matching, "Tag matching threads", 'g'
k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+ k.add :undo, "Undo the previous action", 'u'
end
def initialize hidden_labels=[], load_thread_opts={}
def reload
drop_all_threads
+ UndoManager.clear
BufferManager.draw_screen
load_threads :num => buffer.content_height
end
add_or_unhide m
end
+ def undo
+ UndoManager.undo
+ end
+
def update
@mutex.synchronize do
## let's see you do THIS in python
end
end
+ ## returns an undo lambda
def actually_toggle_starred t
+ pos = curpos
if t.has_label? :starred # if ANY message has a star
t.remove_label :starred # remove from all
UpdateManager.relay self, :unstarred, t.first
+ lambda do
+ t.first.add_label :starred
+ UpdateManager.relay self, :starred, t.first
+ regen_text
+ end
else
t.first.add_label :starred # add only to first
UpdateManager.relay self, :starred, t.first
+ lambda do
+ t.remove_label :starred
+ UpdateManager.relay self, :unstarred, t.first
+ regen_text
+ end
end
end
def toggle_starred
t = cursor_thread or return
- actually_toggle_starred t
+ undo = actually_toggle_starred t
+ UndoManager.register "toggling thread starred status", undo
update_text_for_line curpos
cursor_down
end
def multi_toggle_starred threads
- threads.each { |t| actually_toggle_starred t }
+ UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
+ threads.map { |t| actually_toggle_starred t }
regen_text
end
+ ## returns an undo lambda
def actually_toggle_archived t
+ thread = t
+ pos = curpos
if t.has_label? :inbox
t.remove_label :inbox
UpdateManager.relay self, :archived, t.first
+ lambda do
+ thread.apply_label :inbox
+ update_text_for_line pos
+ UpdateManager.relay self,:unarchived, thread.first
+ end
else
t.apply_label :inbox
UpdateManager.relay self, :unarchived, t.first
+ lambda do
+ thread.remove_label :inbox
+ update_text_for_line pos
+ UpdateManager.relay self, :unarchived, thread.first
+ end
end
end
+ ## returns an undo lambda
def actually_toggle_spammed t
+ thread = t
if t.has_label? :spam
t.remove_label :spam
+ add_or_unhide t.first
UpdateManager.relay self, :unspammed, t.first
+ lambda do
+ thread.apply_label :spam
+ self.hide_thread thread
+ UpdateManager.relay self,:spammed, thread.first
+ end
else
t.apply_label :spam
+ hide_thread t
UpdateManager.relay self, :spammed, t.first
+ lambda do
+ thread.remove_label :spam
+ add_or_unhide thread.first
+ UpdateManager.relay self,:unspammed, thread.first
+ end
end
end
+ ## returns an undo lambda
def actually_toggle_deleted t
if t.has_label? :deleted
t.remove_label :deleted
+ add_or_unhide t.first
UpdateManager.relay self, :undeleted, t.first
+ lambda do
+ t.apply_label :deleted
+ hide_thread t
+ UpdateManager.relay self, :deleted, t.first
+ end
else
t.apply_label :deleted
+ hide_thread t
UpdateManager.relay self, :deleted, t.first
+ lambda do
+ t.remove_label :deleted
+ add_or_unhide t.first
+ UpdateManager.relay self, :undeleted, t.first
+ end
end
end
def toggle_archived
t = cursor_thread or return
- actually_toggle_archived t
+ undo = actually_toggle_archived t
+ UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos }
update_text_for_line curpos
end
def multi_toggle_archived threads
- threads.each { |t| actually_toggle_archived t }
+ undos = threads.map { |t| actually_toggle_archived t }
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }
regen_text
end
## see deleted or spam emails, and when you undelete or unspam them
## you also want them to disappear immediately.
def multi_toggle_spam threads
- threads.each do |t|
- actually_toggle_spammed t
- hide_thread t
- end
+ undos = threads.map { |t| actually_toggle_spammed t }
+ UndoManager.register "marking/unmarking #{threads.size.pluralize 'thread'} as spam",
+ undos, lambda { regen_text }
regen_text
end
## see comment for multi_toggle_spam
def multi_toggle_deleted threads
- threads.each do |t|
- actually_toggle_deleted t
- hide_thread t
- end
+ undos = threads.map { |t| actually_toggle_deleted t }
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
+ undos, lambda { regen_text }
regen_text
end
multi_kill [t]
end
+ ## m-m-m-m-MULTI-KILL
def multi_kill threads
+ UndoManager.register "killing #{threads.size.pluralize 'thread'}" do
+ threads.each do |t|
+ t.remove_label :killed
+ add_or_unhide t.first
+ end
+ regen_text
+ end
+
threads.each do |t|
t.apply_label :killed
hide_thread t
end
+
regen_text
- BufferManager.flash "#{threads.size.pluralize 'Thread'} killed."
+ BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
end
def save background=true
def edit_labels
thread = cursor_thread or return
speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+ old_labels = thread.labels
+ pos = curpos
+
keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
thread.labels = keepl + user_labels
user_labels.each { |l| LabelManager << l }
update_text_for_line curpos
+
+ UndoManager.register "labeling thread #{thread.first.id}" do
+ thread.labels = old_labels
+ update_text_for_line pos
+ UpdateManager.relay self, :labeled, thread.first
+ end
+
UpdateManager.relay self, :labeled, thread.first
end
user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
- if hl.empty?
- threads.each do |t|
- user_labels.each do |(l, to_remove)|
- if to_remove
- t.remove_label l
- else
- t.apply_label l
- end
+ unless hl.empty?
+ BufferManager.flash "'#{hl}' is a reserved label!"
+ return
+ end
+
+ old_labels = threads.map { |t| t.labels.dup }
+
+ threads.each do |t|
+ user_labels.each do |(l, to_remove)|
+ if to_remove
+ t.remove_label l
+ else
+ t.apply_label l
end
+ LabelManager << l
end
- user_labels.each { |(l,_)| LabelManager << l }
- else
- BufferManager.flash "'#{hl}' is a reserved label!"
+ UpdateManager.relay self, :labeled, t.first
end
+
regen_text
+
+ UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
+ threads.zip(old_labels).map do |t, old_labels|
+ t.labels = old_labels
+ UpdateManager.relay self, :labeled, t.first
+ end
+ regen_text
+ end
end
def reply
date = t.date.to_nice_s
- starred = t.has_label?(:starred)
+ starred = t.has_label? :starred
## format the from column
cur_width = 0
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
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
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}"
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("~")
--- /dev/null
+module Redwood
+
+## Implements a single undo list for the Sup instance
+##
+## The basic idea is to keep a list of lambdas to undo
+## things. When an action is called (such as 'archive'),
+## a lambda is registered with UndoManager that will
+## undo the archival action
+
+class UndoManager
+ include Singleton
+
+ def initialize
+ @@actionlist = []
+ self.class.i_am_the_instance self
+ end
+
+ def register desc, *actions, &b
+ actions = [*actions.flatten]
+ actions << b if b
+ raise ArgumentError, "need at least one action" unless actions.length > 0
+ @@actionlist.push :desc => desc, :actions => actions
+ end
+
+ def undo
+ unless @@actionlist.empty?
+ actionset = @@actionlist.pop
+ actionset[:actions].each { |action| action.call }
+ BufferManager.flash "undid #{actionset[:desc]}"
+ else
+ BufferManager.flash "nothing more to undo!"
+ end
+ end
+
+ def clear
+ @@actionlist = []
+ end
+end
+end
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
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
--- /dev/null
+#!/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
+++ /dev/null
-#!/usr/bin/ruby
-
-require 'test/unit'
-require 'sup'
-require 'stringio'
-
-include Redwood
-
-class TestMBoxParsing < Test::Unit::TestCase
- def setup
- end
-
- def teardown
- end
-
- def test_normal_headers
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-To: Sally <sally@sally.com>
-EOS
-
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_equal "Sally <sally@sally.com>", h["To"]
- assert_nil h["Message-Id"]
- end
-
- ## this is shitty behavior in retrospect, but it's built in now.
- def test_message_id_stripping
- h = MBox.read_header StringIO.new("Message-Id: <one@bob.com>\n")
- assert_equal "one@bob.com", h["Message-Id"]
-
- h = MBox.read_header StringIO.new("Message-Id: one@bob.com\n")
- assert_equal "one@bob.com", h["Message-Id"]
- end
-
- def test_multiline
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-Subject: one two three
- four five six
-To: Sally <sally@sally.com>
-References: seven
- eight
-Seven: Eight
-EOS
-
- assert_equal "one two three four five six", h["Subject"]
- assert_equal "Sally <sally@sally.com>", h["To"]
- assert_equal "seven eight", h["References"]
- end
-
- def test_ignore_spacing
- variants = [
- "Subject:one two three end\n",
- "Subject: one two three end\n",
- "Subject: one two three end \n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_equal "one two three end", h["Subject"]
- end
- end
-
- def test_message_id_ignore_spacing
- variants = [
- "Message-Id: <one@bob.com> \n",
- "Message-Id: one@bob.com \n",
- "Message-Id:<one@bob.com> \n",
- "Message-Id:one@bob.com \n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_equal "one@bob.com", h["Message-Id"]
- end
- end
-
- def test_blank_lines
- h = MBox.read_header StringIO.new("")
- assert_equal nil, h["Message-Id"]
- end
-
- def test_empty_headers
- variants = [
- "Message-Id: \n",
- "Message-Id:\n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_equal "", h["Message-Id"]
- end
- end
-
- def test_detect_end_of_headers
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-
-To: a dear friend
-EOS
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_nil h["To"]
-
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r
-To: a dear friend
-EOS
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_nil h["To"]
-
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r\n\r
-To: a dear friend
-EOS
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_nil h["To"]
- end
-end
# 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)