sup 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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