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,50 @@
1
+ require 'sup/util/ncurses'
2
+
3
+ module Redwood
4
+
5
+ class Tagger
6
+ def initialize mode, noun="thread", plural_noun=nil
7
+ @mode = mode
8
+ @tagged = {}
9
+ @noun = noun
10
+ @plural_noun = plural_noun || (@noun + "s")
11
+ end
12
+
13
+ def tagged? o; @tagged[o]; end
14
+ def toggle_tag_for o; @tagged[o] = !@tagged[o]; end
15
+ def tag o; @tagged[o] = true; end
16
+ def untag o; @tagged[o] = false; end
17
+ def drop_all_tags; @tagged.clear; end
18
+ def drop_tag_for o; @tagged.delete o; end
19
+
20
+ def apply_to_tagged action=nil
21
+ targets = @tagged.select_by_value
22
+ num_tagged = targets.size
23
+ if num_tagged == 0
24
+ BufferManager.flash "No tagged threads!"
25
+ return
26
+ end
27
+
28
+ noun = num_tagged == 1 ? @noun : @plural_noun
29
+
30
+ unless action
31
+ c = BufferManager.ask_getch "apply to #{num_tagged} tagged #{noun}:"
32
+ return if c.empty? # user cancelled
33
+ action = @mode.resolve_input c
34
+ end
35
+
36
+ if action
37
+ tagged_sym = "multi_#{action}".intern
38
+ if @mode.respond_to? tagged_sym
39
+ @mode.send tagged_sym, targets
40
+ else
41
+ BufferManager.flash "That command cannot be applied to multiple threads."
42
+ end
43
+ else
44
+ BufferManager.flash "Unknown command #{c.to_character}."
45
+ end
46
+ end
47
+
48
+ end
49
+
50
+ end
@@ -0,0 +1,253 @@
1
+ require 'sup/util/ncurses'
2
+
3
+ module Redwood
4
+
5
+ ## a fully-functional text field supporting completions, expansions,
6
+ ## history--everything!
7
+ ##
8
+ ## writing this fucking sucked. if you thought ncurses was some 1970s
9
+ ## before-people-knew-how-to-program bullshit, wait till you see
10
+ ## ncurses forms.
11
+ ##
12
+ ## completion comments: completion is done emacs-style, and mostly
13
+ ## depends on outside support, as we merely signal the existence of a
14
+ ## new set of completions to show (#new_completions?) or that the
15
+ ## current list of completions should be rolled if they're too large
16
+ ## to fill the screen (#roll_completions?).
17
+ ##
18
+ ## in sup, completion support is implemented through BufferManager#ask
19
+ ## and CompletionMode.
20
+ class TextField
21
+ include Ncurses::Form::DriverHelpers
22
+
23
+ def initialize
24
+ @i = nil
25
+ @history = []
26
+
27
+ @completion_block = nil
28
+ reset_completion_state
29
+ end
30
+
31
+ bool_reader :new_completions, :roll_completions
32
+ attr_reader :completions
33
+
34
+ def value; @value || get_cursed_value end
35
+
36
+ def activate window, y, x, width, question, default=nil, &block
37
+ @w, @y, @x, @width = window, y, x, width
38
+ @question = question
39
+ @completion_block = block
40
+ @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 0, 0
41
+ if @field.respond_to? :opts_off
42
+ @field.opts_off Ncurses::Form::O_STATIC
43
+ @field.opts_off Ncurses::Form::O_BLANK
44
+ end
45
+ @form = Ncurses::Form.new_form [@field]
46
+ @value = default || ''
47
+ Ncurses::Form.post_form @form
48
+ set_cursed_value @value
49
+ end
50
+
51
+ def position_cursor
52
+ @w.attrset Colormap.color_for(:none)
53
+ @w.mvaddstr @y, 0, @question
54
+ Ncurses.curs_set 1
55
+ form_driver_key Ncurses::Form::REQ_END_FIELD
56
+ form_driver_key Ncurses::Form::REQ_NEXT_CHAR if @value && @value =~ / $/ # fucking RETARDED
57
+ end
58
+
59
+ def deactivate
60
+ reset_completion_state
61
+ @form.unpost_form
62
+ @form.free_form
63
+ @field.free_field
64
+ @field = nil
65
+ Ncurses.curs_set 0
66
+ end
67
+
68
+ def handle_input c
69
+ ## short-circuit exit paths
70
+ case c.code
71
+ when Ncurses::KEY_ENTER # submit!
72
+ @value = get_cursed_value
73
+ @history.push @value unless @value =~ /^\s*$/
74
+ @i = @history.size
75
+ return false
76
+ when Ncurses::KEY_CANCEL # cancel
77
+ @value = nil
78
+ return false
79
+ when Ncurses::KEY_TAB # completion
80
+ return true unless @completion_block
81
+ if @completions.empty?
82
+ v = get_cursed_value
83
+ c = @completion_block.call v
84
+ if c.size > 0
85
+ @value = c.map { |full, short| full }.shared_prefix(true)
86
+ set_cursed_value @value
87
+ position_cursor
88
+ end
89
+ if c.size > 1
90
+ @completions = c
91
+ @new_completions = true
92
+ @roll_completions = false
93
+ end
94
+ else
95
+ @new_completions = false
96
+ @roll_completions = true
97
+ end
98
+ return true
99
+ end
100
+
101
+ reset_completion_state
102
+ @value = nil
103
+
104
+ # ctrl_c: control char
105
+ ctrl_c =
106
+ case c.keycode # only test for keycodes
107
+ when Ncurses::KEY_LEFT
108
+ Ncurses::Form::REQ_PREV_CHAR
109
+ when Ncurses::KEY_RIGHT
110
+ Ncurses::Form::REQ_NEXT_CHAR
111
+ when Ncurses::KEY_DC
112
+ Ncurses::Form::REQ_DEL_CHAR
113
+ when Ncurses::KEY_BACKSPACE
114
+ Ncurses::Form::REQ_DEL_PREV
115
+ when Ncurses::KEY_HOME
116
+ nop
117
+ Ncurses::Form::REQ_BEG_FIELD
118
+ when Ncurses::KEY_END
119
+ Ncurses::Form::REQ_END_FIELD
120
+ when Ncurses::KEY_UP, Ncurses::KEY_DOWN
121
+ unless !@i || @history.empty?
122
+ value = get_cursed_value
123
+ #debug "history before #{@history.inspect}"
124
+ @i = @i + (c.is_keycode?(Ncurses::KEY_UP) ? -1 : 1)
125
+ @i = 0 if @i < 0
126
+ @i = @history.size if @i > @history.size
127
+ @value = @history[@i] || ''
128
+ #debug "history after #{@history.inspect}"
129
+ set_cursed_value @value
130
+ Ncurses::Form::REQ_END_FIELD
131
+ end
132
+ else
133
+ # return other keycode or nil if it's not a keycode
134
+ c.dumb? ? nil : c.keycode
135
+ end
136
+
137
+ # handle keysyms
138
+ # ctrl_c: control char
139
+ ctrl_c = case c
140
+ when ?\177 # backspace (octal)
141
+ Ncurses::Form::REQ_DEL_PREV
142
+ when ?\C-a # home
143
+ nop
144
+ Ncurses::Form::REQ_BEG_FIELD
145
+ when ?\C-e # end keysym
146
+ Ncurses::Form::REQ_END_FIELD
147
+ when ?\C-k
148
+ Ncurses::Form::REQ_CLR_EOF
149
+ when ?\C-u
150
+ set_cursed_value cursed_value_after_point
151
+ form_driver_key Ncurses::Form::REQ_END_FIELD
152
+ nop
153
+ Ncurses::Form::REQ_BEG_FIELD
154
+ when ?\C-w
155
+ while action = remove_extra_space
156
+ form_driver_key action
157
+ end
158
+ form_driver_key Ncurses::Form::REQ_PREV_CHAR
159
+ form_driver_key Ncurses::Form::REQ_DEL_WORD
160
+ end if ctrl_c.nil?
161
+
162
+ c.replace(ctrl_c).keycode! if ctrl_c # no effect for dumb CharCode
163
+ form_driver c if c.present?
164
+ true
165
+ end
166
+
167
+ private
168
+
169
+ def reset_completion_state
170
+ @completions = []
171
+ @new_completions = @roll_completions = @clear_completions = false
172
+ end
173
+
174
+ ## ncurses inanity wrapper
175
+ ##
176
+ ## DO NOT READ THIS CODE. YOU WILL GO MAD.
177
+ def get_cursed_value
178
+ return nil unless @field
179
+
180
+ x = Ncurses.curx
181
+ form_driver_key Ncurses::Form::REQ_VALIDATION
182
+ v = @field.field_buffer(0).gsub(/^\s+|\s+$/, "")
183
+
184
+ ## cursor <= end of text
185
+ if x - @question.length - v.length <= 0
186
+ v
187
+ else # trailing spaces
188
+ v + (" " * (x - @question.length - v.length))
189
+ end
190
+
191
+ # ncurses returns a ASCII-8BIT (binary) string, which
192
+ # bytes presumably are of current charset encoding. we force_encoding
193
+ # so that the char representation / string is tagged will be the
194
+ # system locale and also hopefully the terminal/input encoding. an
195
+ # incorrectly configured terminal encoding (not matching the system
196
+ # encoding) will produce erronous results, but will also do that for
197
+ # a lot of other programs since it is impossible to detect which is
198
+ # which and what encoding the inputted byte chars are supposed to have.
199
+ v.force_encoding($encoding).fix_encoding!
200
+ end
201
+
202
+ def remove_extra_space
203
+ return nil unless @field
204
+
205
+ form_driver_key Ncurses::Form::REQ_VALIDATION
206
+ x = Ncurses.curx
207
+ v = @field.field_buffer(0).gsub(/^\s+|\s+$/, "")
208
+ v_index = x - @question.length
209
+
210
+ # at start of line
211
+ if v_index < 1
212
+ nil
213
+ ## cursor <= end of text
214
+ elsif v_index < v.length
215
+ # is the character before the cursor a space?
216
+ if v[v_index-1] == ?\s
217
+ # if there is a non-space char under cursor then go back
218
+ if v[v_index] != ?\s
219
+ Ncurses::Form::REQ_PREV_CHAR
220
+ # otherwise delete the space
221
+ else
222
+ Ncurses::Form::REQ_DEL_PREV
223
+ end
224
+ else
225
+ nil
226
+ end
227
+ elsif v_index == v.length
228
+ # at end of string, with non-space before us
229
+ nil
230
+ else
231
+ # trailing spaces
232
+ Ncurses::Form::REQ_PREV_CHAR
233
+ end
234
+ end
235
+
236
+ def set_cursed_value v
237
+ v = "" if v.nil?
238
+ @field.set_field_buffer 0, v
239
+ end
240
+
241
+ def cursed_value_after_point
242
+ point = Ncurses.curx - @question.length
243
+ get_cursed_value[point..-1]
244
+ end
245
+
246
+ ## this is almost certainly unnecessary, but it's the only way
247
+ ## i could get ncurses to remember my form's value
248
+ def nop
249
+ form_driver_char " "
250
+ form_driver_key Ncurses::Form::REQ_DEL_PREV
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,452 @@
1
+ # encoding: UTF-8
2
+ #
3
+ ## Herein lies all the code responsible for threading messages. It's
4
+ ## basically an online version of the JWZ threading algorithm:
5
+ ## http://www.jwz.org/doc/threading.html
6
+ ##
7
+ ## I didn't implement it for efficiency, but thanks to our search
8
+ ## engine backend, it's typically not applied to very many messages at
9
+ ## once.
10
+ ##
11
+ ## At the top level, we have a ThreadSet, which represents a set of
12
+ ## threads, e.g. a message folder or an inbox. Each ThreadSet contains
13
+ ## zero or more Threads. A Thread represents all the message related
14
+ ## to a particular subject. Each Thread has one or more Containers. A
15
+ ## Container is a recursive structure that holds the message tree as
16
+ ## determined by the references: and in-reply-to: headers. Each
17
+ ## Container holds zero or one messages. In the case of zero messages,
18
+ ## it means we've seen a reference to the message but haven't (yet)
19
+ ## seen the message itself.
20
+ ##
21
+ ## A Thread can have multiple top-level Containers if we decide to
22
+ ## group them together independent of tree structure, typically if
23
+ ## (e.g. due to someone using a primitive MUA) the messages have the
24
+ ## same subject but we don't have evidence from in-reply-to: or
25
+ ## references: headers. In this case Thread#each can optionally yield
26
+ ## a faked root object tying them all together into one tree
27
+ ## structure.
28
+
29
+ require 'set'
30
+
31
+ module Redwood
32
+
33
+ class Thread
34
+ include Enumerable
35
+
36
+ attr_reader :containers
37
+ def initialize
38
+ ## ah, the joys of a multithreaded application with a class called
39
+ ## "Thread". i keep instantiating the wrong one...
40
+ raise "wrong Thread class, buddy!" if block_given?
41
+ @containers = []
42
+ end
43
+
44
+ def << c
45
+ @containers << c
46
+ end
47
+
48
+ def empty?; @containers.empty?; end
49
+ def empty!; @containers.clear; end
50
+ def drop c; @containers.delete(c) or raise "bad drop"; end
51
+
52
+ ## unused
53
+ def dump f=$stdout
54
+ f.puts "=== start thread with #{@containers.length} trees ==="
55
+ @containers.each { |c| c.dump_recursive f; f.puts }
56
+ f.puts "=== end thread ==="
57
+ end
58
+
59
+ ## yields each message, its depth, and its parent. the message yield
60
+ ## parameter can be a Message object, or :fake_root, or nil (no
61
+ ## message found but the presence of one deduced from other
62
+ ## messages).
63
+ def each fake_root=false
64
+ adj = 0
65
+ root = @containers.find_all { |c| c.message && !Message.subj_is_reply?(c.message.subj) }.argmin { |c| c.date }
66
+
67
+ if root
68
+ adj = 1
69
+ root.first_useful_descendant.each_with_stuff do |c, d, par|
70
+ yield c.message, d, (par ? par.message : nil)
71
+ end
72
+ elsif @containers.length > 1 && fake_root
73
+ adj = 1
74
+ yield :fake_root, 0, nil
75
+ end
76
+
77
+ @containers.each do |cont|
78
+ next if cont == root
79
+ fud = cont.first_useful_descendant
80
+ fud.each_with_stuff do |c, d, par|
81
+ ## special case here: if we're an empty root that's already
82
+ ## been joined by a fake root, don't emit
83
+ yield c.message, d + adj, (par ? par.message : nil) unless
84
+ fake_root && c.message.nil? && root.nil? && c == fud
85
+ end
86
+ end
87
+ end
88
+
89
+ def first; each { |m, *o| return m if m }; nil; end
90
+ def has_message?; any? { |m, *o| m.is_a? Message }; end
91
+ def dirty?; any? { |m, *o| m && m.dirty? }; end
92
+ def date; map { |m, *o| m.date if m }.compact.max; end
93
+ def snippet
94
+ with_snippets = select { |m, *o| m && m.snippet && !m.snippet.empty? }
95
+ first_unread, * = with_snippets.select { |m, *o| m.has_label?(:unread) }.sort_by { |m, *o| m.date }.first
96
+ return first_unread.snippet if first_unread
97
+ last_read, * = with_snippets.sort_by { |m, *o| m.date }.last
98
+ return last_read.snippet if last_read
99
+ ""
100
+ end
101
+ def authors; map { |m, *o| m.from if m }.compact.uniq; end
102
+
103
+ def apply_label t; each { |m, *o| m && m.add_label(t) }; end
104
+ def remove_label t; each { |m, *o| m && m.remove_label(t) }; end
105
+
106
+ def toggle_label label
107
+ if has_label? label
108
+ remove_label label
109
+ false
110
+ else
111
+ apply_label label
112
+ true
113
+ end
114
+ end
115
+
116
+ def set_labels l; each { |m, *o| m && m.labels = l }; end
117
+ def has_label? t; any? { |m, *o| m && m.has_label?(t) }; end
118
+ def each_dirty_message; each { |m, *o| m && m.dirty? && yield(m) }; end
119
+
120
+ def direct_participants
121
+ map { |m, *o| [m.from] + m.to if m }.flatten.compact.uniq
122
+ end
123
+
124
+ def participants
125
+ map { |m, *o| [m.from] + m.to + m.cc + m.bcc if m }.flatten.compact.uniq
126
+ end
127
+
128
+ def size; map { |m, *o| m ? 1 : 0 }.sum; end
129
+ def subj; argfind { |m, *o| m && m.subj }; end
130
+ def labels; inject(Set.new) { |s, (m, *o)| m ? s | m.labels : s } end
131
+ def labels= l
132
+ raise ArgumentError, "not a set" unless l.is_a?(Set)
133
+ each { |m, *o| m && m.labels = l.dup }
134
+ end
135
+
136
+ def latest_message
137
+ inject(nil) do |a, b|
138
+ b = b.first
139
+ if a.nil?
140
+ b
141
+ elsif b.nil?
142
+ a
143
+ else
144
+ b.date > a.date ? b : a
145
+ end
146
+ end
147
+ end
148
+
149
+ def to_s
150
+ "<thread containing: #{@containers.join ', '}>"
151
+ end
152
+
153
+ def sort_key
154
+ m = latest_message
155
+ m ? [-m.date.to_i, m.id] : [-Time.now.to_i, ""]
156
+ end
157
+ end
158
+
159
+ ## recursive structure used internally to represent message trees as
160
+ ## described by reply-to: and references: headers.
161
+ ##
162
+ ## the 'id' field is the same as the message id. but the message might
163
+ ## be empty, in the case that we represent a message that was referenced
164
+ ## by another message (as an ancestor) but never received.
165
+ class Container
166
+ attr_accessor :message, :parent, :children, :id, :thread
167
+
168
+ def initialize id
169
+ raise "non-String #{id.inspect}" unless id.is_a? String
170
+ @id = id
171
+ @message, @parent, @thread = nil, nil, nil
172
+ @children = []
173
+ end
174
+
175
+ def each_with_stuff parent=nil
176
+ yield self, 0, parent
177
+ @children.sort_by(&:sort_key).each do |c|
178
+ c.each_with_stuff(self) { |cc, d, par| yield cc, d + 1, par }
179
+ end
180
+ end
181
+
182
+ def descendant_of? o
183
+ if o == self
184
+ true
185
+ else
186
+ @parent && @parent.descendant_of?(o)
187
+ end
188
+ end
189
+
190
+ def == o; Container === o && id == o.id; end
191
+
192
+ def empty?; @message.nil?; end
193
+ def root?; @parent.nil?; end
194
+ def root; root? ? self : @parent.root; end
195
+
196
+ ## skip over any containers which are empty and have only one child. we use
197
+ ## this make the threaded display a little nicer, and only stick in the
198
+ ## "missing message" line when it's graphically necessary, i.e. when the
199
+ ## missing message has more than one descendent.
200
+ def first_useful_descendant
201
+ if empty? && @children.size == 1
202
+ @children.first.first_useful_descendant
203
+ else
204
+ self
205
+ end
206
+ end
207
+
208
+ def find_attr attr
209
+ if empty?
210
+ @children.argfind { |c| c.find_attr attr }
211
+ else
212
+ @message.send attr
213
+ end
214
+ end
215
+ def subj; find_attr :subj; end
216
+ def date; find_attr :date; end
217
+
218
+ def is_reply?; subj && Message.subj_is_reply?(subj); end
219
+
220
+ def to_s
221
+ [ "<#{id}",
222
+ (@parent.nil? ? nil : "parent=#{@parent.id}"),
223
+ (@children.empty? ? nil : "children=#{@children.map { |c| c.id }.inspect}"),
224
+ ].compact.join(" ") + ">"
225
+ end
226
+
227
+ def dump_recursive f=$stdout, indent=0, root=true, parent=nil
228
+ raise "inconsistency" unless parent.nil? || parent.children.include?(self)
229
+ unless root
230
+ f.print " " * indent
231
+ f.print "+->"
232
+ end
233
+ line = "[#{thread.nil? ? ' ' : '*'}] " + #"[#{useful? ? 'U' : ' '}] " +
234
+ if @message
235
+ message.subj ##{@message.refs.inspect} / #{@message.replytos.inspect}"
236
+ else
237
+ "<no message>"
238
+ end
239
+
240
+ f.puts "#{id} #{line}"#[0 .. (105 - indent)]
241
+ indent += 3
242
+ @children.each { |c| c.dump_recursive f, indent, false, self }
243
+ end
244
+
245
+ def sort_key
246
+ empty? ? [Time.now.to_i, ""] : [@message.date.to_i, @message.id]
247
+ end
248
+ end
249
+
250
+ ## A set of threads, so a forest. Is integrated with the index and
251
+ ## builds thread structures by reading messages from it.
252
+ ##
253
+ ## If 'thread_by_subj' is true, puts messages with the same subject in
254
+ ## one thread, even if they don't reference each other. This is
255
+ ## helpful for crappy MUAs that don't set In-reply-to: or References:
256
+ ## headers, but means that messages may be threaded unnecessarily.
257
+ ##
258
+ ## The following invariants are maintained: every Thread has at least one
259
+ ## Container tree, and every Container tree has at least one Message.
260
+ class ThreadSet
261
+ attr_reader :num_messages
262
+ bool_reader :thread_by_subj
263
+
264
+ def initialize index, thread_by_subj=true
265
+ @index = index
266
+ @num_messages = 0
267
+ ## map from message ids to container objects
268
+ @messages = SavingHash.new { |id| Container.new id }
269
+ ## map from subject strings or (or root message ids) to thread objects
270
+ @threads = SavingHash.new { Thread.new }
271
+ @thread_by_subj = thread_by_subj
272
+ end
273
+
274
+ def thread_for_id mid; @messages.member?(mid) && @messages[mid].root.thread end
275
+ def contains_id? id; @messages.member?(id) && !@messages[id].empty? end
276
+ def thread_for m; thread_for_id m.id end
277
+ def contains? m; contains_id? m.id end
278
+
279
+ def threads; @threads.values end
280
+ def size; @threads.size end
281
+
282
+ def dump f=$stdout
283
+ @threads.each do |s, t|
284
+ f.puts "**********************"
285
+ f.puts "** for subject #{s} **"
286
+ f.puts "**********************"
287
+ t.dump f
288
+ end
289
+ end
290
+
291
+ ## link two containers
292
+ def link p, c, overwrite=false
293
+ if p == c || p.descendant_of?(c) || c.descendant_of?(p) # would create a loop
294
+ #puts "*** linking parent #{p.id} and child #{c.id} would create a loop"
295
+ return
296
+ end
297
+
298
+ #puts "in link for #{p.id} to #{c.id}, perform? #{c.parent.nil?} || #{overwrite}"
299
+
300
+ return unless c.parent.nil? || overwrite
301
+ remove_container c
302
+ p.children << c
303
+ c.parent = p
304
+
305
+ ## if the child was previously a top-level container, it now ain't,
306
+ ## so ditch our thread and kill it if necessary
307
+ prune_thread_of c
308
+ end
309
+ private :link
310
+
311
+ def remove_container c
312
+ c.parent.children.delete c if c.parent # remove from tree
313
+ end
314
+ private :remove_container
315
+
316
+ def prune_thread_of c
317
+ return unless c.thread
318
+ c.thread.drop c
319
+ @threads.delete_if { |k, v| v == c.thread } if c.thread.empty?
320
+ c.thread = nil
321
+ end
322
+ private :prune_thread_of
323
+
324
+ def remove_id mid
325
+ return unless @messages.member?(mid)
326
+ c = @messages[mid]
327
+ remove_container c
328
+ prune_thread_of c
329
+ end
330
+
331
+ def remove_thread_containing_id mid
332
+ return unless @messages.member?(mid)
333
+ c = @messages[mid]
334
+ t = c.root.thread
335
+ @threads.delete_if { |key, thread| t == thread }
336
+ end
337
+
338
+ ## load in (at most) num number of threads from the index
339
+ def load_n_threads num, opts={}
340
+ @index.each_id_by_date opts do |mid, builder|
341
+ break if size >= num unless num == -1
342
+ next if contains_id? mid
343
+
344
+ m = builder.call
345
+ load_thread_for_message m, :skip_killed => opts[:skip_killed], :load_deleted => opts[:load_deleted], :load_spam => opts[:load_spam]
346
+ yield size if block_given?
347
+ end
348
+ end
349
+
350
+ ## loads in all messages needed to thread m
351
+ ## may do nothing if m's thread is killed
352
+ def load_thread_for_message m, opts={}
353
+ good = @index.each_message_in_thread_for m, opts do |mid, builder|
354
+ next if contains_id? mid
355
+ add_message builder.call
356
+ end
357
+ add_message m if good
358
+ end
359
+
360
+ ## merges in a pre-loaded thread
361
+ def add_thread t
362
+ raise "duplicate" if @threads.values.member? t
363
+ t.each { |m, *o| add_message m }
364
+ end
365
+
366
+ ## merges two threads together. both must be members of this threadset.
367
+ ## does its best, heuristically, to determine which is the parent.
368
+ def join_threads threads
369
+ return if threads.size < 2
370
+
371
+ containers = threads.map do |t|
372
+ c = @messages.member?(t.first.id) ? @messages[t.first.id] : nil
373
+ raise "not in threadset: #{t.first.id}" unless c && c.message
374
+ c
375
+ end
376
+
377
+ ## use subject headers heuristically
378
+ parent = containers.find { |c| !c.is_reply? }
379
+
380
+ ## no thread was rooted by a non-reply, so make a fake parent
381
+ parent ||= @messages["joining-ref-" + containers.map { |c| c.id }.join("-")]
382
+
383
+ containers.each do |c|
384
+ next if c == parent
385
+ c.message.add_ref parent.id
386
+ link parent, c
387
+ end
388
+
389
+ true
390
+ end
391
+
392
+ def is_relevant? m
393
+ m.refs.any? { |ref_id| @messages.member? ref_id }
394
+ end
395
+
396
+ def delete_message message
397
+ el = @messages[message.id]
398
+ return unless el.message
399
+ el.message = nil
400
+ end
401
+
402
+ ## the heart of the threading code
403
+ def add_message message
404
+ el = @messages[message.id]
405
+ return if el.message # we've seen it before
406
+
407
+ #puts "adding: #{message.id}, refs #{message.refs.inspect}"
408
+
409
+ el.message = message
410
+ oldroot = el.root
411
+
412
+ ## link via references:
413
+ (message.refs + [el.id]).inject(nil) do |prev, ref_id|
414
+ ref = @messages[ref_id]
415
+ link prev, ref if prev
416
+ ref
417
+ end
418
+
419
+ ## link via in-reply-to:
420
+ message.replytos.each do |ref_id|
421
+ ref = @messages[ref_id]
422
+ link ref, el, true
423
+ break # only do the first one
424
+ end
425
+
426
+ root = el.root
427
+ key =
428
+ if thread_by_subj?
429
+ Message.normalize_subj root.subj
430
+ else
431
+ root.id
432
+ end
433
+
434
+ ## check to see if the subject is still the same (in the case
435
+ ## that we first added a child message with a different
436
+ ## subject)
437
+ if root.thread
438
+ if @threads.member?(key) && @threads[key] != root.thread
439
+ @threads.delete key
440
+ end
441
+ else
442
+ thread = @threads[key]
443
+ thread << root
444
+ root.thread = thread
445
+ end
446
+
447
+ ## last bit
448
+ @num_messages += 1
449
+ end
450
+ end
451
+
452
+ end