sup 0.8.1 → 0.9
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of sup might be problematic. Click here for more details.
- data/CONTRIBUTORS +13 -6
- data/History.txt +19 -0
- data/ReleaseNotes +35 -0
- data/bin/sup +82 -77
- data/bin/sup-add +7 -7
- data/bin/sup-config +104 -85
- data/bin/sup-dump +4 -5
- data/bin/sup-recover-sources +9 -10
- data/bin/sup-sync +121 -100
- data/bin/sup-sync-back +18 -15
- data/bin/sup-tweak-labels +24 -21
- data/lib/sup.rb +53 -33
- data/lib/sup/account.rb +0 -2
- data/lib/sup/buffer.rb +47 -22
- data/lib/sup/colormap.rb +6 -6
- data/lib/sup/contact.rb +0 -2
- data/lib/sup/crypto.rb +34 -23
- data/lib/sup/draft.rb +6 -14
- data/lib/sup/ferret_index.rb +471 -0
- data/lib/sup/hook.rb +30 -43
- data/lib/sup/hook.rb.BACKUP.8625.rb +158 -0
- data/lib/sup/hook.rb.BACKUP.8681.rb +158 -0
- data/lib/sup/hook.rb.BASE.8625.rb +155 -0
- data/lib/sup/hook.rb.BASE.8681.rb +155 -0
- data/lib/sup/hook.rb.LOCAL.8625.rb +142 -0
- data/lib/sup/hook.rb.LOCAL.8681.rb +142 -0
- data/lib/sup/hook.rb.REMOTE.8625.rb +145 -0
- data/lib/sup/hook.rb.REMOTE.8681.rb +145 -0
- data/lib/sup/imap.rb +18 -8
- data/lib/sup/index.rb +70 -528
- data/lib/sup/interactive-lock.rb +74 -0
- data/lib/sup/keymap.rb +26 -26
- data/lib/sup/label.rb +2 -4
- data/lib/sup/logger.rb +54 -35
- data/lib/sup/maildir.rb +41 -6
- data/lib/sup/mbox.rb +1 -1
- data/lib/sup/mbox/loader.rb +18 -6
- data/lib/sup/mbox/ssh-file.rb +1 -7
- data/lib/sup/message-chunks.rb +36 -23
- data/lib/sup/message.rb +126 -46
- data/lib/sup/mode.rb +3 -2
- data/lib/sup/modes/console-mode.rb +108 -0
- data/lib/sup/modes/edit-message-mode.rb +15 -5
- data/lib/sup/modes/inbox-mode.rb +2 -4
- data/lib/sup/modes/label-list-mode.rb +1 -1
- data/lib/sup/modes/line-cursor-mode.rb +18 -18
- data/lib/sup/modes/log-mode.rb +29 -16
- data/lib/sup/modes/poll-mode.rb +7 -9
- data/lib/sup/modes/reply-mode.rb +5 -3
- data/lib/sup/modes/scroll-mode.rb +2 -2
- data/lib/sup/modes/search-results-mode.rb +9 -11
- data/lib/sup/modes/text-mode.rb +2 -2
- data/lib/sup/modes/thread-index-mode.rb +26 -16
- data/lib/sup/modes/thread-view-mode.rb +84 -39
- data/lib/sup/person.rb +6 -8
- data/lib/sup/poll.rb +46 -47
- data/lib/sup/rfc2047.rb +1 -5
- data/lib/sup/sent.rb +27 -20
- data/lib/sup/source.rb +90 -13
- data/lib/sup/textfield.rb +4 -4
- data/lib/sup/thread.rb +15 -13
- data/lib/sup/undo.rb +0 -1
- data/lib/sup/update.rb +0 -1
- data/lib/sup/util.rb +51 -43
- data/lib/sup/xapian_index.rb +566 -0
- metadata +57 -46
- data/lib/sup/suicide.rb +0 -36
data/lib/sup/colormap.rb
CHANGED
@@ -129,7 +129,7 @@ class Colormap
|
|
129
129
|
@next_id = (@next_id + 1) % NUM_COLORS
|
130
130
|
@next_id += 1 if @next_id == 0 # 0 is always white on black
|
131
131
|
id = @next_id
|
132
|
-
|
132
|
+
debug "colormap: for color #{sym}, using id #{id} -> #{fg}, #{bg}"
|
133
133
|
Curses.init_pair id, fg, bg or raise ArgumentError,
|
134
134
|
"couldn't initialize curses color pair #{fg}, #{bg} (key #{id})"
|
135
135
|
|
@@ -137,7 +137,7 @@ class Colormap
|
|
137
137
|
## delete the old mapping, if it exists
|
138
138
|
if @users[cp]
|
139
139
|
@users[cp].each do |usym|
|
140
|
-
|
140
|
+
warn "dropping color #{usym} (#{id})"
|
141
141
|
@entries[usym][3] = nil
|
142
142
|
end
|
143
143
|
@users[cp] = []
|
@@ -155,7 +155,7 @@ class Colormap
|
|
155
155
|
## to the default ones.
|
156
156
|
def populate_colormap
|
157
157
|
user_colors = if File.exists? Redwood::COLOR_FN
|
158
|
-
|
158
|
+
debug "loading user colors from #{Redwood::COLOR_FN}"
|
159
159
|
Redwood::load_yaml_obj Redwood::COLOR_FN
|
160
160
|
end
|
161
161
|
|
@@ -171,7 +171,7 @@ class Colormap
|
|
171
171
|
fg = Curses.const_get "COLOR_#{ufg.upcase}"
|
172
172
|
rescue NameError
|
173
173
|
error ||= "Warning: there is no color named \"#{ufg}\", using fallback."
|
174
|
-
|
174
|
+
warn "there is no color named \"#{ufg}\""
|
175
175
|
end
|
176
176
|
end
|
177
177
|
|
@@ -180,7 +180,7 @@ class Colormap
|
|
180
180
|
bg = Curses.const_get "COLOR_#{ubg.upcase}"
|
181
181
|
rescue NameError
|
182
182
|
error ||= "Warning: there is no color named \"#{ubg}\", using fallback."
|
183
|
-
|
183
|
+
warn "there is no color named \"#{ubg}\""
|
184
184
|
end
|
185
185
|
end
|
186
186
|
|
@@ -190,7 +190,7 @@ class Colormap
|
|
190
190
|
Curses.const_get "A_#{a.upcase}"
|
191
191
|
rescue NameError
|
192
192
|
error ||= "Warning: there is no attribute named \"#{a}\", using fallback."
|
193
|
-
|
193
|
+
warn "there is no attribute named \"#{a}\", using fallback."
|
194
194
|
end
|
195
195
|
end
|
196
196
|
end
|
data/lib/sup/contact.rb
CHANGED
data/lib/sup/crypto.rb
CHANGED
@@ -13,17 +13,15 @@ class CryptoManager
|
|
13
13
|
|
14
14
|
def initialize
|
15
15
|
@mutex = Mutex.new
|
16
|
-
self.class.i_am_the_instance self
|
17
16
|
|
18
17
|
bin = `which gpg`.chomp
|
19
|
-
|
20
18
|
@cmd =
|
21
19
|
case bin
|
22
20
|
when /\S/
|
23
|
-
|
21
|
+
debug "crypto: detected gpg binary in #{bin}"
|
24
22
|
"#{bin} --quiet --batch --no-verbose --logger-fd 1 --use-agent"
|
25
23
|
else
|
26
|
-
|
24
|
+
debug "crypto: no gpg binary detected"
|
27
25
|
nil
|
28
26
|
end
|
29
27
|
end
|
@@ -116,27 +114,42 @@ class CryptoManager
|
|
116
114
|
output = run_gpg "--decrypt #{payload_fn.path}"
|
117
115
|
|
118
116
|
if $?.success?
|
119
|
-
decrypted_payload, sig_lines =
|
120
|
-
|
121
|
-
|
117
|
+
decrypted_payload, sig_lines = if output =~ /\A(.*?)((^gpg: .*$)+)\Z/m
|
118
|
+
[$1, $2]
|
119
|
+
else
|
120
|
+
[output, nil]
|
121
|
+
end
|
122
|
+
|
123
|
+
sig = if sig_lines # encrypted & signed
|
124
|
+
if sig_lines =~ /^gpg: (Good signature from .*$)/
|
125
|
+
Chunk::CryptoNotice.new :valid, $1, sig_lines.split("\n")
|
122
126
|
else
|
123
|
-
|
124
|
-
end
|
125
|
-
|
126
|
-
sig =
|
127
|
-
if sig_lines # encrypted & signed
|
128
|
-
if sig_lines =~ /^gpg: (Good signature from .*$)/
|
129
|
-
Chunk::CryptoNotice.new :valid, $1, sig_lines.split("\n")
|
130
|
-
else
|
131
|
-
Chunk::CryptoNotice.new :invalid, $1, sig_lines.split("\n")
|
132
|
-
end
|
127
|
+
Chunk::CryptoNotice.new :invalid, $1, sig_lines.split("\n")
|
133
128
|
end
|
129
|
+
end
|
134
130
|
|
131
|
+
# This is gross. This decrypted payload could very well be a multipart
|
132
|
+
# element itself, as opposed to a simple payload. For example, a
|
133
|
+
# multipart/signed element, like those generated by Mutt when encrypting
|
134
|
+
# and signing a message (instead of just clearsigning the body).
|
135
|
+
# Supposedly, decrypted_payload being a multipart element ought to work
|
136
|
+
# out nicely because Message::multipart_encrypted_to_chunks() runs the
|
137
|
+
# decrypted message through message_to_chunks() again to get any
|
138
|
+
# children. However, it does not work as intended because these inner
|
139
|
+
# payloads need not carry a MIME-Version header, yet they are fed to
|
140
|
+
# RMail as a top-level message, for which the MIME-Version header is
|
141
|
+
# required. This causes for the part not to be detected as multipart,
|
142
|
+
# hence being shown as an attachment. If we detect this is happening,
|
143
|
+
# we force the decrypted payload to be interpreted as MIME.
|
144
|
+
msg = RMail::Parser.read(decrypted_payload)
|
145
|
+
if msg.header.content_type =~ %r{^multipart/} and not msg.multipart?
|
146
|
+
decrypted_payload = "MIME-Version: 1.0\n" + decrypted_payload
|
147
|
+
msg = RMail::Parser.read(decrypted_payload)
|
148
|
+
end
|
135
149
|
notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
|
136
|
-
[
|
150
|
+
[notice, sig, msg]
|
137
151
|
else
|
138
|
-
|
139
|
-
[nil, nil, notice]
|
152
|
+
Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
|
140
153
|
end
|
141
154
|
end
|
142
155
|
|
@@ -145,7 +158,7 @@ private
|
|
145
158
|
def unknown_status lines=[]
|
146
159
|
Chunk::CryptoNotice.new :unknown, "Unable to determine validity of cryptographic signature", lines
|
147
160
|
end
|
148
|
-
|
161
|
+
|
149
162
|
def cant_find_binary
|
150
163
|
["Can't find gpg binary in path."]
|
151
164
|
end
|
@@ -158,9 +171,7 @@ private
|
|
158
171
|
|
159
172
|
def run_gpg args
|
160
173
|
cmd = "#{@cmd} #{args} 2> /dev/null"
|
161
|
-
#Redwood::log "crypto: running: #{cmd}"
|
162
174
|
output = `#{cmd}`
|
163
|
-
#Redwood::log "crypto: output: #{output.inspect}" unless $?.success?
|
164
175
|
output
|
165
176
|
end
|
166
177
|
end
|
data/lib/sup/draft.rb
CHANGED
@@ -7,7 +7,6 @@ class DraftManager
|
|
7
7
|
def initialize dir
|
8
8
|
@dir = dir
|
9
9
|
@source = nil
|
10
|
-
self.class.i_am_the_instance self
|
11
10
|
end
|
12
11
|
|
13
12
|
def self.source_name; "sup://drafts"; end
|
@@ -20,25 +19,18 @@ class DraftManager
|
|
20
19
|
File.open(fn, "w") { |f| yield f }
|
21
20
|
|
22
21
|
my_message = nil
|
23
|
-
@source
|
24
|
-
|
25
|
-
|
26
|
-
UpdateManager.relay self, :added, m
|
27
|
-
my_message = m if thisoffset == offset
|
22
|
+
PollManager.each_message_from(@source) do |m|
|
23
|
+
PollManager.add_new_message m
|
24
|
+
my_message = m
|
28
25
|
end
|
29
26
|
|
30
27
|
my_message
|
31
28
|
end
|
32
29
|
|
33
30
|
def discard m
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
return
|
38
|
-
end
|
39
|
-
raise ArgumentError, "not a draft: source id #{entry[:source_id].inspect}, should be #{DraftManager.source_id.inspect} for #{m.id.inspect} / docno #{docid}" unless entry[:source_id].to_i == DraftManager.source_id
|
40
|
-
Index.drop_entry docid
|
41
|
-
File.delete @source.fn_for_offset(entry[:source_info])
|
31
|
+
raise ArgumentError, "not a draft: source id #{m.source.id.inspect}, should be #{DraftManager.source_id.inspect} for #{m.id.inspect}" unless m.source.id.to_i == DraftManager.source_id
|
32
|
+
Index.delete m.id
|
33
|
+
File.delete @source.fn_for_offset(m.source_info)
|
42
34
|
UpdateManager.relay self, :single_message_deleted, m
|
43
35
|
end
|
44
36
|
end
|
@@ -0,0 +1,471 @@
|
|
1
|
+
require 'ferret'
|
2
|
+
|
3
|
+
module Redwood
|
4
|
+
|
5
|
+
class FerretIndex < BaseIndex
|
6
|
+
|
7
|
+
HookManager.register "custom-search", <<EOS
|
8
|
+
Executes before a string search is applied to the index,
|
9
|
+
returning a new search string.
|
10
|
+
Variables:
|
11
|
+
subs: The string being searched.
|
12
|
+
EOS
|
13
|
+
|
14
|
+
def initialize dir=BASE_DIR
|
15
|
+
super
|
16
|
+
|
17
|
+
@index_mutex = Monitor.new
|
18
|
+
wsa = Ferret::Analysis::WhiteSpaceAnalyzer.new false
|
19
|
+
sa = Ferret::Analysis::StandardAnalyzer.new [], true
|
20
|
+
@analyzer = Ferret::Analysis::PerFieldAnalyzer.new wsa
|
21
|
+
@analyzer[:body] = sa
|
22
|
+
@analyzer[:subject] = sa
|
23
|
+
@qparser ||= Ferret::QueryParser.new :default_field => :body, :analyzer => @analyzer, :or_default => false
|
24
|
+
end
|
25
|
+
|
26
|
+
def load_index dir=File.join(@dir, "ferret")
|
27
|
+
if File.exists? dir
|
28
|
+
debug "loading index..."
|
29
|
+
@index_mutex.synchronize do
|
30
|
+
@index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer, :id_field => 'message_id')
|
31
|
+
debug "loaded index of #{@index.size} messages"
|
32
|
+
end
|
33
|
+
else
|
34
|
+
debug "creating index..."
|
35
|
+
@index_mutex.synchronize do
|
36
|
+
field_infos = Ferret::Index::FieldInfos.new :store => :yes
|
37
|
+
field_infos.add_field :message_id, :index => :untokenized
|
38
|
+
field_infos.add_field :source_id
|
39
|
+
field_infos.add_field :source_info
|
40
|
+
field_infos.add_field :date, :index => :untokenized
|
41
|
+
field_infos.add_field :body
|
42
|
+
field_infos.add_field :label
|
43
|
+
field_infos.add_field :attachments
|
44
|
+
field_infos.add_field :subject
|
45
|
+
field_infos.add_field :from
|
46
|
+
field_infos.add_field :to
|
47
|
+
field_infos.add_field :refs
|
48
|
+
field_infos.add_field :snippet, :index => :no, :term_vector => :no
|
49
|
+
field_infos.create_index dir
|
50
|
+
@index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer, :id_field => 'message_id')
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def add_message m; sync_message m end
|
56
|
+
def update_message m; sync_message m end
|
57
|
+
def update_message_state m; sync_message m end
|
58
|
+
|
59
|
+
def sync_message m, opts={}
|
60
|
+
entry = @index[m.id]
|
61
|
+
|
62
|
+
raise "no source info for message #{m.id}" unless m.source && m.source_info
|
63
|
+
|
64
|
+
source_id = if m.source.is_a? Integer
|
65
|
+
m.source
|
66
|
+
else
|
67
|
+
m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
|
68
|
+
end
|
69
|
+
|
70
|
+
snippet = if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
|
71
|
+
""
|
72
|
+
else
|
73
|
+
m.snippet
|
74
|
+
end
|
75
|
+
|
76
|
+
## write the new document to the index. if the entry already exists in the
|
77
|
+
## index, reuse it (which avoids having to reload the entry from the source,
|
78
|
+
## which can be quite expensive for e.g. large threads of IMAP actions.)
|
79
|
+
##
|
80
|
+
## exception: if the index entry belongs to an earlier version of the
|
81
|
+
## message, use everything from the new message instead, but union the
|
82
|
+
## flags. this allows messages sent to mailing lists to have their header
|
83
|
+
## updated and to have flags set properly.
|
84
|
+
##
|
85
|
+
## minor hack: messages in sources with lower ids have priority over
|
86
|
+
## messages in sources with higher ids. so messages in the inbox will
|
87
|
+
## override everyone, and messages in the sent box will be overridden
|
88
|
+
## by everyone else.
|
89
|
+
##
|
90
|
+
## written in this manner to support previous versions of the index which
|
91
|
+
## did not keep around the entry body. upgrading is thus seamless.
|
92
|
+
entry ||= {}
|
93
|
+
labels = m.labels # override because this is the new state, unless...
|
94
|
+
|
95
|
+
## if we are a later version of a message, ignore what's in the index,
|
96
|
+
## but merge in the labels.
|
97
|
+
if entry[:source_id] && entry[:source_info] && entry[:label] &&
|
98
|
+
((entry[:source_id].to_i > source_id) || (entry[:source_info].to_i < m.source_info))
|
99
|
+
labels += entry[:label].to_set_of_symbols
|
100
|
+
#debug "found updated version of message #{m.id}: #{m.subj}"
|
101
|
+
#debug "previous version was at #{entry[:source_id].inspect}:#{entry[:source_info].inspect}, this version at #{source_id.inspect}:#{m.source_info.inspect}"
|
102
|
+
#debug "merged labels are #{labels.inspect} (index #{entry[:label].inspect}, message #{m.labels.inspect})"
|
103
|
+
entry = {}
|
104
|
+
end
|
105
|
+
|
106
|
+
## if force_overwite is true, ignore what's in the index. this is used
|
107
|
+
## primarily by sup-sync to force index updates.
|
108
|
+
entry = {} if opts[:force_overwrite]
|
109
|
+
|
110
|
+
d = {
|
111
|
+
:message_id => m.id,
|
112
|
+
:source_id => source_id,
|
113
|
+
:source_info => m.source_info,
|
114
|
+
:date => (entry[:date] || m.date.to_indexable_s),
|
115
|
+
:body => (entry[:body] || m.indexable_content),
|
116
|
+
:snippet => snippet, # always override
|
117
|
+
:label => labels.to_a.join(" "),
|
118
|
+
:attachments => (entry[:attachments] || m.attachments.uniq.join(" ")),
|
119
|
+
|
120
|
+
## always override :from and :to.
|
121
|
+
## older versions of Sup would often store the wrong thing in the index
|
122
|
+
## (because they were canonicalizing email addresses, resulting in the
|
123
|
+
## wrong name associated with each.) the correct address is read from
|
124
|
+
## the original header when these messages are opened in thread-view-mode,
|
125
|
+
## so this allows people to forcibly update the address in the index by
|
126
|
+
## marking those threads for saving.
|
127
|
+
:from => (m.from ? m.from.indexable_content : ""),
|
128
|
+
:to => (m.to + m.cc + m.bcc).map { |x| x.indexable_content }.join(" "),
|
129
|
+
|
130
|
+
:subject => (entry[:subject] || wrap_subj(Message.normalize_subj(m.subj))),
|
131
|
+
:refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
|
132
|
+
}
|
133
|
+
|
134
|
+
@index_mutex.synchronize do
|
135
|
+
@index.delete m.id
|
136
|
+
@index.add_document d
|
137
|
+
end
|
138
|
+
end
|
139
|
+
private :sync_message
|
140
|
+
|
141
|
+
def save_index fn=File.join(@dir, "ferret")
|
142
|
+
# don't have to do anything, apparently
|
143
|
+
end
|
144
|
+
|
145
|
+
def contains_id? id
|
146
|
+
@index_mutex.synchronize { @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0 }
|
147
|
+
end
|
148
|
+
|
149
|
+
def size
|
150
|
+
@index_mutex.synchronize { @index.size }
|
151
|
+
end
|
152
|
+
|
153
|
+
EACH_BY_DATE_NUM = 100
|
154
|
+
def each_id_by_date query={}
|
155
|
+
return if empty? # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
|
156
|
+
ferret_query = build_ferret_query query
|
157
|
+
offset = 0
|
158
|
+
while true
|
159
|
+
limit = (query[:limit])? [EACH_BY_DATE_NUM, query[:limit] - offset].min : EACH_BY_DATE_NUM
|
160
|
+
results = @index_mutex.synchronize { @index.search ferret_query, :sort => "date DESC", :limit => limit, :offset => offset }
|
161
|
+
debug "got #{results.total_hits} results for query (offset #{offset}) #{ferret_query.inspect}"
|
162
|
+
results.hits.each do |hit|
|
163
|
+
yield @index_mutex.synchronize { @index[hit.doc][:message_id] }, lambda { build_message hit.doc }
|
164
|
+
end
|
165
|
+
break if query[:limit] and offset >= query[:limit] - limit
|
166
|
+
break if offset >= results.total_hits - limit
|
167
|
+
offset += limit
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def num_results_for query={}
|
172
|
+
return 0 if empty? # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
|
173
|
+
ferret_query = build_ferret_query query
|
174
|
+
@index_mutex.synchronize { @index.search(ferret_query, :limit => 1).total_hits }
|
175
|
+
end
|
176
|
+
|
177
|
+
SAME_SUBJECT_DATE_LIMIT = 7
|
178
|
+
MAX_CLAUSES = 1000
|
179
|
+
def each_message_in_thread_for m, opts={}
|
180
|
+
#debug "Building thread for #{m.id}: #{m.subj}"
|
181
|
+
messages = {}
|
182
|
+
searched = {}
|
183
|
+
num_queries = 0
|
184
|
+
|
185
|
+
pending = [m.id]
|
186
|
+
if $config[:thread_by_subject] # do subject queries
|
187
|
+
date_min = m.date - (SAME_SUBJECT_DATE_LIMIT * 12 * 3600)
|
188
|
+
date_max = m.date + (SAME_SUBJECT_DATE_LIMIT * 12 * 3600)
|
189
|
+
|
190
|
+
q = Ferret::Search::BooleanQuery.new true
|
191
|
+
sq = Ferret::Search::PhraseQuery.new(:subject)
|
192
|
+
wrap_subj(Message.normalize_subj(m.subj)).split.each do |t|
|
193
|
+
sq.add_term t
|
194
|
+
end
|
195
|
+
q.add_query sq, :must
|
196
|
+
q.add_query Ferret::Search::RangeQuery.new(:date, :>= => date_min.to_indexable_s, :<= => date_max.to_indexable_s), :must
|
197
|
+
|
198
|
+
q = build_ferret_query :qobj => q
|
199
|
+
|
200
|
+
p1 = @index_mutex.synchronize { @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] } }
|
201
|
+
debug "found #{p1.size} results for subject query #{q}"
|
202
|
+
|
203
|
+
p2 = @index_mutex.synchronize { @index.search(q.to_s, :limit => :all).hits.map { |hit| @index[hit.doc][:message_id] } }
|
204
|
+
debug "found #{p2.size} results in string form"
|
205
|
+
|
206
|
+
pending = (pending + p1 + p2).uniq
|
207
|
+
end
|
208
|
+
|
209
|
+
until pending.empty? || (opts[:limit] && messages.size >= opts[:limit])
|
210
|
+
q = Ferret::Search::BooleanQuery.new true
|
211
|
+
# this disappeared in newer ferrets... wtf.
|
212
|
+
# q.max_clause_count = 2048
|
213
|
+
|
214
|
+
lim = [MAX_CLAUSES / 2, pending.length].min
|
215
|
+
pending[0 ... lim].each do |id|
|
216
|
+
searched[id] = true
|
217
|
+
q.add_query Ferret::Search::TermQuery.new(:message_id, id), :should
|
218
|
+
q.add_query Ferret::Search::TermQuery.new(:refs, id), :should
|
219
|
+
end
|
220
|
+
pending = pending[lim .. -1]
|
221
|
+
|
222
|
+
q = build_ferret_query :qobj => q
|
223
|
+
|
224
|
+
num_queries += 1
|
225
|
+
killed = false
|
226
|
+
@index_mutex.synchronize do
|
227
|
+
@index.search_each(q, :limit => :all) do |docid, score|
|
228
|
+
break if opts[:limit] && messages.size >= opts[:limit]
|
229
|
+
if @index[docid][:label].split(/\s+/).include?("killed") && opts[:skip_killed]
|
230
|
+
killed = true
|
231
|
+
break
|
232
|
+
end
|
233
|
+
mid = @index[docid][:message_id]
|
234
|
+
unless messages.member?(mid)
|
235
|
+
#debug "got #{mid} as a child of #{id}"
|
236
|
+
messages[mid] ||= lambda { build_message docid }
|
237
|
+
refs = @index[docid][:refs].split
|
238
|
+
pending += refs.select { |id| !searched[id] }
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
if killed
|
245
|
+
#debug "thread for #{m.id} is killed, ignoring"
|
246
|
+
false
|
247
|
+
else
|
248
|
+
#debug "ran #{num_queries} queries to build thread of #{messages.size} messages for #{m.id}: #{m.subj}" if num_queries > 0
|
249
|
+
messages.each { |mid, builder| yield mid, builder }
|
250
|
+
true
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
## builds a message object from a ferret result
|
255
|
+
def build_message docid
|
256
|
+
@index_mutex.synchronize do
|
257
|
+
doc = @index[docid] or return
|
258
|
+
|
259
|
+
source = SourceManager[doc[:source_id].to_i]
|
260
|
+
raise "invalid source #{doc[:source_id]}" unless source
|
261
|
+
|
262
|
+
#puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})"
|
263
|
+
|
264
|
+
fake_header = {
|
265
|
+
"date" => Time.at(doc[:date].to_i),
|
266
|
+
"subject" => unwrap_subj(doc[:subject]),
|
267
|
+
"from" => doc[:from],
|
268
|
+
"to" => doc[:to].split.join(", "), # reformat
|
269
|
+
"message-id" => doc[:message_id],
|
270
|
+
"references" => doc[:refs].split.map { |x| "<#{x}>" }.join(" "),
|
271
|
+
}
|
272
|
+
|
273
|
+
m = Message.new :source => source, :source_info => doc[:source_info].to_i,
|
274
|
+
:labels => doc[:label].to_set_of_symbols,
|
275
|
+
:snippet => doc[:snippet]
|
276
|
+
m.parse_header fake_header
|
277
|
+
m
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
def delete id
|
282
|
+
@index_mutex.synchronize { @index.delete id }
|
283
|
+
end
|
284
|
+
|
285
|
+
def load_contacts emails, h={}
|
286
|
+
q = Ferret::Search::BooleanQuery.new true
|
287
|
+
emails.each do |e|
|
288
|
+
qq = Ferret::Search::BooleanQuery.new true
|
289
|
+
qq.add_query Ferret::Search::TermQuery.new(:from, e), :should
|
290
|
+
qq.add_query Ferret::Search::TermQuery.new(:to, e), :should
|
291
|
+
q.add_query qq
|
292
|
+
end
|
293
|
+
q.add_query Ferret::Search::TermQuery.new(:label, "spam"), :must_not
|
294
|
+
|
295
|
+
debug "contact search: #{q}"
|
296
|
+
contacts = {}
|
297
|
+
num = h[:num] || 20
|
298
|
+
@index_mutex.synchronize do
|
299
|
+
@index.search_each q, :sort => "date DESC", :limit => :all do |docid, score|
|
300
|
+
break if contacts.size >= num
|
301
|
+
#debug "got message #{docid} to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}"
|
302
|
+
f = @index[docid][:from]
|
303
|
+
t = @index[docid][:to]
|
304
|
+
|
305
|
+
if AccountManager.is_account_email? f
|
306
|
+
t.split(" ").each { |e| contacts[Person.from_address(e)] = true }
|
307
|
+
else
|
308
|
+
contacts[Person.from_address(f)] = true
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
contacts.keys.compact
|
314
|
+
end
|
315
|
+
|
316
|
+
def each_id query={}
|
317
|
+
ferret_query = build_ferret_query query
|
318
|
+
results = @index_mutex.synchronize { @index.search ferret_query, :limit => (query[:limit] || :all) }
|
319
|
+
results.hits.map { |hit| yield @index[hit.doc][:message_id] }
|
320
|
+
end
|
321
|
+
|
322
|
+
def optimize
|
323
|
+
@index_mutex.synchronize { @index.optimize }
|
324
|
+
end
|
325
|
+
|
326
|
+
def source_for_id id
|
327
|
+
entry = @index[id]
|
328
|
+
return unless entry
|
329
|
+
entry[:source_id].to_i
|
330
|
+
end
|
331
|
+
|
332
|
+
class ParseError < StandardError; end
|
333
|
+
|
334
|
+
## parse a query string from the user. returns a query object
|
335
|
+
## that can be passed to any index method with a 'query'
|
336
|
+
## argument, as well as build_ferret_query.
|
337
|
+
##
|
338
|
+
## raises a ParseError if something went wrong.
|
339
|
+
def parse_query s
|
340
|
+
query = {}
|
341
|
+
|
342
|
+
subs = HookManager.run("custom-search", :subs => s) || s
|
343
|
+
subs = s.gsub(/\b(to|from):(\S+)\b/) do
|
344
|
+
field, name = $1, $2
|
345
|
+
if(p = ContactManager.contact_for(name))
|
346
|
+
[field, p.email]
|
347
|
+
elsif name == "me"
|
348
|
+
[field, "(" + AccountManager.user_emails.join("||") + ")"]
|
349
|
+
else
|
350
|
+
[field, name]
|
351
|
+
end.join(":")
|
352
|
+
end
|
353
|
+
|
354
|
+
## if we see a label:deleted or a label:spam term anywhere in the query
|
355
|
+
## string, we set the extra load_spam or load_deleted options to true.
|
356
|
+
## bizarre? well, because the query allows arbitrary parenthesized boolean
|
357
|
+
## expressions, without fully parsing the query, we can't tell whether
|
358
|
+
## the user is explicitly directing us to search spam messages or not.
|
359
|
+
## e.g. if the string is -(-(-(-(-label:spam)))), does the user want to
|
360
|
+
## search spam messages or not?
|
361
|
+
##
|
362
|
+
## so, we rely on the fact that turning these extra options ON turns OFF
|
363
|
+
## the adding of "-label:deleted" or "-label:spam" terms at the very
|
364
|
+
## final stage of query processing. if the user wants to search spam
|
365
|
+
## messages, not adding that is the right thing; if he doesn't want to
|
366
|
+
## search spam messages, then not adding it won't have any effect.
|
367
|
+
query[:load_spam] = true if subs =~ /\blabel:spam\b/
|
368
|
+
query[:load_deleted] = true if subs =~ /\blabel:deleted\b/
|
369
|
+
|
370
|
+
## gmail style "is" operator
|
371
|
+
subs = subs.gsub(/\b(is|has):(\S+)\b/) do
|
372
|
+
field, label = $1, $2
|
373
|
+
case label
|
374
|
+
when "read"
|
375
|
+
"-label:unread"
|
376
|
+
when "spam"
|
377
|
+
query[:load_spam] = true
|
378
|
+
"label:spam"
|
379
|
+
when "deleted"
|
380
|
+
query[:load_deleted] = true
|
381
|
+
"label:deleted"
|
382
|
+
else
|
383
|
+
"label:#{$2}"
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
## gmail style attachments "filename" and "filetype" searches
|
388
|
+
subs = subs.gsub(/\b(filename|filetype):(\((.+?)\)\B|(\S+)\b)/) do
|
389
|
+
field, name = $1, ($3 || $4)
|
390
|
+
case field
|
391
|
+
when "filename"
|
392
|
+
debug "filename: translated #{field}:#{name} to attachments:(#{name.downcase})"
|
393
|
+
"attachments:(#{name.downcase})"
|
394
|
+
when "filetype"
|
395
|
+
debug "filetype: translated #{field}:#{name} to attachments:(*.#{name.downcase})"
|
396
|
+
"attachments:(*.#{name.downcase})"
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
if $have_chronic
|
401
|
+
subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
|
402
|
+
field, datestr = $1, ($3 || $4)
|
403
|
+
realdate = Chronic.parse datestr, :guess => false, :context => :past
|
404
|
+
if realdate
|
405
|
+
case field
|
406
|
+
when "after"
|
407
|
+
debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
|
408
|
+
"date:(>= #{sprintf "%012d", realdate.end.to_i})"
|
409
|
+
when "before"
|
410
|
+
debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
|
411
|
+
"date:(<= #{sprintf "%012d", realdate.begin.to_i})"
|
412
|
+
else
|
413
|
+
debug "chronic: translated #{field}:#{datestr} to #{realdate}"
|
414
|
+
"date:(<= #{sprintf "%012d", realdate.end.to_i}) date:(>= #{sprintf "%012d", realdate.begin.to_i})"
|
415
|
+
end
|
416
|
+
else
|
417
|
+
raise ParseError, "can't understand date #{datestr.inspect}"
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
## limit:42 restrict the search to 42 results
|
423
|
+
subs = subs.gsub(/\blimit:(\S+)\b/) do
|
424
|
+
lim = $1
|
425
|
+
if lim =~ /^\d+$/
|
426
|
+
query[:limit] = lim.to_i
|
427
|
+
''
|
428
|
+
else
|
429
|
+
raise ParseError, "non-numeric limit #{lim.inspect}"
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
begin
|
434
|
+
query[:qobj] = @qparser.parse(subs)
|
435
|
+
query[:text] = s
|
436
|
+
query
|
437
|
+
rescue Ferret::QueryParser::QueryParseException => e
|
438
|
+
raise ParseError, e.message
|
439
|
+
end
|
440
|
+
end
|
441
|
+
|
442
|
+
private
|
443
|
+
|
444
|
+
def build_ferret_query query
|
445
|
+
q = Ferret::Search::BooleanQuery.new
|
446
|
+
q.add_query Ferret::Search::MatchAllQuery.new, :must
|
447
|
+
q.add_query query[:qobj], :must if query[:qobj]
|
448
|
+
labels = ([query[:label]] + (query[:labels] || [])).compact
|
449
|
+
labels.each { |t| q.add_query Ferret::Search::TermQuery.new("label", t.to_s), :must }
|
450
|
+
if query[:participants]
|
451
|
+
q2 = Ferret::Search::BooleanQuery.new
|
452
|
+
query[:participants].each do |p|
|
453
|
+
q2.add_query Ferret::Search::TermQuery.new("from", p.email), :should
|
454
|
+
q2.add_query Ferret::Search::TermQuery.new("to", p.email), :should
|
455
|
+
end
|
456
|
+
q.add_query q2, :must
|
457
|
+
end
|
458
|
+
|
459
|
+
q.add_query Ferret::Search::TermQuery.new("label", "spam"), :must_not unless query[:load_spam] || labels.include?(:spam)
|
460
|
+
q.add_query Ferret::Search::TermQuery.new("label", "deleted"), :must_not unless query[:load_deleted] || labels.include?(:deleted)
|
461
|
+
q.add_query Ferret::Search::TermQuery.new("label", "killed"), :must_not if query[:skip_killed]
|
462
|
+
|
463
|
+
q.add_query Ferret::Search::TermQuery.new("source_id", query[:source_id]), :must if query[:source_id]
|
464
|
+
q
|
465
|
+
end
|
466
|
+
|
467
|
+
def wrap_subj subj; "__START_SUBJECT__ #{subj} __END_SUBJECT__"; end
|
468
|
+
def unwrap_subj subj; subj =~ /__START_SUBJECT__ (.*?) __END_SUBJECT__/ && $1; end
|
469
|
+
end
|
470
|
+
|
471
|
+
end
|