sup 0.19.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +12 -0
  4. data/CONTRIBUTORS +84 -0
  5. data/Gemfile +3 -0
  6. data/HACKING +42 -0
  7. data/History.txt +361 -0
  8. data/LICENSE +280 -0
  9. data/README.md +70 -0
  10. data/Rakefile +12 -0
  11. data/ReleaseNotes +231 -0
  12. data/bin/sup +434 -0
  13. data/bin/sup-add +118 -0
  14. data/bin/sup-config +243 -0
  15. data/bin/sup-dump +43 -0
  16. data/bin/sup-import-dump +101 -0
  17. data/bin/sup-psych-ify-config-files +21 -0
  18. data/bin/sup-recover-sources +87 -0
  19. data/bin/sup-sync +210 -0
  20. data/bin/sup-sync-back-maildir +127 -0
  21. data/bin/sup-tweak-labels +140 -0
  22. data/contrib/colorpicker.rb +100 -0
  23. data/contrib/completion/_sup.zsh +114 -0
  24. data/devel/console.sh +3 -0
  25. data/devel/count-loc.sh +3 -0
  26. data/devel/load-index.rb +9 -0
  27. data/devel/profile.rb +12 -0
  28. data/devel/start-console.rb +5 -0
  29. data/doc/FAQ.txt +119 -0
  30. data/doc/Hooks.txt +79 -0
  31. data/doc/Philosophy.txt +69 -0
  32. data/lib/sup.rb +467 -0
  33. data/lib/sup/account.rb +90 -0
  34. data/lib/sup/buffer.rb +768 -0
  35. data/lib/sup/colormap.rb +239 -0
  36. data/lib/sup/contact.rb +67 -0
  37. data/lib/sup/crypto.rb +461 -0
  38. data/lib/sup/draft.rb +119 -0
  39. data/lib/sup/hook.rb +159 -0
  40. data/lib/sup/horizontal_selector.rb +59 -0
  41. data/lib/sup/idle.rb +42 -0
  42. data/lib/sup/index.rb +882 -0
  43. data/lib/sup/interactive_lock.rb +89 -0
  44. data/lib/sup/keymap.rb +140 -0
  45. data/lib/sup/label.rb +87 -0
  46. data/lib/sup/logger.rb +77 -0
  47. data/lib/sup/logger/singleton.rb +10 -0
  48. data/lib/sup/maildir.rb +257 -0
  49. data/lib/sup/mbox.rb +187 -0
  50. data/lib/sup/message.rb +803 -0
  51. data/lib/sup/message_chunks.rb +328 -0
  52. data/lib/sup/mode.rb +140 -0
  53. data/lib/sup/modes/buffer_list_mode.rb +50 -0
  54. data/lib/sup/modes/completion_mode.rb +55 -0
  55. data/lib/sup/modes/compose_mode.rb +38 -0
  56. data/lib/sup/modes/console_mode.rb +125 -0
  57. data/lib/sup/modes/contact_list_mode.rb +148 -0
  58. data/lib/sup/modes/edit_message_async_mode.rb +110 -0
  59. data/lib/sup/modes/edit_message_mode.rb +728 -0
  60. data/lib/sup/modes/file_browser_mode.rb +109 -0
  61. data/lib/sup/modes/forward_mode.rb +82 -0
  62. data/lib/sup/modes/help_mode.rb +19 -0
  63. data/lib/sup/modes/inbox_mode.rb +85 -0
  64. data/lib/sup/modes/label_list_mode.rb +138 -0
  65. data/lib/sup/modes/label_search_results_mode.rb +38 -0
  66. data/lib/sup/modes/line_cursor_mode.rb +203 -0
  67. data/lib/sup/modes/log_mode.rb +57 -0
  68. data/lib/sup/modes/person_search_results_mode.rb +12 -0
  69. data/lib/sup/modes/poll_mode.rb +19 -0
  70. data/lib/sup/modes/reply_mode.rb +228 -0
  71. data/lib/sup/modes/resume_mode.rb +52 -0
  72. data/lib/sup/modes/scroll_mode.rb +252 -0
  73. data/lib/sup/modes/search_list_mode.rb +204 -0
  74. data/lib/sup/modes/search_results_mode.rb +59 -0
  75. data/lib/sup/modes/text_mode.rb +76 -0
  76. data/lib/sup/modes/thread_index_mode.rb +1033 -0
  77. data/lib/sup/modes/thread_view_mode.rb +941 -0
  78. data/lib/sup/person.rb +134 -0
  79. data/lib/sup/poll.rb +272 -0
  80. data/lib/sup/rfc2047.rb +56 -0
  81. data/lib/sup/search.rb +110 -0
  82. data/lib/sup/sent.rb +58 -0
  83. data/lib/sup/service/label_service.rb +45 -0
  84. data/lib/sup/source.rb +244 -0
  85. data/lib/sup/tagger.rb +50 -0
  86. data/lib/sup/textfield.rb +253 -0
  87. data/lib/sup/thread.rb +452 -0
  88. data/lib/sup/time.rb +93 -0
  89. data/lib/sup/undo.rb +38 -0
  90. data/lib/sup/update.rb +30 -0
  91. data/lib/sup/util.rb +747 -0
  92. data/lib/sup/util/ncurses.rb +274 -0
  93. data/lib/sup/util/path.rb +9 -0
  94. data/lib/sup/util/query.rb +17 -0
  95. data/lib/sup/util/uri.rb +15 -0
  96. data/lib/sup/version.rb +3 -0
  97. data/sup.gemspec +53 -0
  98. data/test/dummy_source.rb +61 -0
  99. data/test/gnupg_test_home/gpg.conf +1 -0
  100. data/test/gnupg_test_home/pubring.gpg +0 -0
  101. data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
  102. data/test/gnupg_test_home/receiver_secring.gpg +0 -0
  103. data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
  104. data/test/gnupg_test_home/secring.gpg +0 -0
  105. data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
  106. data/test/gnupg_test_home/trustdb.gpg +0 -0
  107. data/test/integration/test_label_service.rb +18 -0
  108. data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
  109. data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
  110. data/test/messages/missing-line.eml +9 -0
  111. data/test/test_crypto.rb +109 -0
  112. data/test/test_header_parsing.rb +168 -0
  113. data/test/test_helper.rb +7 -0
  114. data/test/test_message.rb +532 -0
  115. data/test/test_messages_dir.rb +147 -0
  116. data/test/test_yaml_migration.rb +85 -0
  117. data/test/test_yaml_regressions.rb +17 -0
  118. data/test/unit/service/test_label_service.rb +19 -0
  119. data/test/unit/test_horizontal_selector.rb +40 -0
  120. data/test/unit/util/test_query.rb +46 -0
  121. data/test/unit/util/test_string.rb +57 -0
  122. data/test/unit/util/test_uri.rb +19 -0
  123. metadata +423 -0
@@ -0,0 +1,941 @@
1
+ module Redwood
2
+
3
+ class ThreadViewMode < LineCursorMode
4
+ ## this holds all info we need to lay out a message
5
+ class MessageLayout
6
+ attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state
7
+ end
8
+
9
+ class ChunkLayout
10
+ attr_accessor :state
11
+ end
12
+
13
+ INDENT_SPACES = 2 # how many spaces to indent child messages
14
+
15
+ HookManager.register "detailed-headers", <<EOS
16
+ Add or remove headers from the detailed header display of a message.
17
+ Variables:
18
+ message: The message whose headers are to be formatted.
19
+ headers: A hash of header (name, value) pairs, initialized to the default
20
+ headers.
21
+ Return value:
22
+ None. The variable 'headers' should be modified in place.
23
+ EOS
24
+
25
+ HookManager.register "bounce-command", <<EOS
26
+ Determines the command used to bounce a message.
27
+ Variables:
28
+ from: The From header of the message being bounced
29
+ (eg: likely _not_ your address).
30
+ to: The addresses you asked the message to be bounced to as an array.
31
+ Return value:
32
+ A string representing the command to pipe the mail into. This
33
+ should include the entire command except for the destination addresses,
34
+ which will be appended by sup.
35
+ EOS
36
+
37
+ HookManager.register "publish", <<EOS
38
+ Executed when a message or a chunk is requested to be published.
39
+ Variables:
40
+ chunk: Redwood::Message or Redwood::Chunk::* to be published.
41
+ Return value:
42
+ None.
43
+ EOS
44
+
45
+ register_keymap do |k|
46
+ k.add :toggle_detailed_header, "Toggle detailed header", 'h'
47
+ k.add :show_header, "Show full message header", 'H'
48
+ k.add :show_message, "Show full message (raw form)", 'V'
49
+ k.add :activate_chunk, "Expand/collapse or activate item", :enter
50
+ k.add :expand_all_messages, "Expand/collapse all messages", 'E'
51
+ k.add :edit_draft, "Edit draft", 'e'
52
+ k.add :send_draft, "Send draft", 'y'
53
+ k.add :edit_labels, "Edit or add labels for a thread", 'l'
54
+ k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
55
+ k.add :jump_to_next_open, "Jump to next open message", 'n'
56
+ k.add :jump_to_next_and_open, "Jump to next message and open", "\C-n"
57
+ k.add :jump_to_prev_open, "Jump to previous open message", 'p'
58
+ k.add :jump_to_prev_and_open, "Jump to previous message and open", "\C-p"
59
+ k.add :align_current_message, "Align current message in buffer", 'z'
60
+ k.add :toggle_starred, "Star or unstar message", '*'
61
+ k.add :toggle_new, "Toggle unread/read status of message", 'N'
62
+ # k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
63
+ k.add :reply, "Reply to a message", 'r'
64
+ k.add :reply_all, "Reply to all participants of this message", 'G'
65
+ k.add :forward, "Forward a message or attachment", 'f'
66
+ k.add :bounce, "Bounce message to other recipient(s)", '!'
67
+ k.add :alias, "Edit alias/nickname for a person", 'i'
68
+ k.add :edit_as_new, "Edit message as new", 'D'
69
+ k.add :save_to_disk, "Save message/attachment to disk", 's'
70
+ k.add :save_all_to_disk, "Save all attachments to disk", 'A'
71
+ k.add :publish, "Publish message/attachment using publish-hook", 'P'
72
+ k.add :search, "Search for messages from particular people", 'S'
73
+ k.add :compose, "Compose message to person", 'm'
74
+ k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
75
+ k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
76
+ k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
77
+
78
+ k.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
79
+ k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
80
+ k.add :kill_and_next, "Kill this thread, kill buffer, and view next", '&'
81
+ k.add :toggle_wrap, "Toggle wrapping of text", 'w'
82
+
83
+ k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
84
+ kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
85
+ kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
86
+ kk.add :kill_and_kill, "Kill this thread and kill buffer", '&'
87
+ kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
88
+ kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
89
+ kk.add :do_nothing_and_kill, "Just kill this buffer", '.'
90
+ end
91
+
92
+ k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
93
+ kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
94
+ kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
95
+ kk.add :kill_and_next, "Kill this thread, kill buffer, and view next", '&'
96
+ kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
97
+ kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
98
+ kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n', ','
99
+ end
100
+
101
+ k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
102
+ kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
103
+ kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
104
+ kk.add :kill_and_prev, "Kill this thread, kill buffer, and view previous", '&'
105
+ kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
106
+ kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
107
+ kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n', ']'
108
+ end
109
+ end
110
+
111
+ ## there are a couple important instance variables we hold to format
112
+ ## the thread and to provide line-based functionality. @layout is a
113
+ ## map from Messages to MessageLayouts, and @chunk_layout from
114
+ ## Chunks to ChunkLayouts. @message_lines is a map from row #s to
115
+ ## Message objects. @chunk_lines is a map from row #s to Chunk
116
+ ## objects. @person_lines is a map from row #s to Person objects.
117
+
118
+ def initialize thread, hidden_labels=[], index_mode=nil
119
+ super :slip_rows => $config[:slip_rows]
120
+ @thread = thread
121
+ @hidden_labels = hidden_labels
122
+
123
+ ## used for dispatch-and-next
124
+ @index_mode = index_mode
125
+ @dying = false
126
+
127
+ @layout = SavingHash.new { MessageLayout.new }
128
+ @chunk_layout = SavingHash.new { ChunkLayout.new }
129
+ earliest, latest = nil, nil
130
+ latest_date = nil
131
+ altcolor = false
132
+
133
+ @thread.each do |m, d, p|
134
+ next unless m
135
+ earliest ||= m
136
+ @layout[m].state = initial_state_for m
137
+ @layout[m].toggled_state = false
138
+ @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
139
+ @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
140
+ @layout[m].orig_new = m.has_label? :read
141
+ altcolor = !altcolor
142
+ if latest_date.nil? || m.date > latest_date
143
+ latest_date = m.date
144
+ latest = m
145
+ end
146
+ end
147
+
148
+ @wrap = true
149
+
150
+ @layout[latest].state = :open if @layout[latest].state == :closed
151
+ @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
152
+ end
153
+
154
+ def toggle_wrap
155
+ @wrap = !@wrap
156
+ regen_text
157
+ buffer.mark_dirty if buffer
158
+ end
159
+
160
+ def draw_line ln, opts={}
161
+ if ln == curpos
162
+ super ln, :highlight => true
163
+ else
164
+ super
165
+ end
166
+ end
167
+ def lines; @text.length; end
168
+ def [] i; @text[i]; end
169
+
170
+ ## a little hacky---since regen_text can depend on buffer features like the
171
+ ## content_width, we don't call it in the constructor, and instead call it
172
+ ## here, which is set before we're responsible for drawing ourself.
173
+ def buffer= b
174
+ super
175
+ regen_text
176
+ end
177
+
178
+ def show_header
179
+ m = @message_lines[curpos] or return
180
+ BufferManager.spawn_unless_exists("Full header for #{m.id}") do
181
+ TextMode.new m.raw_header.ascii
182
+ end
183
+ end
184
+
185
+ def show_message
186
+ m = @message_lines[curpos] or return
187
+ BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
188
+ TextMode.new m.raw_message.ascii
189
+ end
190
+ end
191
+
192
+ def toggle_detailed_header
193
+ m = @message_lines[curpos] or return
194
+ @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
195
+ update
196
+ end
197
+
198
+ def reply type_arg=nil
199
+ m = @message_lines[curpos] or return
200
+ mode = ReplyMode.new m, type_arg
201
+ BufferManager.spawn "Reply to #{m.subj}", mode
202
+ end
203
+
204
+ def reply_all; reply :all; end
205
+
206
+ def subscribe_to_list
207
+ m = @message_lines[curpos] or return
208
+ if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
209
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe")
210
+ else
211
+ BufferManager.flash "Can't find List-Subscribe header for this message."
212
+ end
213
+ end
214
+
215
+ def unsubscribe_from_list
216
+ m = @message_lines[curpos] or return
217
+ if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
218
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe")
219
+ else
220
+ BufferManager.flash "Can't find List-Unsubscribe header for this message."
221
+ end
222
+ end
223
+
224
+ def forward
225
+ if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
226
+ ForwardMode.spawn_nicely :attachments => [chunk]
227
+ elsif(m = @message_lines[curpos])
228
+ ForwardMode.spawn_nicely :message => m
229
+ end
230
+ end
231
+
232
+ def bounce
233
+ m = @message_lines[curpos] or return
234
+ to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
235
+
236
+ defcmd = AccountManager.default_account.bounce_sendmail
237
+
238
+ cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
239
+ when nil, /^$/ then defcmd
240
+ else hookcmd
241
+ end + ' ' + to.map { |t| t.email }.join(' ')
242
+
243
+ bt = to.size > 1 ? "#{to.size} recipients" : to[0].to_s
244
+
245
+ if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
246
+ debug "bounce command: #{cmd}"
247
+ begin
248
+ IO.popen(cmd, 'w') do |sm|
249
+ sm.puts m.raw_message
250
+ end
251
+ raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
252
+ m.add_label :forwarded
253
+ Index.save_message m
254
+ rescue SystemCallError, SendmailCommandFailed => e
255
+ warn "problem sending mail: #{e.message}"
256
+ BufferManager.flash "Problem sending mail: #{e.message}"
257
+ end
258
+ end
259
+ end
260
+
261
+ include CanAliasContacts
262
+ def alias
263
+ p = @person_lines[curpos] or return
264
+ alias_contact p
265
+ update
266
+ end
267
+
268
+ def search
269
+ p = @person_lines[curpos] or return
270
+ mode = PersonSearchResultsMode.new [p]
271
+ BufferManager.spawn "Search for #{p.name}", mode
272
+ mode.load_threads :num => mode.buffer.content_height
273
+ end
274
+
275
+ def compose
276
+ p = @person_lines[curpos]
277
+ if p
278
+ ComposeMode.spawn_nicely :to_default => p
279
+ else
280
+ ComposeMode.spawn_nicely
281
+ end
282
+ end
283
+
284
+ def edit_labels
285
+ old_labels = @thread.labels
286
+ reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
287
+ new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s}
288
+
289
+ return unless new_labels
290
+ @thread.labels = Set.new(reserved_labels) + new_labels
291
+ new_labels.each { |l| LabelManager << l }
292
+ update
293
+ UpdateManager.relay self, :labeled, @thread.first
294
+ Index.save_thread @thread
295
+ UndoManager.register "labeling thread" do
296
+ @thread.labels = old_labels
297
+ Index.save_thread @thread
298
+ UpdateManager.relay self, :labeled, @thread.first
299
+ end
300
+ end
301
+
302
+ def toggle_starred
303
+ m = @message_lines[curpos] or return
304
+ toggle_label m, :starred
305
+ end
306
+
307
+ def toggle_new
308
+ m = @message_lines[curpos] or return
309
+ toggle_label m, :unread
310
+ end
311
+
312
+ def toggle_label m, label
313
+ if m.has_label? label
314
+ m.remove_label label
315
+ else
316
+ m.add_label label
317
+ end
318
+ ## TODO: don't recalculate EVERYTHING just to add a stupid little
319
+ ## star to the display
320
+ update
321
+ UpdateManager.relay self, :single_message_labeled, m
322
+ Index.save_thread @thread
323
+ end
324
+
325
+ ## called when someone presses enter when the cursor is highlighting
326
+ ## a chunk. for expandable chunks (including messages) we toggle
327
+ ## open/closed state; for viewable chunks (like attachments) we
328
+ ## view.
329
+ def activate_chunk
330
+ chunk = @chunk_lines[curpos] or return
331
+ if chunk.is_a? Chunk::Text
332
+ ## if the cursor is over a text region, expand/collapse the
333
+ ## entire message
334
+ chunk = @message_lines[curpos]
335
+ end
336
+ layout = if chunk.is_a?(Message)
337
+ @layout[chunk]
338
+ elsif chunk.expandable?
339
+ @chunk_layout[chunk]
340
+ end
341
+ if layout
342
+ layout.state = (layout.state != :closed ? :closed : :open)
343
+ #cursor_down if layout.state == :closed # too annoying
344
+ update
345
+ elsif chunk.viewable?
346
+ view chunk
347
+ end
348
+ if chunk.is_a?(Message) && $config[:jump_to_open_message]
349
+ jump_to_message chunk
350
+ jump_to_next_open if layout.state == :closed
351
+ end
352
+ end
353
+
354
+ def edit_as_new
355
+ m = @message_lines[curpos] or return
356
+ mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
357
+ BufferManager.spawn "edit as new", mode
358
+ mode.default_edit_message
359
+ end
360
+
361
+ def save_to_disk
362
+ chunk = @chunk_lines[curpos] or return
363
+ case chunk
364
+ when Chunk::Attachment
365
+ default_dir = $config[:default_attachment_save_dir]
366
+ default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty?
367
+ default_fn = File.expand_path File.join(default_dir, chunk.filename)
368
+ fn = BufferManager.ask_for_filename :filename, "Save attachment to file or directory: ", default_fn, true
369
+
370
+ # if user selects directory use file name from message
371
+ if fn and File.directory? fn
372
+ fn = File.join(fn, chunk.filename)
373
+ end
374
+
375
+ save_to_file(fn) { |f| f.print chunk.raw_content } if fn
376
+ else
377
+ m = @message_lines[curpos]
378
+ fn = BufferManager.ask_for_filename :filename, "Save message to file: "
379
+ return unless fn
380
+ save_to_file(fn) do |f|
381
+ m.each_raw_message_line { |l| f.print l }
382
+ end
383
+ end
384
+ end
385
+
386
+ def save_all_to_disk
387
+ m = @message_lines[curpos] or return
388
+ default_dir = ($config[:default_attachment_save_dir] || ".")
389
+ folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true
390
+ return unless folder
391
+
392
+ num = 0
393
+ num_errors = 0
394
+ m.chunks.each do |chunk|
395
+ next unless chunk.is_a?(Chunk::Attachment)
396
+ fn = File.join(folder, chunk.filename)
397
+ num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
398
+ num += 1
399
+ end
400
+
401
+ if num == 0
402
+ BufferManager.flash "Didn't find any attachments!"
403
+ else
404
+ if num_errors == 0
405
+ BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}."
406
+ else
407
+ BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)."
408
+ end
409
+ end
410
+ end
411
+
412
+ def publish
413
+ chunk = @chunk_lines[curpos] or return
414
+ if HookManager.enabled? "publish"
415
+ HookManager.run "publish", :chunk => chunk
416
+ else
417
+ BufferManager.flash "Publishing hook not defined."
418
+ end
419
+ end
420
+
421
+ def edit_draft
422
+ m = @message_lines[curpos] or return
423
+ if m.is_draft?
424
+ mode = ResumeMode.new m
425
+ BufferManager.spawn "Edit message", mode
426
+ BufferManager.kill_buffer self.buffer
427
+ mode.default_edit_message
428
+ else
429
+ BufferManager.flash "Not a draft message!"
430
+ end
431
+ end
432
+
433
+ def send_draft
434
+ m = @message_lines[curpos] or return
435
+ if m.is_draft?
436
+ mode = ResumeMode.new m
437
+ BufferManager.spawn "Send message", mode
438
+ BufferManager.kill_buffer self.buffer
439
+ mode.send_message
440
+ else
441
+ BufferManager.flash "Not a draft message!"
442
+ end
443
+ end
444
+
445
+ def jump_to_first_open
446
+ m = @message_lines[0] or return
447
+ if @layout[m].state != :closed
448
+ jump_to_message m#, true
449
+ else
450
+ jump_to_next_open #true
451
+ end
452
+ end
453
+
454
+ def jump_to_next_and_open
455
+ return continue_search_in_buffer if in_search? # err.. don't know why im doing this
456
+
457
+ m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
458
+ return unless m
459
+
460
+ nextm = @layout[m].next
461
+ return unless nextm
462
+
463
+ if @layout[m].toggled_state == true
464
+ @layout[m].state = :closed
465
+ @layout[m].toggled_state = false
466
+ update
467
+ end
468
+
469
+ if @layout[nextm].state == :closed
470
+ @layout[nextm].state = :open
471
+ @layout[nextm].toggled_state = true
472
+ end
473
+
474
+ jump_to_message nextm if nextm
475
+
476
+ update if @layout[nextm].toggled_state
477
+ end
478
+
479
+ def jump_to_next_open force_alignment=nil
480
+ return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
481
+ m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
482
+ return unless m
483
+ while nextm = @layout[m].next
484
+ break if @layout[nextm].state != :closed
485
+ m = nextm
486
+ end
487
+ jump_to_message nextm, force_alignment if nextm
488
+ end
489
+
490
+ def align_current_message
491
+ m = @message_lines[curpos] or return
492
+ jump_to_message m, true
493
+ end
494
+
495
+ def jump_to_prev_and_open force_alignment=nil
496
+ m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] }
497
+ return unless m
498
+
499
+ nextm = @layout[m].prev
500
+ return unless nextm
501
+
502
+ if @layout[m].toggled_state == true
503
+ @layout[m].state = :closed
504
+ @layout[m].toggled_state = false
505
+ update
506
+ end
507
+
508
+ if @layout[nextm].state == :closed
509
+ @layout[nextm].state = :open
510
+ @layout[nextm].toggled_state = true
511
+ end
512
+
513
+ jump_to_message nextm if nextm
514
+ update if @layout[nextm].toggled_state
515
+ end
516
+
517
+ def jump_to_prev_open
518
+ m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
519
+ return unless m
520
+ ## jump to the top of the current message if we're in the body;
521
+ ## otherwise, to the previous message
522
+
523
+ top = @layout[m].top
524
+ if curpos == top
525
+ while(prevm = @layout[m].prev)
526
+ break if @layout[prevm].state != :closed
527
+ m = prevm
528
+ end
529
+ jump_to_message prevm if prevm
530
+ else
531
+ jump_to_message m
532
+ end
533
+ end
534
+
535
+ def jump_to_message m, force_alignment=false
536
+ l = @layout[m]
537
+
538
+ ## boundaries of the message
539
+ message_left = l.depth * INDENT_SPACES
540
+ message_right = message_left + l.width
541
+
542
+ ## calculate leftmost colum
543
+ left = if force_alignment # force mode: align exactly
544
+ message_left
545
+ else # regular: minimize cursor movement
546
+ ## leftmost and rightmost are boundaries of all valid left-column
547
+ ## alignments.
548
+ leftmost = [message_left, message_right - buffer.content_width + 1].min
549
+ rightmost = message_left
550
+ leftcol.clamp(leftmost, rightmost)
551
+ end
552
+
553
+ jump_to_line l.top # move vertically
554
+ jump_to_col left # move horizontally
555
+ set_cursor_pos l.top # set cursor pos
556
+ end
557
+
558
+ def expand_all_messages
559
+ @global_message_state ||= :closed
560
+ @global_message_state = (@global_message_state == :closed ? :open : :closed)
561
+ @layout.each { |m, l| l.state = @global_message_state }
562
+ update
563
+ end
564
+
565
+ def collapse_non_new_messages
566
+ @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
567
+ update
568
+ end
569
+
570
+ def expand_all_quotes
571
+ if(m = @message_lines[curpos])
572
+ quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
573
+ numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
574
+ newstate = numopen > quotes.length / 2 ? :closed : :open
575
+ quotes.each { |c| @chunk_layout[c].state = newstate }
576
+ update
577
+ end
578
+ end
579
+
580
+ def cleanup
581
+ @layout = @chunk_layout = @text = nil # for good luck
582
+ end
583
+
584
+ def archive_and_kill; archive_and_then :kill end
585
+ def spam_and_kill; spam_and_then :kill end
586
+ def delete_and_kill; delete_and_then :kill end
587
+ def kill_and_kill; kill_and_then :kill end
588
+ def unread_and_kill; unread_and_then :kill end
589
+ def do_nothing_and_kill; do_nothing_and_then :kill end
590
+
591
+ def archive_and_next; archive_and_then :next end
592
+ def spam_and_next; spam_and_then :next end
593
+ def delete_and_next; delete_and_then :next end
594
+ def kill_and_next; kill_and_then :next end
595
+ def unread_and_next; unread_and_then :next end
596
+ def do_nothing_and_next; do_nothing_and_then :next end
597
+
598
+ def archive_and_prev; archive_and_then :prev end
599
+ def spam_and_prev; spam_and_then :prev end
600
+ def delete_and_prev; delete_and_then :prev end
601
+ def kill_and_prev; kill_and_then :prev end
602
+ def unread_and_prev; unread_and_then :prev end
603
+ def do_nothing_and_prev; do_nothing_and_then :prev end
604
+
605
+ def archive_and_then op
606
+ dispatch op do
607
+ @thread.remove_label :inbox
608
+ UpdateManager.relay self, :archived, @thread.first
609
+ Index.save_thread @thread
610
+ UndoManager.register "archiving 1 thread" do
611
+ @thread.apply_label :inbox
612
+ Index.save_thread @thread
613
+ UpdateManager.relay self, :unarchived, @thread.first
614
+ end
615
+ end
616
+ end
617
+
618
+ def spam_and_then op
619
+ dispatch op do
620
+ @thread.apply_label :spam
621
+ UpdateManager.relay self, :spammed, @thread.first
622
+ Index.save_thread @thread
623
+ UndoManager.register "marking 1 thread as spam" do
624
+ @thread.remove_label :spam
625
+ Index.save_thread @thread
626
+ UpdateManager.relay self, :unspammed, @thread.first
627
+ end
628
+ end
629
+ end
630
+
631
+ def delete_and_then op
632
+ dispatch op do
633
+ @thread.apply_label :deleted
634
+ UpdateManager.relay self, :deleted, @thread.first
635
+ Index.save_thread @thread
636
+ UndoManager.register "deleting 1 thread" do
637
+ @thread.remove_label :deleted
638
+ Index.save_thread @thread
639
+ UpdateManager.relay self, :undeleted, @thread.first
640
+ end
641
+ end
642
+ end
643
+
644
+ def kill_and_then op
645
+ dispatch op do
646
+ @thread.apply_label :killed
647
+ UpdateManager.relay self, :killed, @thread.first
648
+ Index.save_thread @thread
649
+ UndoManager.register "killed 1 thread" do
650
+ @thread.remove_label :killed
651
+ Index.save_thread @thread
652
+ UpdateManager.relay self, :unkilled, @thread.first
653
+ end
654
+ end
655
+ end
656
+
657
+ def unread_and_then op
658
+ dispatch op do
659
+ @thread.apply_label :unread
660
+ UpdateManager.relay self, :unread, @thread.first
661
+ Index.save_thread @thread
662
+ end
663
+ end
664
+
665
+ def do_nothing_and_then op
666
+ dispatch op
667
+ end
668
+
669
+ def dispatch op
670
+ return if @dying
671
+ @dying = true
672
+
673
+ l = lambda do
674
+ yield if block_given?
675
+ BufferManager.kill_buffer_safely buffer
676
+ end
677
+
678
+ case op
679
+ when :next
680
+ @index_mode.launch_next_thread_after @thread, &l
681
+ when :prev
682
+ @index_mode.launch_prev_thread_before @thread, &l
683
+ when :kill
684
+ l.call
685
+ else
686
+ raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
687
+ end
688
+ end
689
+ private :dispatch
690
+
691
+ def pipe_message
692
+ chunk = @chunk_lines[curpos]
693
+ chunk = nil unless chunk.is_a?(Chunk::Attachment)
694
+ message = @message_lines[curpos] unless chunk
695
+
696
+ return unless chunk || message
697
+
698
+ command = BufferManager.ask(:shell, "pipe command: ")
699
+ return if command.nil? || command.empty?
700
+
701
+ output = pipe_to_process(command) do |stream|
702
+ if chunk
703
+ stream.print chunk.raw_content
704
+ else
705
+ message.each_raw_message_line { |l| stream.print l }
706
+ end
707
+ end
708
+
709
+ if output
710
+ BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
711
+ else
712
+ BufferManager.flash "'#{command}' done!"
713
+ end
714
+ end
715
+
716
+
717
+ def status
718
+ user_labels = @thread.labels.to_a.map do |l|
719
+ l.to_s if LabelManager.user_defined_labels.member?(l)
720
+ end.compact.join(",")
721
+ user_labels = (user_labels.empty? and "" or "<#{user_labels}>")
722
+ [user_labels, super].join(" -- ")
723
+ end
724
+
725
+ private
726
+
727
+ def initial_state_for m
728
+ if m.has_label?(:starred) || m.has_label?(:unread)
729
+ :open
730
+ else
731
+ :closed
732
+ end
733
+ end
734
+
735
+ def update
736
+ regen_text
737
+ buffer.mark_dirty if buffer
738
+ end
739
+
740
+ ## here we generate the actual content lines. we accumulate
741
+ ## everything into @text, and we set @chunk_lines and
742
+ ## @message_lines, and we update @layout.
743
+ def regen_text
744
+ @text = []
745
+ @chunk_lines = []
746
+ @message_lines = []
747
+ @person_lines = []
748
+
749
+ prevm = nil
750
+ @thread.each do |m, depth, parent|
751
+ unless m.is_a? Message # handle nil and :fake_root
752
+ @text += chunk_to_lines m, nil, @text.length, depth, parent
753
+ next
754
+ end
755
+ l = @layout[m]
756
+
757
+ ## is this still necessary?
758
+ next unless @layout[m].state # skip discarded drafts
759
+
760
+ ## build the patina
761
+ text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
762
+
763
+ l.top = @text.length
764
+ l.bot = @text.length + text.length # updated below
765
+ l.prev = prevm
766
+ l.next = nil
767
+ l.depth = depth
768
+ # l.state we preserve
769
+ l.width = 0 # updated below
770
+ @layout[l.prev].next = m if l.prev
771
+
772
+ (0 ... text.length).each do |i|
773
+ @chunk_lines[@text.length + i] = m
774
+ @message_lines[@text.length + i] = m
775
+ lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
776
+ end
777
+
778
+ @text += text
779
+ prevm = m
780
+ if l.state != :closed
781
+ m.chunks.each do |c|
782
+ cl = @chunk_layout[c]
783
+
784
+ ## set the default state for chunks
785
+ cl.state ||=
786
+ if c.expandable? && c.respond_to?(:initial_state)
787
+ c.initial_state
788
+ else
789
+ :closed
790
+ end
791
+
792
+ text = chunk_to_lines c, cl.state, @text.length, depth
793
+ (0 ... text.length).each do |i|
794
+ @chunk_lines[@text.length + i] = c
795
+ @message_lines[@text.length + i] = m
796
+ lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
797
+ l.width = lw if lw > l.width
798
+ end
799
+ @text += text
800
+ end
801
+ @layout[m].bot = @text.length
802
+ end
803
+ end
804
+ end
805
+
806
+ def message_patina_lines m, state, start, parent, prefix, color, star_color
807
+ prefix_widget = [color, prefix]
808
+
809
+ open_widget = [color, (state == :closed ? "+ " : "- ")]
810
+ new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
811
+ starred_widget = if m.has_label?(:starred)
812
+ [star_color, "*"]
813
+ else
814
+ [color, " "]
815
+ end
816
+ attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
817
+
818
+ case state
819
+ when :open
820
+ @person_lines[start] = m.from
821
+ [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
822
+ [color,
823
+ "#{m.from ? m.from.mediumname.fix_encoding! : '?'} to #{m.recipients.map { |l| l.shortname.fix_encoding! }.join(', ')} #{m.date.to_nice_s.fix_encoding!} (#{m.date.to_nice_distance_s.fix_encoding!})"]]]
824
+
825
+ when :closed
826
+ @person_lines[start] = m.from
827
+ [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
828
+ [color,
829
+ "#{m.from ? m.from.mediumname.fix_encoding! : '?'}, #{m.date.to_nice_s.fix_encoding!} (#{m.date.to_nice_distance_s.fix_encoding!}) #{m.snippet ? m.snippet.fix_encoding! : ''}"]]]
830
+
831
+ when :detailed
832
+ @person_lines[start] = m.from
833
+ from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
834
+ [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
835
+
836
+ addressee_lines = []
837
+ unless m.to.empty?
838
+ m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
839
+ addressee_lines += format_person_list " To: ", m.to
840
+ end
841
+ unless m.cc.empty?
842
+ m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
843
+ addressee_lines += format_person_list " Cc: ", m.cc
844
+ end
845
+ unless m.bcc.empty?
846
+ m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
847
+ addressee_lines += format_person_list " Bcc: ", m.bcc
848
+ end
849
+
850
+ headers = OrderedHash.new
851
+ headers["Date"] = "#{m.date.to_message_nice_s} (#{m.date.to_nice_distance_s})"
852
+ headers["Subject"] = m.subj
853
+
854
+ show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
855
+ unless show_labels.empty?
856
+ headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
857
+ end
858
+ if parent
859
+ headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.to_message_nice_s}"
860
+ end
861
+
862
+ HookManager.run "detailed-headers", :message => m, :headers => headers
863
+
864
+ from_line + (addressee_lines + headers.map { |k, v| " #{k}: #{v}" }).map { |l| [[color, prefix + " " + l]] }
865
+ end
866
+ end
867
+
868
+ def format_person_list prefix, people
869
+ ptext = people.map { |p| format_person p }
870
+ pad = " " * prefix.display_length
871
+ [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
872
+ ptext[1 .. -1].map_with_index do |e, i|
873
+ pad + e + (i == ptext.length - 1 ? "" : ",")
874
+ end
875
+ end
876
+
877
+ def format_person p
878
+ p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
879
+ end
880
+
881
+ def maybe_wrap_text lines
882
+ if @wrap
883
+ config_width = $config[:wrap_width]
884
+ if config_width and config_width != 0
885
+ width = [config_width, buffer.content_width].min
886
+ else
887
+ width = buffer.content_width
888
+ end
889
+ # lines can apparently be both String and Array, convert to Array for map.
890
+ if lines.kind_of? String
891
+ lines = lines.lines.to_a
892
+ end
893
+ lines = lines.map { |l| l.chomp.wrap width if l }.flatten
894
+ end
895
+ return lines
896
+ end
897
+
898
+ ## todo: check arguments on this overly complex function
899
+ def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
900
+ prefix = " " * INDENT_SPACES * depth
901
+ case chunk
902
+ when :fake_root
903
+ [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
904
+ when nil
905
+ [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
906
+ when Message
907
+ message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
908
+ (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
909
+
910
+ else
911
+ raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
912
+ if chunk.inlineable?
913
+ lines = maybe_wrap_text(chunk.lines)
914
+ lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
915
+ elsif chunk.expandable?
916
+ case state
917
+ when :closed
918
+ [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
919
+ when :open
920
+ lines = maybe_wrap_text(chunk.lines)
921
+ [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
922
+ end
923
+ else
924
+ [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
925
+ end
926
+ end
927
+ end
928
+
929
+ def view chunk
930
+ BufferManager.flash "viewing #{chunk.content_type} attachment..."
931
+ success = chunk.view!
932
+ BufferManager.erase_flash
933
+ BufferManager.completely_redraw_screen
934
+ unless success
935
+ BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename)
936
+ BufferManager.flash "Couldn't execute view command, viewing as text."
937
+ end
938
+ end
939
+ end
940
+
941
+ end