sup 0.7 → 0.8

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sup might be problematic. Click here for more details.

Files changed (46) hide show
  1. data/CONTRIBUTORS +8 -3
  2. data/History.txt +19 -0
  3. data/README.txt +45 -44
  4. data/ReleaseNotes +6 -0
  5. data/bin/sup +36 -5
  6. data/bin/sup-add +0 -0
  7. data/bin/sup-config +0 -0
  8. data/bin/sup-dump +0 -0
  9. data/bin/sup-recover-sources +8 -12
  10. data/bin/sup-sync +22 -16
  11. data/bin/sup-sync-back +1 -1
  12. data/bin/sup-tweak-labels +8 -8
  13. data/lib/sup.rb +3 -17
  14. data/lib/sup/account.rb +2 -3
  15. data/lib/sup/buffer.rb +21 -10
  16. data/lib/sup/colormap.rb +30 -27
  17. data/lib/sup/contact.rb +1 -1
  18. data/lib/sup/draft.rb +1 -3
  19. data/lib/sup/imap.rb +1 -1
  20. data/lib/sup/index.rb +70 -48
  21. data/lib/sup/label.rb +12 -10
  22. data/lib/sup/logger.rb +1 -1
  23. data/lib/sup/maildir.rb +1 -1
  24. data/lib/sup/mbox.rb +13 -70
  25. data/lib/sup/mbox/loader.rb +26 -15
  26. data/lib/sup/message-chunks.rb +18 -6
  27. data/lib/sup/message.rb +56 -67
  28. data/lib/sup/mode.rb +2 -1
  29. data/lib/sup/modes/buffer-list-mode.rb +6 -2
  30. data/lib/sup/modes/compose-mode.rb +0 -1
  31. data/lib/sup/modes/contact-list-mode.rb +1 -1
  32. data/lib/sup/modes/edit-message-mode.rb +37 -9
  33. data/lib/sup/modes/inbox-mode.rb +34 -0
  34. data/lib/sup/modes/label-list-mode.rb +10 -3
  35. data/lib/sup/modes/reply-mode.rb +24 -13
  36. data/lib/sup/modes/resume-mode.rb +2 -0
  37. data/lib/sup/modes/scroll-mode.rb +10 -9
  38. data/lib/sup/modes/search-results-mode.rb +2 -2
  39. data/lib/sup/modes/thread-index-mode.rb +157 -38
  40. data/lib/sup/modes/thread-view-mode.rb +27 -11
  41. data/lib/sup/person.rb +22 -73
  42. data/lib/sup/poll.rb +18 -20
  43. data/lib/sup/source.rb +44 -0
  44. data/lib/sup/undo.rb +39 -0
  45. data/lib/sup/util.rb +25 -16
  46. metadata +46 -45
@@ -27,6 +27,7 @@ EOS
27
27
  register_keymap do |k|
28
28
  k.add :toggle_detailed_header, "Toggle detailed header", 'h'
29
29
  k.add :show_header, "Show full message header", 'H'
30
+ k.add :show_message, "Show full message (raw form)", 'V'
30
31
  k.add :activate_chunk, "Expand/collapse or activate item", :enter
31
32
  k.add :expand_all_messages, "Expand/collapse all messages", 'E'
32
33
  k.add :edit_draft, "Edit draft", 'e'
@@ -134,6 +135,13 @@ EOS
134
135
  end
135
136
  end
136
137
 
138
+ def show_message
139
+ m = @message_lines[curpos] or return
140
+ BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
141
+ TextMode.new m.raw_message
142
+ end
143
+ end
144
+
137
145
  def toggle_detailed_header
138
146
  m = @message_lines[curpos] or return
139
147
  @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
@@ -149,7 +157,7 @@ EOS
149
157
  def subscribe_to_list
150
158
  m = @message_lines[curpos] or return
151
159
  if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
152
- ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
160
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
153
161
  else
154
162
  BufferManager.flash "Can't find List-Subscribe header for this message."
155
163
  end
@@ -158,7 +166,7 @@ EOS
158
166
  def unsubscribe_from_list
159
167
  m = @message_lines[curpos] or return
160
168
  if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
161
- ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
169
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
162
170
  else
163
171
  BufferManager.flash "Can't find List-Unsubscribe header for this message."
164
172
  end
@@ -234,12 +242,16 @@ EOS
234
242
  ## view.
235
243
  def activate_chunk
236
244
  chunk = @chunk_lines[curpos] or return
237
- layout =
238
- if chunk.is_a?(Message)
239
- @layout[chunk]
240
- elsif chunk.expandable?
241
- @chunk_layout[chunk]
242
- end
245
+ if chunk.is_a? Chunk::Text
246
+ ## if the cursor is over a text region, expand/collapse the
247
+ ## entire message
248
+ chunk = @message_lines[curpos]
249
+ end
250
+ layout = if chunk.is_a?(Message)
251
+ @layout[chunk]
252
+ elsif chunk.expandable?
253
+ @chunk_layout[chunk]
254
+ end
243
255
  if layout
244
256
  layout.state = (layout.state != :closed ? :closed : :open)
245
257
  #cursor_down if layout.state == :closed # too annoying
@@ -247,6 +259,10 @@ EOS
247
259
  elsif chunk.viewable?
248
260
  view chunk
249
261
  end
262
+ if chunk.is_a?(Message)
263
+ jump_to_message chunk
264
+ jump_to_next_open if layout.state == :closed
265
+ end
250
266
  end
251
267
 
252
268
  def edit_as_new
@@ -540,7 +556,7 @@ private
540
556
  (0 ... text.length).each do |i|
541
557
  @chunk_lines[@text.length + i] = m
542
558
  @message_lines[@text.length + i] = m
543
- lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.length }.sum
559
+ lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
544
560
  end
545
561
 
546
562
  @text += text
@@ -561,7 +577,7 @@ private
561
577
  (0 ... text.length).each do |i|
562
578
  @chunk_lines[@text.length + i] = c
563
579
  @message_lines[@text.length + i] = m
564
- lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.length }.sum - (depth * INDENT_SPACES)
580
+ lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
565
581
  l.width = lw if lw > l.width
566
582
  end
567
583
  @text += text
@@ -635,7 +651,7 @@ private
635
651
 
636
652
  def format_person_list prefix, people
637
653
  ptext = people.map { |p| format_person p }
638
- pad = " " * prefix.length
654
+ pad = " " * prefix.display_length
639
655
  [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
640
656
  ptext[1 .. -1].map_with_index do |e, i|
641
657
  pad + e + (i == ptext.length - 1 ? "" : ",")
@@ -1,62 +1,9 @@
1
1
  module Redwood
2
2
 
3
- class PersonManager
4
- include Singleton
5
-
6
- def initialize fn
7
- @fn = fn
8
- @@people = {}
9
-
10
- ## read in stored people
11
- IO.readlines(fn).map do |l|
12
- l =~ /^(.*)?:\s+(\d+)\s+(.*)$/ or next
13
- email, time, name = $1, $2, $3
14
- @@people[email] = Person.new name, email, time, false
15
- end if File.exists? fn
16
-
17
- self.class.i_am_the_instance self
18
- end
19
-
20
- def save
21
- File.open(@fn, "w") do |f|
22
- @@people.each do |email, p|
23
- next if p.email == p.name
24
- next if p.name =~ /=/ # drop rfc2047-encoded, and lots of other useless emails. definitely a heuristic.
25
- f.puts "#{p.email}: #{p.timestamp} #{p.name}"
26
- end
27
- end
28
- end
29
-
30
- def self.people_for s, opts={}
31
- return [] if s.nil?
32
- s.split_on_commas.map { |ss| self.person_for ss, opts }
33
- end
34
-
35
- def self.person_for s, opts={}
36
- p = Person.from_address(s) or return nil
37
- p.definitive = true if opts[:definitive]
38
- register p
39
- end
40
-
41
- def self.register p
42
- oldp = @@people[p.email]
43
-
44
- if oldp.nil? || p.better_than?(oldp)
45
- @@people[p.email] = p
46
- end
47
-
48
- @@people[p.email].touch!
49
- @@people[p.email]
50
- end
51
- end
52
-
53
- ## don't create these by hand. rather, go through personmanager, to
54
- ## ensure uniqueness and overriding.
55
3
  class Person
56
- attr_accessor :name, :email, :timestamp
57
- bool_accessor :definitive
4
+ attr_accessor :name, :email
58
5
 
59
- def initialize name, email, timestamp=0, definitive=false
6
+ def initialize name, email
60
7
  raise ArgumentError, "email can't be nil" unless email
61
8
 
62
9
  if name
@@ -67,26 +14,10 @@ class Person
67
14
  end
68
15
 
69
16
  @email = email.gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ").downcase
70
- @definitive = definitive
71
- @timestamp = timestamp
72
- end
73
-
74
- ## heuristic: whether the name attached to this email is "real", i.e.
75
- ## we should bother to store it.
76
- def generic?
77
- @email =~ /no\-?reply/
78
- end
79
-
80
- def better_than? o
81
- return false if o.definitive? || generic?
82
- return true if definitive?
83
- o.name.nil? || (name && name.length > o.name.length && name =~ /[a-z]/)
84
17
  end
85
18
 
86
19
  def to_s; "#@name <#@email>" end
87
20
 
88
- def touch!; @timestamp = Time.now.to_i end
89
-
90
21
  # def == o; o && o.email == email; end
91
22
  # alias :eql? :==
92
23
  # def hash; [name, email].hash; end
@@ -146,8 +77,20 @@ class Person
146
77
  return nil if s.nil?
147
78
 
148
79
  ## try and parse an email address and name
149
- name, email =
150
- case s
80
+ name, email = case s
81
+ when /(.+?) ((\S+?)@\S+) \3/
82
+ ## ok, this first match cause is insane, but bear with me. email
83
+ ## addresses are stored in the to/from/etc fields of the index in a
84
+ ## weird format: "name address first-part-of-address", i.e. spaces
85
+ ## separating those three bits, and no <>'s. this is the output of
86
+ ## #indexable_content. here, we reverse-engineer that format to extract
87
+ ## a valid address.
88
+ ##
89
+ ## we store things this way to allow searches on a to/from/etc field to
90
+ ## match any of those parts. a more robust solution would be to store a
91
+ ## separate, non-indexed field with the proper headers. but this way we
92
+ ## save precious bits, and it's backwards-compatible with older indexes.
93
+ [$1, $2]
151
94
  when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/
152
95
  a, b = $1, $2
153
96
  [a.gsub('\"', '"'), b]
@@ -162,6 +105,12 @@ class Person
162
105
  Person.new name, email
163
106
  end
164
107
 
108
+ def self.from_address_list ss
109
+ return [] if ss.nil?
110
+ ss.split_on_commas.map { |s| self.from_address s }
111
+ end
112
+
113
+ ## see comments in self.from_address
165
114
  def indexable_content
166
115
  [name, email, email.split(/@/).first].join(" ")
167
116
  end
@@ -40,7 +40,7 @@ EOS
40
40
  end
41
41
 
42
42
  def buffer
43
- b, new = BufferManager.spawn_unless_exists("<poll for new messages>", :hidden => true) { PollMode.new }
43
+ b, new = BufferManager.spawn_unless_exists("poll for new messages", :hidden => true, :system => true) { PollMode.new }
44
44
  b
45
45
  end
46
46
 
@@ -86,7 +86,7 @@ EOS
86
86
  Index.usual_sources.each do |source|
87
87
  # yield "source #{source} is done? #{source.done?} (cur_offset #{source.cur_offset} >= #{source.end_offset})"
88
88
  begin
89
- yield "Loading from #{source}... " unless source.done? || source.has_errors?
89
+ yield "Loading from #{source}... " unless source.done? || (source.respond_to?(:has_errors?) && source.has_errors?)
90
90
  rescue SourceError => e
91
91
  Redwood::log "problem getting messages from #{source}: #{e.message}"
92
92
  Redwood::report_broken_sources :force_to_top => true
@@ -97,13 +97,13 @@ EOS
97
97
  numi = 0
98
98
  add_messages_from source do |m, offset, entry|
99
99
  ## always preserve the labels on disk.
100
- m.labels = entry[:label].split(/\s+/).map { |x| x.intern } if entry
100
+ m.labels = ((m.labels - [:unread, :inbox]) + entry[:label].symbolistize).uniq if entry
101
101
  yield "Found message at #{offset} with labels {#{m.labels * ', '}}"
102
102
  unless entry
103
103
  num += 1
104
- from_and_subj << [m.from.longname, m.subj]
104
+ from_and_subj << [m.from && m.from.longname, m.subj]
105
105
  if m.has_label?(:inbox) && ([:spam, :deleted, :killed] & m.labels).empty?
106
- from_and_subj_inbox << [m.from.longname, m.subj]
106
+ from_and_subj_inbox << [m.from && m.from.longname, m.subj]
107
107
  numi += 1
108
108
  end
109
109
  end
@@ -137,31 +137,29 @@ EOS
137
137
  def add_messages_from source, opts={}
138
138
  begin
139
139
  return if source.done? || source.has_errors?
140
-
140
+
141
141
  source.each do |offset, labels|
142
142
  if source.has_errors?
143
143
  Redwood::log "error loading messages from #{source}: #{source.error.message}"
144
144
  return
145
145
  end
146
-
146
+
147
147
  labels.each { |l| LabelManager << l }
148
148
  labels = labels + (source.archived? ? [] : [:inbox])
149
149
 
150
- begin
151
- m = Message.new :source => source, :source_info => offset, :labels => labels
152
- if m.source_marked_read?
153
- m.remove_label :unread
154
- labels.delete :unread
155
- end
150
+ m = Message.new :source => source, :source_info => offset, :labels => labels
151
+ m.load_from_source!
156
152
 
157
- docid, entry = Index.load_entry_for_id m.id
158
- HookManager.run "before-add-message", :message => m
159
- m = yield(m, offset, entry) or next if block_given?
160
- Index.sync_message m, docid, entry, opts
161
- UpdateManager.relay self, :added, m unless entry
162
- rescue MessageFormatError => e
163
- Redwood::log "ignoring erroneous message at #{source}##{offset}: #{e.message}"
153
+ if m.source_marked_read?
154
+ m.remove_label :unread
155
+ labels.delete :unread
164
156
  end
157
+
158
+ docid, entry = Index.load_entry_for_id m.id
159
+ HookManager.run "before-add-message", :message => m
160
+ m = yield(m, offset, entry) or next if block_given?
161
+ times = Index.sync_message m, false, docid, entry, opts
162
+ UpdateManager.relay self, :added, m unless entry
165
163
  end
166
164
  rescue SourceError => e
167
165
  Redwood::log "problem getting messages from #{source}: #{e.message}"
@@ -1,3 +1,5 @@
1
+ require "sup/rfc2047"
2
+
1
3
  module Redwood
2
4
 
3
5
  class SourceError < StandardError
@@ -99,7 +101,49 @@ class Source
99
101
  end
100
102
  end
101
103
 
104
+ ## read a raw email header from a filehandle (or anything that responds to
105
+ ## #gets), and turn it into a hash of key-value pairs.
106
+ ##
107
+ ## WARNING! THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have
108
+ ## a significant effect on Sup's processing speed of email from ALL sources.
109
+ ## Little things like string interpolation, regexp interpolation, += vs <<,
110
+ ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO!
111
+ def self.parse_raw_email_header f
112
+ header = {}
113
+ last = nil
114
+
115
+ while(line = f.gets)
116
+ case line
117
+ ## these three can occur multiple times, and we want the first one
118
+ when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2
119
+ ## mark this guy specially. not sure why i care.
120
+ when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2
121
+ when /^\r*$/; break
122
+ else
123
+ if last
124
+ header[last] << " " unless header[last].empty?
125
+ header[last] << line.strip
126
+ end
127
+ end
128
+ end
129
+
130
+ %w(subject from to cc bcc).each do |k|
131
+ v = header[k] or next
132
+ next unless Rfc2047.is_encoded? v
133
+ header[k] = begin
134
+ Rfc2047.decode_to $encoding, v
135
+ rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
136
+ #Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
137
+ v
138
+ end
139
+ end
140
+ header
141
+ end
142
+
102
143
  protected
144
+
145
+ ## convenience function
146
+ def parse_raw_email_header f; self.class.parse_raw_email_header f end
103
147
 
104
148
  def Source.expand_filesystem_uri uri
105
149
  uri.gsub "~", File.expand_path("~")
@@ -0,0 +1,39 @@
1
+ module Redwood
2
+
3
+ ## Implements a single undo list for the Sup instance
4
+ ##
5
+ ## The basic idea is to keep a list of lambdas to undo
6
+ ## things. When an action is called (such as 'archive'),
7
+ ## a lambda is registered with UndoManager that will
8
+ ## undo the archival action
9
+
10
+ class UndoManager
11
+ include Singleton
12
+
13
+ def initialize
14
+ @@actionlist = []
15
+ self.class.i_am_the_instance self
16
+ end
17
+
18
+ def register desc, *actions, &b
19
+ actions = [*actions.flatten]
20
+ actions << b if b
21
+ raise ArgumentError, "need at least one action" unless actions.length > 0
22
+ @@actionlist.push :desc => desc, :actions => actions
23
+ end
24
+
25
+ def undo
26
+ unless @@actionlist.empty?
27
+ actionset = @@actionlist.pop
28
+ actionset[:actions].each { |action| action.call }
29
+ BufferManager.flash "undid #{actionset[:desc]}"
30
+ else
31
+ BufferManager.flash "nothing more to undo!"
32
+ end
33
+ end
34
+
35
+ def clear
36
+ @@actionlist = []
37
+ end
38
+ end
39
+ end
@@ -172,6 +172,8 @@ class Object
172
172
  end
173
173
 
174
174
  class String
175
+ def display_length; scan(/./u).size end
176
+
175
177
  def camel_to_hyphy
176
178
  self.gsub(/([a-z])([A-Z0-9])/, '\1-\2').downcase
177
179
  end
@@ -188,11 +190,6 @@ class String
188
190
  ret
189
191
  end
190
192
 
191
- ## one of the few things i miss from perl
192
- def ucfirst
193
- self[0 .. 0].upcase + self[1 .. -1]
194
- end
195
-
196
193
  ## a very complicated regex found on teh internets to split on
197
194
  ## commas, unless they occurr within double quotes.
198
195
  def split_on_commas
@@ -276,6 +273,11 @@ class String
276
273
  def normalize_whitespace
277
274
  gsub(/\t/, " ").gsub(/\r/, "")
278
275
  end
276
+
277
+ ## takes a space-separated list of words, and returns an array of symbols.
278
+ ## typically used in Sup for translating Ferret's representation of a list
279
+ ## of labels (a string) to an array of label symbols.
280
+ def symbolistize; split.map { |x| x.intern } end
279
281
  end
280
282
 
281
283
  class Numeric
@@ -403,6 +405,10 @@ class Array
403
405
 
404
406
  def last= e; self[-1] = e end
405
407
  def nonempty?; !empty? end
408
+
409
+ def to_set_of_symbols
410
+ map { |x| x.is_a?(Symbol) ? x : x.intern }.uniq
411
+ end
406
412
  end
407
413
 
408
414
  class Time
@@ -620,17 +626,20 @@ end
620
626
 
621
627
  class Iconv
622
628
  def self.easy_decode target, charset, text
623
- return text if charset =~ /^(x-unknown|unknown[-_]?8bit|ascii[-_]?7[-_]?bit)$/i
629
+ return text if charset =~ /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i
624
630
  charset = case charset
625
- when /UTF[-_]?8/i: "utf-8"
626
- when /(iso[-_])?latin[-_]?1$/i: "ISO-8859-1"
627
- when /unicode[-_]1[-_]1[-_]utf[-_]7/i: "utf-7"
628
- else charset
629
- end
630
-
631
- # Convert:
632
- #
633
- # Remember - Iconv.open(to, from)!
634
- Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
631
+ when /UTF[-_ ]?8/i: "utf-8"
632
+ when /(iso[-_ ])?latin[-_ ]?1$/i: "ISO-8859-1"
633
+ when /iso[-_ ]?8859[-_ ]?15/i: 'ISO-8859-15'
634
+ when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i: "utf-7"
635
+ else charset
636
+ end
637
+
638
+ begin
639
+ Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
640
+ rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
641
+ Redwood::log "warning: error (#{e.class.name}) decoding text from #{charset} to #{target}: #{text[0 ... 20]}"
642
+ text
643
+ end
635
644
  end
636
645
  end