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,59 @@
1
+ module Redwood
2
+
3
+ class SearchResultsMode < ThreadIndexMode
4
+ def initialize query
5
+ @query = query
6
+ super [], query
7
+ end
8
+
9
+ register_keymap do |k|
10
+ k.add :refine_search, "Refine search", '|'
11
+ k.add :save_search, "Save search", '%'
12
+ end
13
+
14
+ def refine_search
15
+ text = BufferManager.ask :search, "refine query: ", (@query[:text] + " ")
16
+ return unless text && text !~ /^\s*$/
17
+ SearchResultsMode.spawn_from_query text
18
+ end
19
+
20
+ def save_search
21
+ name = BufferManager.ask :save_search, "Name this search: "
22
+ return unless name && name !~ /^\s*$/
23
+ name.strip!
24
+ unless SearchManager.valid_name? name
25
+ BufferManager.flash "Not saved: " + SearchManager.name_format_hint
26
+ return
27
+ end
28
+ if SearchManager.all_searches.include? name
29
+ BufferManager.flash "Not saved: \"#{name}\" already exists"
30
+ return
31
+ end
32
+ BufferManager.flash "Search saved as \"#{name}\"" if SearchManager.add name, @query[:text].strip
33
+ end
34
+
35
+ ## a proper is_relevant? method requires some way of asking the index
36
+ ## if an in-memory object satisfies a query. i'm not sure how to do
37
+ ## that yet. in the worst case i can make an in-memory index, add
38
+ ## the message, and search against it to see if i have > 0 results,
39
+ ## but that seems pretty insane.
40
+
41
+ def self.spawn_from_query text
42
+ begin
43
+ if SearchManager.predefined_queries.has_key? text
44
+ query = SearchManager.predefined_queries[text]
45
+ else
46
+ query = Index.parse_query(text)
47
+ end
48
+ return unless query
49
+ short_text = text.length < 20 ? text : text[0 ... 20] + "..."
50
+ mode = SearchResultsMode.new query
51
+ BufferManager.spawn "search: \"#{short_text}\"", mode
52
+ mode.load_threads :num => mode.buffer.content_height
53
+ rescue Index::ParseError => e
54
+ BufferManager.flash "Problem: #{e.message}!"
55
+ end
56
+ end
57
+ end
58
+
59
+ end
@@ -0,0 +1,76 @@
1
+ module Redwood
2
+
3
+ class TextMode < ScrollMode
4
+ attr_reader :text
5
+ register_keymap do |k|
6
+ k.add :save_to_disk, "Save to disk", 's'
7
+ k.add :pipe, "Pipe to process", '|'
8
+ end
9
+
10
+ def initialize text="", filename=nil
11
+ @text = text
12
+ @filename = filename
13
+ update_lines
14
+ buffer.mark_dirty if buffer
15
+ super()
16
+ end
17
+
18
+ def save_to_disk
19
+ fn = BufferManager.ask_for_filename :filename, "Save to file: ", @filename
20
+ save_to_file(fn) { |f| f.puts text } if fn
21
+ end
22
+
23
+ def pipe
24
+ command = BufferManager.ask(:shell, "pipe command: ")
25
+ return if command.nil? || command.empty?
26
+
27
+ output = pipe_to_process(command) do |stream|
28
+ @text.each { |l| stream.puts l }
29
+ end
30
+
31
+ if output
32
+ BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
33
+ else
34
+ BufferManager.flash "'#{command}' done!"
35
+ end
36
+ end
37
+
38
+ def text= t
39
+ @text = t
40
+ update_lines
41
+ if buffer
42
+ ensure_mode_validity
43
+ buffer.mark_dirty
44
+ end
45
+ end
46
+
47
+ def << line
48
+ @lines = [0] if @text.empty?
49
+ @text << line.fix_encoding!
50
+ @lines << @text.length
51
+ if buffer
52
+ ensure_mode_validity
53
+ buffer.mark_dirty
54
+ end
55
+ end
56
+
57
+ def lines
58
+ @lines.length - 1
59
+ end
60
+
61
+ def [] i
62
+ return nil unless i < @lines.length
63
+ @text[@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)].normalize_whitespace
64
+ # (@lines[i] ... (i + 1 < @lines.length ? @lines[i + 1] - 1 : @text.length)).inspect
65
+ end
66
+
67
+ private
68
+
69
+ def update_lines
70
+ pos = @text.find_all_positions("\n")
71
+ pos.push @text.length unless pos.last == @text.length - 1
72
+ @lines = [0] + pos.map { |x| x + 1 }
73
+ end
74
+ end
75
+
76
+ end
@@ -0,0 +1,1033 @@
1
+ require 'set'
2
+
3
+ module Redwood
4
+
5
+ ## subclasses should implement:
6
+ ## - is_relevant?
7
+
8
+ class ThreadIndexMode < LineCursorMode
9
+ DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
10
+ MIN_FROM_WIDTH = 15
11
+ LOAD_MORE_THREAD_NUM = 20
12
+
13
+ HookManager.register "index-mode-size-widget", <<EOS
14
+ Generates the per-thread size widget for each thread.
15
+ Variables:
16
+ thread: The message thread to be formatted.
17
+ EOS
18
+
19
+ HookManager.register "index-mode-date-widget", <<EOS
20
+ Generates the per-thread date widget for each thread.
21
+ Variables:
22
+ thread: The message thread to be formatted.
23
+ EOS
24
+
25
+ HookManager.register "mark-as-spam", <<EOS
26
+ This hook is run when a thread is marked as spam
27
+ Variables:
28
+ thread: The message thread being marked as spam.
29
+ EOS
30
+
31
+ register_keymap do |k|
32
+ k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
33
+ k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
34
+ kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
35
+ end
36
+ k.add :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
37
+ k.add :cancel_search, "Cancel current search", :ctrl_g
38
+ k.add :reload, "Refresh view", '@'
39
+ k.add :toggle_archived, "Toggle archived status", 'a'
40
+ k.add :toggle_starred, "Star or unstar all messages in thread", '*'
41
+ k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
42
+ k.add :edit_labels, "Edit or add labels for a thread", 'l'
43
+ k.add :edit_message, "Edit message (drafts only)", 'e'
44
+ k.add :toggle_spam, "Mark/unmark thread as spam", 'S'
45
+ k.add :toggle_deleted, "Delete/undelete thread", 'd'
46
+ k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
47
+ k.add :flush_index, "Flush all changes now", '$'
48
+ k.add :jump_to_next_new, "Jump to next new thread", :tab
49
+ k.add :reply, "Reply to latest message in a thread", 'r'
50
+ k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
51
+ k.add :forward, "Forward latest message in a thread", 'f'
52
+ k.add :toggle_tagged, "Tag/untag selected thread", 't'
53
+ k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
54
+ k.add :tag_matching, "Tag matching threads", 'g'
55
+ k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
56
+ k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
57
+ k.add :undo, "Undo the previous action", 'u'
58
+ end
59
+
60
+ def initialize hidden_labels=[], load_thread_opts={}
61
+ super()
62
+ @mutex = Mutex.new # covers the following variables:
63
+ @threads = []
64
+ @hidden_threads = {}
65
+ @size_widget_width = nil
66
+ @size_widgets = []
67
+ @date_widget_width = nil
68
+ @date_widgets = []
69
+ @tags = Tagger.new self
70
+
71
+ ## these guys, and @text and @lines, are not covered
72
+ @load_thread = nil
73
+ @load_thread_opts = load_thread_opts
74
+ @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
75
+ @date_width = DATE_WIDTH
76
+
77
+ @interrupt_search = false
78
+
79
+ initialize_threads # defines @ts and @ts_mutex
80
+ update # defines @text and @lines
81
+
82
+ UpdateManager.register self
83
+
84
+ @save_thread_mutex = Mutex.new
85
+
86
+ @last_load_more_size = nil
87
+ to_load_more do |size|
88
+ next if @last_load_more_size == 0
89
+ load_threads :num => size,
90
+ :when_done => lambda { |num| @last_load_more_size = num }
91
+ end
92
+ end
93
+
94
+ def unsaved?; dirty? end
95
+ def lines; @text.length; end
96
+ def [] i; @text[i]; end
97
+ def contains_thread? t; @threads.include?(t) end
98
+
99
+ def reload
100
+ drop_all_threads
101
+ UndoManager.clear
102
+ BufferManager.draw_screen
103
+ load_threads :num => buffer.content_height
104
+ end
105
+
106
+ ## open up a thread view window
107
+ def select t=nil, when_done=nil
108
+ t ||= cursor_thread or return
109
+
110
+ Redwood::reporting_thread("load messages for thread-view-mode") do
111
+ num = t.size
112
+ message = "Loading #{num.pluralize 'message body'}..."
113
+ BufferManager.say(message) do |sid|
114
+ t.each_with_index do |(m, *_), i|
115
+ next unless m
116
+ BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
117
+ m.load_from_source!
118
+ end
119
+ end
120
+ mode = ThreadViewMode.new t, @hidden_labels, self
121
+ BufferManager.spawn t.subj, mode
122
+ BufferManager.draw_screen
123
+ mode.jump_to_first_open if $config[:jump_to_open_message]
124
+ BufferManager.draw_screen # lame TODO: make this unnecessary
125
+ ## the first draw_screen is needed before topline and botline
126
+ ## are set, and the second to show the cursor having moved
127
+
128
+ t.remove_label :unread
129
+ Index.save_thread t
130
+
131
+ update_text_for_line curpos
132
+ UpdateManager.relay self, :read, t.first
133
+ when_done.call if when_done
134
+ end
135
+ end
136
+
137
+ def multi_select threads
138
+ threads.each { |t| select t }
139
+ end
140
+
141
+ ## these two methods are called by thread-view-modes when the user
142
+ ## wants to view the previous/next thread without going back to
143
+ ## index-mode. we update the cursor as a convenience.
144
+ def launch_next_thread_after thread, &b
145
+ launch_another_thread thread, 1, &b
146
+ end
147
+
148
+ def launch_prev_thread_before thread, &b
149
+ launch_another_thread thread, -1, &b
150
+ end
151
+
152
+ def launch_another_thread thread, direction, &b
153
+ l = @lines[thread] or return
154
+ target_l = l + direction
155
+ t = @mutex.synchronize do
156
+ if target_l >= 0 && target_l < @threads.length
157
+ @threads[target_l]
158
+ end
159
+ end
160
+
161
+ if t # there's a next thread
162
+ set_cursor_pos target_l # move out of mutex?
163
+ select t, b
164
+ elsif b # no next thread. call the block anyways
165
+ b.call
166
+ end
167
+ end
168
+
169
+ def handle_single_message_labeled_update sender, m
170
+ ## no need to do anything different here; we don't differentiate
171
+ ## messages from their containing threads
172
+ handle_labeled_update sender, m
173
+ end
174
+
175
+ def handle_labeled_update sender, m
176
+ if(t = thread_containing(m))
177
+ l = @lines[t] or return
178
+ update_text_for_line l
179
+ elsif is_relevant?(m)
180
+ add_or_unhide m
181
+ end
182
+ end
183
+
184
+ def handle_simple_update sender, m
185
+ t = thread_containing(m) or return
186
+ l = @lines[t] or return
187
+ update_text_for_line l
188
+ end
189
+
190
+ %w(read unread archived starred unstarred).each do |state|
191
+ define_method "handle_#{state}_update" do |*a|
192
+ handle_simple_update(*a)
193
+ end
194
+ end
195
+
196
+ ## overwrite me!
197
+ def is_relevant? m; false; end
198
+
199
+ def handle_added_update sender, m
200
+ add_or_unhide m
201
+ BufferManager.draw_screen
202
+ end
203
+
204
+ def handle_updated_update sender, m
205
+ t = thread_containing(m) or return
206
+ l = @lines[t] or return
207
+ @ts_mutex.synchronize do
208
+ @ts.delete_message m
209
+ @ts.add_message m
210
+ end
211
+ Index.save_thread t, sync_back = false
212
+ update_text_for_line l
213
+ end
214
+
215
+ def handle_location_deleted_update sender, m
216
+ t = thread_containing(m)
217
+ delete_thread t if t and t.first.id == m.id
218
+ @ts_mutex.synchronize do
219
+ @ts.delete_message m if t
220
+ end
221
+ update
222
+ end
223
+
224
+ def handle_single_message_deleted_update sender, m
225
+ @ts_mutex.synchronize do
226
+ return unless @ts.contains? m
227
+ @ts.remove_id m.id
228
+ end
229
+ update
230
+ end
231
+
232
+ def handle_deleted_update sender, m
233
+ t = @ts_mutex.synchronize { @ts.thread_for m }
234
+ return unless t
235
+ hide_thread t
236
+ update
237
+ end
238
+
239
+ def handle_killed_update sender, m
240
+ t = @ts_mutex.synchronize { @ts.thread_for m }
241
+ return unless t
242
+ hide_thread t
243
+ update
244
+ end
245
+
246
+ def handle_spammed_update sender, m
247
+ t = @ts_mutex.synchronize { @ts.thread_for m }
248
+ return unless t
249
+ hide_thread t
250
+ update
251
+ end
252
+
253
+ def handle_undeleted_update sender, m
254
+ add_or_unhide m
255
+ end
256
+
257
+ def handle_unkilled_update sender, m
258
+ add_or_unhide m
259
+ end
260
+
261
+ def undo
262
+ UndoManager.undo
263
+ end
264
+
265
+ def update
266
+ old_cursor_thread = cursor_thread
267
+ @mutex.synchronize do
268
+ ## let's see you do THIS in python
269
+ @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
270
+ @size_widgets = @threads.map { |t| size_widget_for_thread t }
271
+ @size_widget_width = @size_widgets.max_of { |w| w.display_length }
272
+ @date_widgets = @threads.map { |t| date_widget_for_thread t }
273
+ @date_widget_width = @date_widgets.max_of { |w| w.display_length }
274
+ end
275
+ set_cursor_pos @threads.index(old_cursor_thread)||curpos
276
+
277
+ regen_text
278
+ end
279
+
280
+ def edit_message
281
+ return unless(t = cursor_thread)
282
+ message, *_ = t.find { |m, *o| m.has_label? :draft }
283
+ if message
284
+ mode = ResumeMode.new message
285
+ BufferManager.spawn "Edit message", mode
286
+ else
287
+ BufferManager.flash "Not a draft message!"
288
+ end
289
+ end
290
+
291
+ ## returns an undo lambda
292
+ def actually_toggle_starred t
293
+ if t.has_label? :starred # if ANY message has a star
294
+ t.remove_label :starred # remove from all
295
+ UpdateManager.relay self, :unstarred, t.first
296
+ lambda do
297
+ t.first.add_label :starred
298
+ UpdateManager.relay self, :starred, t.first
299
+ regen_text
300
+ end
301
+ else
302
+ t.first.add_label :starred # add only to first
303
+ UpdateManager.relay self, :starred, t.first
304
+ lambda do
305
+ t.remove_label :starred
306
+ UpdateManager.relay self, :unstarred, t.first
307
+ regen_text
308
+ end
309
+ end
310
+ end
311
+
312
+ def toggle_starred
313
+ t = cursor_thread or return
314
+ undo = actually_toggle_starred t
315
+ UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
316
+ update_text_for_line curpos
317
+ cursor_down
318
+ Index.save_thread t
319
+ end
320
+
321
+ def multi_toggle_starred threads
322
+ UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
323
+ threads.map { |t| actually_toggle_starred t },
324
+ lambda { threads.each { |t| Index.save_thread t } }
325
+ regen_text
326
+ threads.each { |t| Index.save_thread t }
327
+ end
328
+
329
+ ## returns an undo lambda
330
+ def actually_toggle_archived t
331
+ thread = t
332
+ pos = curpos
333
+ if t.has_label? :inbox
334
+ t.remove_label :inbox
335
+ UpdateManager.relay self, :archived, t.first
336
+ lambda do
337
+ thread.apply_label :inbox
338
+ update_text_for_line pos
339
+ UpdateManager.relay self,:unarchived, thread.first
340
+ end
341
+ else
342
+ t.apply_label :inbox
343
+ UpdateManager.relay self, :unarchived, t.first
344
+ lambda do
345
+ thread.remove_label :inbox
346
+ update_text_for_line pos
347
+ UpdateManager.relay self, :unarchived, thread.first
348
+ end
349
+ end
350
+ end
351
+
352
+ ## returns an undo lambda
353
+ def actually_toggle_spammed t
354
+ thread = t
355
+ if t.has_label? :spam
356
+ t.remove_label :spam
357
+ add_or_unhide t.first
358
+ UpdateManager.relay self, :unspammed, t.first
359
+ lambda do
360
+ thread.apply_label :spam
361
+ self.hide_thread thread
362
+ UpdateManager.relay self,:spammed, thread.first
363
+ end
364
+ else
365
+ t.apply_label :spam
366
+ hide_thread t
367
+ UpdateManager.relay self, :spammed, t.first
368
+ lambda do
369
+ thread.remove_label :spam
370
+ add_or_unhide thread.first
371
+ UpdateManager.relay self,:unspammed, thread.first
372
+ end
373
+ end
374
+ end
375
+
376
+ ## returns an undo lambda
377
+ def actually_toggle_deleted t
378
+ if t.has_label? :deleted
379
+ t.remove_label :deleted
380
+ add_or_unhide t.first
381
+ UpdateManager.relay self, :undeleted, t.first
382
+ lambda do
383
+ t.apply_label :deleted
384
+ hide_thread t
385
+ UpdateManager.relay self, :deleted, t.first
386
+ end
387
+ else
388
+ t.apply_label :deleted
389
+ hide_thread t
390
+ UpdateManager.relay self, :deleted, t.first
391
+ lambda do
392
+ t.remove_label :deleted
393
+ add_or_unhide t.first
394
+ UpdateManager.relay self, :undeleted, t.first
395
+ end
396
+ end
397
+ end
398
+
399
+ def toggle_archived
400
+ t = cursor_thread or return
401
+ undo = actually_toggle_archived t
402
+ UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
403
+ lambda { Index.save_thread t }
404
+ update_text_for_line curpos
405
+ Index.save_thread t
406
+ end
407
+
408
+ def multi_toggle_archived threads
409
+ undos = threads.map { |t| actually_toggle_archived t }
410
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
411
+ lambda { threads.each { |t| Index.save_thread t } }
412
+ regen_text
413
+ threads.each { |t| Index.save_thread t }
414
+ end
415
+
416
+ def toggle_new
417
+ t = cursor_thread or return
418
+ t.toggle_label :unread
419
+ update_text_for_line curpos
420
+ cursor_down
421
+ Index.save_thread t
422
+ end
423
+
424
+ def multi_toggle_new threads
425
+ threads.each { |t| t.toggle_label :unread }
426
+ regen_text
427
+ threads.each { |t| Index.save_thread t }
428
+ end
429
+
430
+ def multi_toggle_tagged threads
431
+ @mutex.synchronize { @tags.drop_all_tags }
432
+ regen_text
433
+ end
434
+
435
+ def join_threads
436
+ ## this command has no non-tagged form. as a convenience, allow this
437
+ ## command to be applied to tagged threads without hitting ';'.
438
+ @tags.apply_to_tagged :join_threads
439
+ end
440
+
441
+ def multi_join_threads threads
442
+ @ts.join_threads threads or return
443
+ threads.each { |t| Index.save_thread t }
444
+ @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
445
+ update
446
+ end
447
+
448
+ def jump_to_next_new
449
+ n = @mutex.synchronize do
450
+ ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
451
+ (0 ... curpos).find { |i| @threads[i].has_label? :unread }
452
+ end
453
+ if n
454
+ ## jump there if necessary
455
+ jump_to_line n unless n >= topline && n < botline
456
+ set_cursor_pos n
457
+ else
458
+ BufferManager.flash "No new messages."
459
+ end
460
+ end
461
+
462
+ def toggle_spam
463
+ t = cursor_thread or return
464
+ multi_toggle_spam [t]
465
+ end
466
+
467
+ ## both spam and deleted have the curious characteristic that you
468
+ ## always want to hide the thread after either applying or removing
469
+ ## that label. in all thread-index-views except for
470
+ ## label-search-results-mode, when you mark a message as spam or
471
+ ## deleted, you want it to disappear immediately; in LSRM, you only
472
+ ## see deleted or spam emails, and when you undelete or unspam them
473
+ ## you also want them to disappear immediately.
474
+ def multi_toggle_spam threads
475
+ undos = threads.map { |t| actually_toggle_spammed t }
476
+ threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
477
+ UndoManager.register "marking/unmarking #{threads.size.pluralize 'thread'} as spam",
478
+ undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
479
+ regen_text
480
+ threads.each { |t| Index.save_thread t }
481
+ end
482
+
483
+ def toggle_deleted
484
+ t = cursor_thread or return
485
+ multi_toggle_deleted [t]
486
+ end
487
+
488
+ ## see comment for multi_toggle_spam
489
+ def multi_toggle_deleted threads
490
+ undos = threads.map { |t| actually_toggle_deleted t }
491
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
492
+ undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
493
+ regen_text
494
+ threads.each { |t| Index.save_thread t }
495
+ end
496
+
497
+ def kill
498
+ t = cursor_thread or return
499
+ multi_kill [t]
500
+ end
501
+
502
+ def flush_index
503
+ @flush_id = BufferManager.say "Flushing index..."
504
+ Index.save_index
505
+ BufferManager.clear @flush_id
506
+ end
507
+
508
+ ## m-m-m-m-MULTI-KILL
509
+ def multi_kill threads
510
+ UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
511
+ threads.each do |t|
512
+ if t.toggle_label :killed
513
+ add_or_unhide t.first
514
+ else
515
+ hide_thread t
516
+ end
517
+ end.each do |t|
518
+ UpdateManager.relay self, :labeled, t.first
519
+ Index.save_thread t
520
+ end
521
+ regen_text
522
+ end
523
+
524
+ threads.each do |t|
525
+ if t.toggle_label :killed
526
+ hide_thread t
527
+ else
528
+ add_or_unhide t.first
529
+ end
530
+ end.each do |t|
531
+ # send 'labeled'... this might be more specific
532
+ UpdateManager.relay self, :labeled, t.first
533
+ Index.save_thread t
534
+ end
535
+
536
+ killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
537
+ BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
538
+ regen_text
539
+ end
540
+
541
+ def cleanup
542
+ UpdateManager.unregister self
543
+
544
+ if @load_thread
545
+ @load_thread.kill
546
+ BufferManager.clear @mbid if @mbid
547
+ sleep 0.1 # TODO: necessary?
548
+ BufferManager.erase_flash
549
+ end
550
+ dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
551
+ fail "dirty threads remain" unless dirty_threads.empty?
552
+ super
553
+ end
554
+
555
+ def toggle_tagged
556
+ t = cursor_thread or return
557
+ @mutex.synchronize { @tags.toggle_tag_for t }
558
+ update_text_for_line curpos
559
+ cursor_down
560
+ end
561
+
562
+ def toggle_tagged_all
563
+ @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
564
+ regen_text
565
+ end
566
+
567
+ def tag_matching
568
+ query = BufferManager.ask :search, "tag threads matching (regex): "
569
+ return if query.nil? || query.empty?
570
+ query = begin
571
+ /#{query}/i
572
+ rescue RegexpError => e
573
+ BufferManager.flash "error interpreting '#{query}': #{e.message}"
574
+ return
575
+ end
576
+ @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
577
+ regen_text
578
+ end
579
+
580
+ def apply_to_tagged; @tags.apply_to_tagged; end
581
+
582
+ def edit_labels
583
+ thread = cursor_thread or return
584
+ speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
585
+
586
+ old_labels = thread.labels
587
+ pos = curpos
588
+
589
+ keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
590
+
591
+ user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
592
+ return unless user_labels
593
+
594
+ thread.labels = Set.new(keepl) + user_labels
595
+ user_labels.each { |l| LabelManager << l }
596
+ update_text_for_line curpos
597
+
598
+ UndoManager.register "labeling thread" do
599
+ thread.labels = old_labels
600
+ update_text_for_line pos
601
+ UpdateManager.relay self, :labeled, thread.first
602
+ Index.save_thread thread
603
+ end
604
+
605
+ UpdateManager.relay self, :labeled, thread.first
606
+ Index.save_thread thread
607
+ end
608
+
609
+ def multi_edit_labels threads
610
+ user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
611
+ return unless user_labels
612
+
613
+ user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
614
+ hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
615
+ unless hl.empty?
616
+ BufferManager.flash "'#{hl}' is a reserved label!"
617
+ return
618
+ end
619
+
620
+ old_labels = threads.map { |t| t.labels.dup }
621
+
622
+ threads.each do |t|
623
+ user_labels.each do |(l, to_remove)|
624
+ if to_remove
625
+ t.remove_label l
626
+ else
627
+ t.apply_label l
628
+ LabelManager << l
629
+ end
630
+ end
631
+ UpdateManager.relay self, :labeled, t.first
632
+ end
633
+
634
+ regen_text
635
+
636
+ UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
637
+ threads.zip(old_labels).map do |t, old_labels|
638
+ t.labels = old_labels
639
+ UpdateManager.relay self, :labeled, t.first
640
+ Index.save_thread t
641
+ end
642
+ regen_text
643
+ end
644
+
645
+ threads.each { |t| Index.save_thread t }
646
+ end
647
+
648
+ def reply type_arg=nil
649
+ t = cursor_thread or return
650
+ m = t.latest_message
651
+ return if m.nil? # probably won't happen
652
+ m.load_from_source!
653
+ mode = ReplyMode.new m, type_arg
654
+ BufferManager.spawn "Reply to #{m.subj}", mode
655
+ end
656
+
657
+ def reply_all; reply :all; end
658
+
659
+ def forward
660
+ t = cursor_thread or return
661
+ m = t.latest_message
662
+ return if m.nil? # probably won't happen
663
+ m.load_from_source!
664
+ ForwardMode.spawn_nicely :message => m
665
+ end
666
+
667
+ def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
668
+ return if @load_thread # todo: wrap in mutex
669
+ @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
670
+ num = load_n_threads n, opts
671
+ opts[:when_done].call(num) if opts[:when_done]
672
+ @load_thread = nil
673
+ end
674
+ end
675
+
676
+ ## TODO: figure out @ts_mutex in this method
677
+ def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
678
+ @interrupt_search = false
679
+ @mbid = BufferManager.say "Searching for threads..."
680
+
681
+ ts_to_load = n
682
+ ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
683
+
684
+ orig_size = @ts.size
685
+ last_update = Time.now
686
+ @ts.load_n_threads(ts_to_load, opts) do |i|
687
+ if (Time.now - last_update) >= 0.25
688
+ BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
689
+ update
690
+ BufferManager.draw_screen
691
+ last_update = Time.now
692
+ end
693
+ ::Thread.pass
694
+ break if @interrupt_search
695
+ end
696
+ @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
697
+
698
+ update
699
+ BufferManager.clear @mbid
700
+ @mbid = nil
701
+ BufferManager.draw_screen
702
+ @ts.size - orig_size
703
+ end
704
+ ignore_concurrent_calls :load_n_threads
705
+
706
+ def status
707
+ if (l = lines) == 0
708
+ "line 0 of 0"
709
+ else
710
+ "line #{curpos + 1} of #{l}"
711
+ end
712
+ end
713
+
714
+ def cancel_search
715
+ @interrupt_search = true
716
+ end
717
+
718
+ def load_all_threads
719
+ load_threads :num => -1
720
+ end
721
+
722
+ def load_threads opts={}
723
+ if opts[:num].nil?
724
+ n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
725
+ else
726
+ n = opts[:num]
727
+ end
728
+
729
+ myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
730
+ opts[:when_done].call(num) if opts[:when_done]
731
+
732
+ if num > 0
733
+ BufferManager.flash "Found #{num.pluralize 'thread'}."
734
+ else
735
+ BufferManager.flash "No matches."
736
+ end
737
+ end)})
738
+
739
+ if opts[:background] || opts[:background].nil?
740
+ load_n_threads_background n, myopts
741
+ else
742
+ load_n_threads n, myopts
743
+ end
744
+ end
745
+ ignore_concurrent_calls :load_threads
746
+
747
+ def read_and_archive
748
+ return unless cursor_thread
749
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
750
+
751
+ was_unread = thread.labels.member? :unread
752
+ UndoManager.register "reading and archiving thread" do
753
+ thread.apply_label :inbox
754
+ thread.apply_label :unread if was_unread
755
+ add_or_unhide thread.first
756
+ Index.save_thread thread
757
+ end
758
+
759
+ cursor_thread.remove_label :unread
760
+ cursor_thread.remove_label :inbox
761
+ hide_thread cursor_thread
762
+ regen_text
763
+ Index.save_thread thread
764
+ end
765
+
766
+ def multi_read_and_archive threads
767
+ old_labels = threads.map { |t| t.labels.dup }
768
+
769
+ threads.each do |t|
770
+ t.remove_label :unread
771
+ t.remove_label :inbox
772
+ hide_thread t
773
+ end
774
+ regen_text
775
+
776
+ UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
777
+ threads.zip(old_labels).each do |t, l|
778
+ t.labels = l
779
+ add_or_unhide t.first
780
+ Index.save_thread t
781
+ end
782
+ regen_text
783
+ end
784
+
785
+ threads.each { |t| Index.save_thread t }
786
+ end
787
+
788
+ def resize rows, cols
789
+ regen_text
790
+ super
791
+ end
792
+
793
+ protected
794
+
795
+ def add_or_unhide m
796
+ @ts_mutex.synchronize do
797
+ if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
798
+ @ts.load_thread_for_message m, @load_thread_opts
799
+ end
800
+
801
+ @hidden_threads.delete @ts.thread_for(m)
802
+ end
803
+
804
+ update
805
+ end
806
+
807
+ def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
808
+
809
+ ## used to tag threads by query. this can be made a lot more sophisticated,
810
+ ## but for right now we'll do the obvious this.
811
+ def thread_matches? t, query
812
+ t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
813
+ end
814
+
815
+ def size_widget_for_thread t
816
+ HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
817
+ end
818
+
819
+ def date_widget_for_thread t
820
+ HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
821
+ end
822
+
823
+ def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
824
+
825
+ def drop_all_threads
826
+ @tags.drop_all_tags
827
+ initialize_threads
828
+ update
829
+ end
830
+
831
+ def delete_thread t
832
+ @mutex.synchronize do
833
+ i = @threads.index(t) or return
834
+ @threads.delete_at i
835
+ @size_widgets.delete_at i
836
+ @date_widgets.delete_at i
837
+ @tags.drop_tag_for t
838
+ end
839
+ end
840
+
841
+ def hide_thread t
842
+ @mutex.synchronize do
843
+ i = @threads.index(t) or return
844
+ raise "already hidden" if @hidden_threads[t]
845
+ @hidden_threads[t] = true
846
+ @threads.delete_at i
847
+ @size_widgets.delete_at i
848
+ @date_widgets.delete_at i
849
+ @tags.drop_tag_for t
850
+ end
851
+ end
852
+
853
+ def update_text_for_line l
854
+ return unless l # not sure why this happens, but it does, occasionally
855
+
856
+ need_update = false
857
+
858
+ @mutex.synchronize do
859
+ @size_widgets[l] = size_widget_for_thread @threads[l]
860
+ @date_widgets[l] = date_widget_for_thread @threads[l]
861
+
862
+ ## if a widget size has increased, we need to redraw everyone
863
+ need_update =
864
+ (@size_widgets[l].size > @size_widget_width) or
865
+ (@date_widgets[l].size > @date_widget_width)
866
+ end
867
+
868
+ if need_update
869
+ update
870
+ else
871
+ @text[l] = text_for_thread_at l
872
+ buffer.mark_dirty if buffer
873
+ end
874
+ end
875
+
876
+ def regen_text
877
+ threads = @mutex.synchronize { @threads }
878
+ @text = threads.map_with_index { |t, i| text_for_thread_at i }
879
+ @lines = threads.map_with_index { |t, i| [t, i] }.to_h
880
+ buffer.mark_dirty if buffer
881
+ end
882
+
883
+ def authors; map { |m, *o| m.from if m }.compact.uniq; end
884
+
885
+ ## preserve author order from the thread
886
+ def author_names_and_newness_for_thread t, limit=nil
887
+ new = {}
888
+ seen = {}
889
+ authors = t.map do |m, *o|
890
+ next unless m && m.from
891
+ new[m.from] ||= m.has_label?(:unread)
892
+ next if seen[m.from]
893
+ seen[m.from] = true
894
+ m.from
895
+ end.compact
896
+
897
+ result = []
898
+ authors.each do |a|
899
+ break if limit && result.size >= limit
900
+ name = if AccountManager.is_account?(a)
901
+ "me"
902
+ elsif t.authors.size == 1
903
+ a.mediumname
904
+ else
905
+ a.shortname
906
+ end
907
+
908
+ result << [name, new[a]]
909
+ end
910
+
911
+ if result.size == 1 && (author_and_newness = result.assoc("me"))
912
+ unless (recipients = t.participants - t.authors).empty?
913
+ result = recipients.collect do |r|
914
+ break if limit && result.size >= limit
915
+ name = (recipients.size == 1) ? r.mediumname : r.shortname
916
+ ["(#{name})", author_and_newness[1]]
917
+ end
918
+ end
919
+ end
920
+
921
+ result
922
+ end
923
+
924
+ AUTHOR_LIMIT = 5
925
+ def text_for_thread_at line
926
+ t, size_widget, date_widget = @mutex.synchronize do
927
+ [@threads[line], @size_widgets[line], @date_widgets[line]]
928
+ end
929
+
930
+ starred = t.has_label? :starred
931
+
932
+ ## format the from column
933
+ cur_width = 0
934
+ ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
935
+ from = []
936
+ ann.each_with_index do |(name, newness), i|
937
+ break if cur_width >= from_width
938
+ last = i == ann.length - 1
939
+
940
+ abbrev =
941
+ if cur_width + name.display_length > from_width
942
+ name.slice_by_display_length(from_width - cur_width - 1) + "."
943
+ elsif cur_width + name.display_length == from_width
944
+ name.slice_by_display_length(from_width - cur_width)
945
+ else
946
+ if last
947
+ name.slice_by_display_length(from_width - cur_width)
948
+ else
949
+ name.slice_by_display_length(from_width - cur_width - 1) + ","
950
+ end
951
+ end
952
+
953
+ cur_width += abbrev.display_length
954
+
955
+ if last && from_width > cur_width
956
+ abbrev += " " * (from_width - cur_width)
957
+ end
958
+
959
+ from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
960
+ end
961
+
962
+ is_me = AccountManager.method(:is_account?)
963
+ directly_participated = t.direct_participants.any?(&is_me)
964
+ participated = directly_participated || t.participants.any?(&is_me)
965
+
966
+ subj_color =
967
+ if t.has_label?(:draft)
968
+ :index_draft_color
969
+ elsif t.has_label?(:unread)
970
+ :index_new_color
971
+ elsif starred
972
+ :index_starred_color
973
+ elsif Colormap.sym_is_defined(:index_subject_color)
974
+ :index_subject_color
975
+ else
976
+ :index_old_color
977
+ end
978
+
979
+ size_padding = @size_widget_width - size_widget.display_length
980
+ size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
981
+
982
+ date_padding = @date_widget_width - date_widget.display_length
983
+ date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
984
+
985
+ [
986
+ [:tagged_color, @tags.tagged?(t) ? ">" : " "],
987
+ [:date_color, date_widget_text],
988
+ [:starred_color, (starred ? "*" : " ")],
989
+ ] +
990
+ from +
991
+ [
992
+ [:size_widget_color, size_widget_text],
993
+ [:with_attachment_color , t.labels.member?(:attachment) ? "@" : " "],
994
+ [:to_me_color, directly_participated ? ">" : (participated ? '+' : " ")],
995
+ ] +
996
+ (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
997
+ |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
998
+ } +
999
+ [
1000
+ [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
1001
+ [:snippet_color, t.snippet],
1002
+ ]
1003
+ end
1004
+
1005
+ def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
1006
+
1007
+ private
1008
+
1009
+ def default_size_widget_for t
1010
+ case t.size
1011
+ when 1
1012
+ ""
1013
+ else
1014
+ "(#{t.size})"
1015
+ end
1016
+ end
1017
+
1018
+ def default_date_widget_for t
1019
+ t.date.getlocal.to_nice_s
1020
+ end
1021
+
1022
+ def from_width
1023
+ [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
1024
+ end
1025
+
1026
+ def initialize_threads
1027
+ @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
1028
+ @ts_mutex = Mutex.new
1029
+ @hidden_threads = {}
1030
+ end
1031
+ end
1032
+
1033
+ end