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,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