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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +12 -0
- data/CONTRIBUTORS +84 -0
- data/Gemfile +3 -0
- data/HACKING +42 -0
- data/History.txt +361 -0
- data/LICENSE +280 -0
- data/README.md +70 -0
- data/Rakefile +12 -0
- data/ReleaseNotes +231 -0
- data/bin/sup +434 -0
- data/bin/sup-add +118 -0
- data/bin/sup-config +243 -0
- data/bin/sup-dump +43 -0
- data/bin/sup-import-dump +101 -0
- data/bin/sup-psych-ify-config-files +21 -0
- data/bin/sup-recover-sources +87 -0
- data/bin/sup-sync +210 -0
- data/bin/sup-sync-back-maildir +127 -0
- data/bin/sup-tweak-labels +140 -0
- data/contrib/colorpicker.rb +100 -0
- data/contrib/completion/_sup.zsh +114 -0
- data/devel/console.sh +3 -0
- data/devel/count-loc.sh +3 -0
- data/devel/load-index.rb +9 -0
- data/devel/profile.rb +12 -0
- data/devel/start-console.rb +5 -0
- data/doc/FAQ.txt +119 -0
- data/doc/Hooks.txt +79 -0
- data/doc/Philosophy.txt +69 -0
- data/lib/sup.rb +467 -0
- data/lib/sup/account.rb +90 -0
- data/lib/sup/buffer.rb +768 -0
- data/lib/sup/colormap.rb +239 -0
- data/lib/sup/contact.rb +67 -0
- data/lib/sup/crypto.rb +461 -0
- data/lib/sup/draft.rb +119 -0
- data/lib/sup/hook.rb +159 -0
- data/lib/sup/horizontal_selector.rb +59 -0
- data/lib/sup/idle.rb +42 -0
- data/lib/sup/index.rb +882 -0
- data/lib/sup/interactive_lock.rb +89 -0
- data/lib/sup/keymap.rb +140 -0
- data/lib/sup/label.rb +87 -0
- data/lib/sup/logger.rb +77 -0
- data/lib/sup/logger/singleton.rb +10 -0
- data/lib/sup/maildir.rb +257 -0
- data/lib/sup/mbox.rb +187 -0
- data/lib/sup/message.rb +803 -0
- data/lib/sup/message_chunks.rb +328 -0
- data/lib/sup/mode.rb +140 -0
- data/lib/sup/modes/buffer_list_mode.rb +50 -0
- data/lib/sup/modes/completion_mode.rb +55 -0
- data/lib/sup/modes/compose_mode.rb +38 -0
- data/lib/sup/modes/console_mode.rb +125 -0
- data/lib/sup/modes/contact_list_mode.rb +148 -0
- data/lib/sup/modes/edit_message_async_mode.rb +110 -0
- data/lib/sup/modes/edit_message_mode.rb +728 -0
- data/lib/sup/modes/file_browser_mode.rb +109 -0
- data/lib/sup/modes/forward_mode.rb +82 -0
- data/lib/sup/modes/help_mode.rb +19 -0
- data/lib/sup/modes/inbox_mode.rb +85 -0
- data/lib/sup/modes/label_list_mode.rb +138 -0
- data/lib/sup/modes/label_search_results_mode.rb +38 -0
- data/lib/sup/modes/line_cursor_mode.rb +203 -0
- data/lib/sup/modes/log_mode.rb +57 -0
- data/lib/sup/modes/person_search_results_mode.rb +12 -0
- data/lib/sup/modes/poll_mode.rb +19 -0
- data/lib/sup/modes/reply_mode.rb +228 -0
- data/lib/sup/modes/resume_mode.rb +52 -0
- data/lib/sup/modes/scroll_mode.rb +252 -0
- data/lib/sup/modes/search_list_mode.rb +204 -0
- data/lib/sup/modes/search_results_mode.rb +59 -0
- data/lib/sup/modes/text_mode.rb +76 -0
- data/lib/sup/modes/thread_index_mode.rb +1033 -0
- data/lib/sup/modes/thread_view_mode.rb +941 -0
- data/lib/sup/person.rb +134 -0
- data/lib/sup/poll.rb +272 -0
- data/lib/sup/rfc2047.rb +56 -0
- data/lib/sup/search.rb +110 -0
- data/lib/sup/sent.rb +58 -0
- data/lib/sup/service/label_service.rb +45 -0
- data/lib/sup/source.rb +244 -0
- data/lib/sup/tagger.rb +50 -0
- data/lib/sup/textfield.rb +253 -0
- data/lib/sup/thread.rb +452 -0
- data/lib/sup/time.rb +93 -0
- data/lib/sup/undo.rb +38 -0
- data/lib/sup/update.rb +30 -0
- data/lib/sup/util.rb +747 -0
- data/lib/sup/util/ncurses.rb +274 -0
- data/lib/sup/util/path.rb +9 -0
- data/lib/sup/util/query.rb +17 -0
- data/lib/sup/util/uri.rb +15 -0
- data/lib/sup/version.rb +3 -0
- data/sup.gemspec +53 -0
- data/test/dummy_source.rb +61 -0
- data/test/gnupg_test_home/gpg.conf +1 -0
- data/test/gnupg_test_home/pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
- data/test/gnupg_test_home/receiver_secring.gpg +0 -0
- data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
- data/test/gnupg_test_home/secring.gpg +0 -0
- data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
- data/test/gnupg_test_home/trustdb.gpg +0 -0
- data/test/integration/test_label_service.rb +18 -0
- data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
- data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
- data/test/messages/missing-line.eml +9 -0
- data/test/test_crypto.rb +109 -0
- data/test/test_header_parsing.rb +168 -0
- data/test/test_helper.rb +7 -0
- data/test/test_message.rb +532 -0
- data/test/test_messages_dir.rb +147 -0
- data/test/test_yaml_migration.rb +85 -0
- data/test/test_yaml_regressions.rb +17 -0
- data/test/unit/service/test_label_service.rb +19 -0
- data/test/unit/test_horizontal_selector.rb +40 -0
- data/test/unit/util/test_query.rb +46 -0
- data/test/unit/util/test_string.rb +57 -0
- data/test/unit/util/test_uri.rb +19 -0
- metadata +423 -0
data/lib/sup/tagger.rb
ADDED
|
@@ -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
|
data/lib/sup/thread.rb
ADDED
|
@@ -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
|