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.

Files changed (67) hide show
  1. data/CONTRIBUTORS +13 -6
  2. data/History.txt +19 -0
  3. data/ReleaseNotes +35 -0
  4. data/bin/sup +82 -77
  5. data/bin/sup-add +7 -7
  6. data/bin/sup-config +104 -85
  7. data/bin/sup-dump +4 -5
  8. data/bin/sup-recover-sources +9 -10
  9. data/bin/sup-sync +121 -100
  10. data/bin/sup-sync-back +18 -15
  11. data/bin/sup-tweak-labels +24 -21
  12. data/lib/sup.rb +53 -33
  13. data/lib/sup/account.rb +0 -2
  14. data/lib/sup/buffer.rb +47 -22
  15. data/lib/sup/colormap.rb +6 -6
  16. data/lib/sup/contact.rb +0 -2
  17. data/lib/sup/crypto.rb +34 -23
  18. data/lib/sup/draft.rb +6 -14
  19. data/lib/sup/ferret_index.rb +471 -0
  20. data/lib/sup/hook.rb +30 -43
  21. data/lib/sup/hook.rb.BACKUP.8625.rb +158 -0
  22. data/lib/sup/hook.rb.BACKUP.8681.rb +158 -0
  23. data/lib/sup/hook.rb.BASE.8625.rb +155 -0
  24. data/lib/sup/hook.rb.BASE.8681.rb +155 -0
  25. data/lib/sup/hook.rb.LOCAL.8625.rb +142 -0
  26. data/lib/sup/hook.rb.LOCAL.8681.rb +142 -0
  27. data/lib/sup/hook.rb.REMOTE.8625.rb +145 -0
  28. data/lib/sup/hook.rb.REMOTE.8681.rb +145 -0
  29. data/lib/sup/imap.rb +18 -8
  30. data/lib/sup/index.rb +70 -528
  31. data/lib/sup/interactive-lock.rb +74 -0
  32. data/lib/sup/keymap.rb +26 -26
  33. data/lib/sup/label.rb +2 -4
  34. data/lib/sup/logger.rb +54 -35
  35. data/lib/sup/maildir.rb +41 -6
  36. data/lib/sup/mbox.rb +1 -1
  37. data/lib/sup/mbox/loader.rb +18 -6
  38. data/lib/sup/mbox/ssh-file.rb +1 -7
  39. data/lib/sup/message-chunks.rb +36 -23
  40. data/lib/sup/message.rb +126 -46
  41. data/lib/sup/mode.rb +3 -2
  42. data/lib/sup/modes/console-mode.rb +108 -0
  43. data/lib/sup/modes/edit-message-mode.rb +15 -5
  44. data/lib/sup/modes/inbox-mode.rb +2 -4
  45. data/lib/sup/modes/label-list-mode.rb +1 -1
  46. data/lib/sup/modes/line-cursor-mode.rb +18 -18
  47. data/lib/sup/modes/log-mode.rb +29 -16
  48. data/lib/sup/modes/poll-mode.rb +7 -9
  49. data/lib/sup/modes/reply-mode.rb +5 -3
  50. data/lib/sup/modes/scroll-mode.rb +2 -2
  51. data/lib/sup/modes/search-results-mode.rb +9 -11
  52. data/lib/sup/modes/text-mode.rb +2 -2
  53. data/lib/sup/modes/thread-index-mode.rb +26 -16
  54. data/lib/sup/modes/thread-view-mode.rb +84 -39
  55. data/lib/sup/person.rb +6 -8
  56. data/lib/sup/poll.rb +46 -47
  57. data/lib/sup/rfc2047.rb +1 -5
  58. data/lib/sup/sent.rb +27 -20
  59. data/lib/sup/source.rb +90 -13
  60. data/lib/sup/textfield.rb +4 -4
  61. data/lib/sup/thread.rb +15 -13
  62. data/lib/sup/undo.rb +0 -1
  63. data/lib/sup/update.rb +0 -1
  64. data/lib/sup/util.rb +51 -43
  65. data/lib/sup/xapian_index.rb +566 -0
  66. metadata +57 -46
  67. data/lib/sup/suicide.rb +0 -36
@@ -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
- Redwood::log "colormap: for color #{sym}, using id #{id} -> #{fg}, #{bg}"
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
- Redwood::log "dropping color #{usym} (#{id})"
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
- Redwood::log "loading user colors from #{Redwood::COLOR_FN}"
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
- Redwood::log "Warning: there is no color named \"#{ufg}\""
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
- Redwood::log "Warning: there is no color named \"#{ubg}\""
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
- Redwood::log "Warning: there is no attribute named \"#{a}\", using fallback."
193
+ warn "there is no attribute named \"#{a}\", using fallback."
194
194
  end
195
195
  end
196
196
  end
@@ -22,8 +22,6 @@ class ContactManager
22
22
  @a2p[aalias] = p unless aalias.nil? || aalias.empty?
23
23
  end
24
24
  end
25
-
26
- self.class.i_am_the_instance self
27
25
  end
28
26
 
29
27
  def contacts; @p2a.keys end
@@ -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
- Redwood::log "crypto: detected gpg binary in #{bin}"
21
+ debug "crypto: detected gpg binary in #{bin}"
24
22
  "#{bin} --quiet --batch --no-verbose --logger-fd 1 --use-agent"
25
23
  else
26
- Redwood::log "crypto: no gpg binary detected"
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
- if output =~ /\A(.*?)((^gpg: .*$)+)\Z/m
121
- [$1, $2]
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
- [output, nil]
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
- [RMail::Parser.read(decrypted_payload), sig, notice]
150
+ [notice, sig, msg]
137
151
  else
138
- notice = Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
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
@@ -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.each do |thisoffset, theselabels|
24
- m = Message.new :source => @source, :source_info => thisoffset, :labels => theselabels
25
- Index.sync_message m
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
- docid, entry = Index.load_entry_for_id m.id
35
- unless entry
36
- Redwood::log "can't find entry for draft: #{m.id.inspect}. You probably already discarded it."
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