]> git.cworth.org Git - sup/blob - lib/sup/index.rb
cd7c77593ea5513ca4dc300f437fa72738bbcbb4
[sup] / lib / sup / index.rb
1 ## the index structure for redwood. interacts with ferret.
2
3 require 'thread'
4 require 'fileutils'
5 require 'ferret'
6
7 module Redwood
8
9 class Index
10   include Singleton
11
12   attr_reader :index
13   def initialize dir=BASE_DIR
14     @dir = dir
15     @sources = {}
16     @sources_dirty = false
17
18     wsa = Ferret::Analysis::WhiteSpaceAnalyzer.new false
19     sa = Ferret::Analysis::StandardAnalyzer.new Ferret::Analysis::FULL_ENGLISH_STOP_WORDS, true
20     @analyzer = Ferret::Analysis::PerFieldAnalyzer.new wsa
21     @analyzer[:body] = sa
22     @qparser ||= Ferret::QueryParser.new :default_field => :body, :analyzer => @analyzer
23
24     self.class.i_am_the_instance self
25   end
26
27   def load
28     load_sources
29     load_index
30   end
31
32   def save
33     Redwood::log "saving index and sources..."
34     FileUtils.mkdir_p @dir unless File.exists? @dir
35     save_sources
36     save_index
37   end
38
39   def add_source source
40     raise "duplicate source!" if @sources.include? source
41     @sources_dirty = true
42     source.id ||= @sources.size
43     ##TODO: why was this necessary?
44     ##source.id += 1 while @sources.member? source.id
45     @sources[source.id] = source
46   end
47
48   def source_for uri; @sources.values.find { |s| s.is_source_for? uri }; end
49   def usual_sources; @sources.values.find_all { |s| s.usual? }; end
50   def sources; @sources.values; end
51
52   def load_index dir=File.join(@dir, "ferret")
53     if File.exists? dir
54       Redwood::log "loading index..."
55       @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
56       Redwood::log "loaded index of #{@index.size} messages"
57     else
58       Redwood::log "creating index..."
59       field_infos = Ferret::Index::FieldInfos.new :store => :yes
60       field_infos.add_field :message_id
61       field_infos.add_field :source_id
62       field_infos.add_field :source_info
63       field_infos.add_field :date, :index => :untokenized
64       field_infos.add_field :body, :store => :no
65       field_infos.add_field :label
66       field_infos.add_field :subject
67       field_infos.add_field :from
68       field_infos.add_field :to
69       field_infos.add_field :refs
70       field_infos.add_field :snippet, :index => :no, :term_vector => :no
71       field_infos.create_index dir
72       @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
73     end
74   end
75
76   ## Syncs the message to the index: deleting if it's already there,
77   ## and adding either way. Index state will be determined by m.labels.
78   ##
79   ## docid and entry can be specified if they're already known.
80   def sync_message m, docid=nil, entry=nil
81     docid, entry = load_entry_for_id m.id unless docid && entry
82
83     raise "no source info for message #{m.id}" unless m.source && m.source_info
84     raise "trying deleting non-corresponding entry #{docid}" if docid && @index[docid][:message_id] != m.id
85
86     source_id = 
87       if m.source.is_a? Integer
88         raise "Debugging: integer source set"
89         m.source
90       else
91         m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
92       end
93
94     to = (m.to + m.cc + m.bcc).map { |x| x.email }.join(" ")
95     d = {
96       :message_id => m.id,
97       :source_id => source_id,
98       :source_info => m.source_info,
99       :date => m.date.to_indexable_s,
100       :body => m.content,
101       :snippet => m.snippet,
102       :label => m.labels.join(" "),
103       :from => m.from ? m.from.email : "",
104       :to => (m.to + m.cc + m.bcc).map { |x| x.email }.join(" "),
105       :subject => wrap_subj(Message.normalize_subj(m.subj)),
106       :refs => (m.refs + m.replytos).uniq.join(" "),
107     }
108
109     @index.delete docid if docid
110     @index.add_document d
111     
112     docid, entry = load_entry_for_id m.id
113     ## this hasn't been triggered in a long time. TODO: decide whether it's still a problem.
114     raise "just added message #{m.id} but couldn't find it in a search" unless docid
115     true
116   end
117
118   def save_index fn=File.join(@dir, "ferret")
119     # don't have to do anything,  apparently
120   end
121
122   def contains_id? id
123     @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0
124   end
125   def contains? m; contains_id? m.id; end
126   def size; @index.size; end
127
128   ## you should probably not call this on a block that doesn't break
129   ## rather quickly because the results can be very large.
130   EACH_BY_DATE_NUM = 100
131   def each_id_by_date opts={}
132     return if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
133     query = build_query opts
134     offset = 0
135     while true
136       results = @index.search(query, :sort => "date DESC", :limit => EACH_BY_DATE_NUM, :offset => offset)
137       Redwood::log "got #{results.total_hits} results for query (offset #{offset}) #{query.inspect}"
138       results.hits.each { |hit| yield @index[hit.doc][:message_id], lambda { build_message hit.doc } }
139       break if offset >= results.total_hits - EACH_BY_DATE_NUM
140       offset += EACH_BY_DATE_NUM
141     end
142   end
143
144   def num_results_for opts={}
145     return 0 if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
146     q = build_query opts
147     index.search(q).total_hits
148   end
149
150   ## yield all messages in the thread containing 'm' by repeatedly
151   ## querying the index. yields pairs of message ids and
152   ## message-building lambdas, so that building an unwanted message
153   ## can be skipped in the block if desired.
154   ##
155   ## stops loading any thread if a message with a :killed flag is found.
156   SAME_SUBJECT_DATE_LIMIT = 7
157   def each_message_in_thread_for m, opts={}
158     Redwood::log "Building thread for #{m.id}: #{m.subj}"
159     messages = {}
160     searched = {}
161     num_queries = 0
162
163     if $config[:thread_by_subject] # do subject queries
164       date_min = m.date - (SAME_SUBJECT_DATE_LIMIT * 12 * 3600)
165       date_max = m.date + (SAME_SUBJECT_DATE_LIMIT * 12 * 3600)
166
167       q = Ferret::Search::BooleanQuery.new true
168       sq = Ferret::Search::PhraseQuery.new(:subject)
169       wrap_subj(Message.normalize_subj(m.subj)).split(/\s+/).each do |t|
170         sq.add_term t
171       end
172       q.add_query sq, :must
173       q.add_query Ferret::Search::RangeQuery.new(:date, :>= => date_min.to_indexable_s, :<= => date_max.to_indexable_s), :must
174
175       q = build_query :qobj => q
176
177       pending = @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] }
178       Redwood::log "found #{pending.size} results for subject query #{q}"
179     else
180       pending = [m.id]
181     end
182
183     until pending.empty? || (opts[:limit] && messages.size >= opts[:limit])
184       id = pending.pop
185       next if searched.member? id
186       searched[id] = true
187       q = Ferret::Search::BooleanQuery.new true
188       q.add_query Ferret::Search::TermQuery.new(:message_id, id), :should
189       q.add_query Ferret::Search::TermQuery.new(:refs, id), :should
190
191       q = build_query :qobj => q, :load_killed => true
192
193       num_queries += 1
194       @index.search_each(q, :limit => :all) do |docid, score|
195         break if opts[:limit] && messages.size >= opts[:limit]
196         break if @index[docid][:label].split(/\s+/).include? "killed" unless opts[:load_killed]
197         mid = @index[docid][:message_id]
198         unless id == mid || messages.member?(mid)
199           Redwood::log "got #{mid} as a child of #{id}"
200           messages[mid] ||= lambda { build_message docid }
201           refs = @index[docid][:refs].split(" ")
202           pending += refs
203         end
204       end
205     end
206     Redwood::log "ran #{num_queries} queries to build thread of #{messages.size + 1} messages for #{m.id}" if num_queries > 0
207     messages.each { |mid, builder| yield mid, builder }
208   end
209
210   ## builds a message object from a ferret result
211   def build_message docid
212     doc = @index[docid]
213     source = @sources[doc[:source_id].to_i]
214     #puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})"
215     raise "invalid source #{doc[:source_id]}" unless source
216
217     fake_header = {
218       "date" => Time.at(doc[:date].to_i),
219       "subject" => unwrap_subj(doc[:subject]),
220       "from" => doc[:from],
221       "to" => doc[:to],
222       "message-id" => doc[:message_id],
223       "references" => doc[:refs],
224     }
225
226     Message.new :source => source, :source_info => doc[:source_info].to_i, 
227                 :labels => doc[:label].split(" ").map { |s| s.intern },
228                 :snippet => doc[:snippet], :header => fake_header
229   end
230
231   def fresh_thread_id; @next_thread_id += 1; end
232   def wrap_subj subj; "__START_SUBJECT__ #{subj} __END_SUBJECT__"; end
233   def unwrap_subj subj; subj =~ /__START_SUBJECT__ (.*?) __END_SUBJECT__/ && $1; end
234
235   def drop_entry docno; @index.delete docno; end
236
237   def load_entry_for_id mid
238     results = @index.search(Ferret::Search::TermQuery.new(:message_id, mid))
239     return if results.total_hits == 0
240     docid = results.hits[0].doc
241     [docid, @index[docid]]
242   end
243
244   def load_contacts emails, h={}
245     q = Ferret::Search::BooleanQuery.new true
246     emails.each do |e|
247       qq = Ferret::Search::BooleanQuery.new true
248       qq.add_query Ferret::Search::TermQuery.new(:from, e), :should
249       qq.add_query Ferret::Search::TermQuery.new(:to, e), :should
250       q.add_query qq
251     end
252     q.add_query Ferret::Search::TermQuery.new(:label, "spam"), :must_not
253     
254     Redwood::log "contact search: #{q}"
255     contacts = {}
256     num = h[:num] || 20
257     @index.search_each(q, :sort => "date DESC", :limit => :all) do |docid, score|
258       break if contacts.size >= num
259       #Redwood::log "got message with to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}"
260       f = @index[docid][:from]
261       t = @index[docid][:to]
262
263       if AccountManager.is_account_email? f
264         t.split(" ").each { |e| #Redwood::log "adding #{e} because there's a message to him from account email #{f}"; 
265           contacts[Person.for(e)] = true }
266       else
267         #Redwood::log "adding from #{f} because there's a message from him to #{t}"
268         contacts[Person.for(f)] = true
269       end
270     end
271
272     contacts.keys.compact
273   end
274
275   def load_sources fn=Redwood::SOURCE_FN
276     source_array = (Redwood::load_yaml_obj(fn) || []).map { |o| Recoverable.new o }
277     @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
278     @sources_dirty = false
279   end
280
281 protected
282
283   def parse_user_query_string str; @qparser.parse str; end
284   def build_query opts
285     query = Ferret::Search::BooleanQuery.new
286     query.add_query opts[:qobj], :must if opts[:qobj]
287     labels = ([opts[:label]] + (opts[:labels] || [])).compact
288     labels.each { |t| query.add_query Ferret::Search::TermQuery.new("label", t.to_s), :must }
289     if opts[:participants]
290       q2 = Ferret::Search::BooleanQuery.new
291       opts[:participants].each do |p|
292         q2.add_query Ferret::Search::TermQuery.new("from", p.email), :should
293         q2.add_query Ferret::Search::TermQuery.new("to", p.email), :should
294       end
295       query.add_query q2, :must
296     end
297         
298     query.add_query Ferret::Search::TermQuery.new("label", "spam"), :must_not unless opts[:load_spam] || labels.include?(:spam)
299     query.add_query Ferret::Search::TermQuery.new("label", "deleted"), :must_not unless opts[:load_deleted] || labels.include?(:deleted)
300     query.add_query Ferret::Search::TermQuery.new("label", "killed"), :must_not unless opts[:load_killed] || labels.include?(:killed)
301     query
302   end
303
304   def save_sources fn=Redwood::SOURCE_FN
305     if @sources_dirty || @sources.any? { |id, s| s.dirty? }
306       bakfn = fn + ".bak"
307       if File.exists? fn
308         File.chmod 0600, fn
309         FileUtils.mv fn, bakfn, :force => true unless File.exists?(bakfn) && File.size(bakfn) > File.size(fn)
310       end
311       Redwood::save_yaml_obj @sources.values, fn
312       File.chmod 0600, fn
313     end
314     @sources_dirty = false
315   end
316 end
317
318 end