sup 0.19.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|