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/draft.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
module Redwood
|
2
|
+
|
3
|
+
class DraftManager
|
4
|
+
include Redwood::Singleton
|
5
|
+
|
6
|
+
attr_accessor :source
|
7
|
+
def initialize dir
|
8
|
+
@dir = dir
|
9
|
+
@source = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.source_name; "sup://drafts"; end
|
13
|
+
def self.source_id; 9999; end
|
14
|
+
def new_source; @source = DraftLoader.new; end
|
15
|
+
|
16
|
+
def write_draft
|
17
|
+
offset = @source.gen_offset
|
18
|
+
fn = @source.fn_for_offset offset
|
19
|
+
File.open(fn, "w") { |f| yield f }
|
20
|
+
PollManager.poll_from @source
|
21
|
+
end
|
22
|
+
|
23
|
+
def discard m
|
24
|
+
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
|
25
|
+
Index.delete m.id
|
26
|
+
File.delete @source.fn_for_offset(m.source_info) rescue Errono::ENOENT
|
27
|
+
UpdateManager.relay self, :single_message_deleted, m
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class DraftLoader < Source
|
32
|
+
attr_accessor :dir
|
33
|
+
yaml_properties
|
34
|
+
|
35
|
+
def initialize dir=Redwood::DRAFT_DIR
|
36
|
+
Dir.mkdir dir unless File.exists? dir
|
37
|
+
super DraftManager.source_name, true, false
|
38
|
+
@dir = dir
|
39
|
+
@cur_offset = 0
|
40
|
+
end
|
41
|
+
|
42
|
+
def properly_initialized?
|
43
|
+
!!(@dir && @cur_offset)
|
44
|
+
end
|
45
|
+
|
46
|
+
def id; DraftManager.source_id; end
|
47
|
+
def to_s; DraftManager.source_name; end
|
48
|
+
def uri; DraftManager.source_name; end
|
49
|
+
|
50
|
+
def poll
|
51
|
+
ids = get_ids
|
52
|
+
ids.each do |id|
|
53
|
+
if id >= @cur_offset
|
54
|
+
@cur_offset = id + 1
|
55
|
+
yield :add,
|
56
|
+
:info => id,
|
57
|
+
:labels => [:draft, :inbox],
|
58
|
+
:progress => 0.0
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def gen_offset
|
64
|
+
i = 0
|
65
|
+
while File.exists? fn_for_offset(i)
|
66
|
+
i += 1
|
67
|
+
end
|
68
|
+
i
|
69
|
+
end
|
70
|
+
|
71
|
+
def fn_for_offset o; File.join(@dir, o.to_s); end
|
72
|
+
|
73
|
+
def load_header offset
|
74
|
+
File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f }
|
75
|
+
end
|
76
|
+
|
77
|
+
def load_message offset
|
78
|
+
raise SourceError, "Draft not found" unless File.exists? fn_for_offset(offset)
|
79
|
+
File.open fn_for_offset(offset) do |f|
|
80
|
+
RMail::Mailbox::MBoxReader.new(f).each_message do |input|
|
81
|
+
return RMail::Parser.read(input)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def raw_header offset
|
87
|
+
ret = ""
|
88
|
+
File.open fn_for_offset(offset) do |f|
|
89
|
+
until f.eof? || (l = f.gets) =~ /^$/
|
90
|
+
ret += l
|
91
|
+
end
|
92
|
+
end
|
93
|
+
ret
|
94
|
+
end
|
95
|
+
|
96
|
+
def each_raw_message_line offset
|
97
|
+
File.open(fn_for_offset(offset)) do |f|
|
98
|
+
yield f.gets until f.eof?
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def raw_message offset
|
103
|
+
IO.read(fn_for_offset(offset))
|
104
|
+
end
|
105
|
+
|
106
|
+
def start_offset; 0; end
|
107
|
+
def end_offset
|
108
|
+
ids = get_ids
|
109
|
+
ids.empty? ? 0 : (ids.last + 1)
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def get_ids
|
115
|
+
Dir.entries(@dir).select { |x| x =~ /^\d+$/ }.map { |x| x.to_i }.sort
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
data/lib/sup/hook.rb
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
require "sup/util"
|
2
|
+
|
3
|
+
module Redwood
|
4
|
+
|
5
|
+
class HookManager
|
6
|
+
class HookContext
|
7
|
+
def initialize name
|
8
|
+
@__say_id = nil
|
9
|
+
@__name = name
|
10
|
+
@__cache = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def say s
|
14
|
+
if BufferManager.instantiated?
|
15
|
+
@__say_id = BufferManager.say s, @__say_id
|
16
|
+
BufferManager.draw_screen
|
17
|
+
else
|
18
|
+
log s
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def flash s
|
23
|
+
if BufferManager.instantiated?
|
24
|
+
BufferManager.flash s
|
25
|
+
else
|
26
|
+
log s
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def log s
|
31
|
+
info "hook[#@__name]: #{s}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def ask_yes_or_no q
|
35
|
+
if BufferManager.instantiated?
|
36
|
+
BufferManager.ask_yes_or_no q
|
37
|
+
else
|
38
|
+
print q
|
39
|
+
gets.chomp.downcase == 'y'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def get tag
|
44
|
+
HookManager.tags[tag]
|
45
|
+
end
|
46
|
+
|
47
|
+
def set tag, value
|
48
|
+
HookManager.tags[tag] = value
|
49
|
+
end
|
50
|
+
|
51
|
+
def __run __hook, __filename, __locals
|
52
|
+
__binding = binding
|
53
|
+
__lprocs, __lvars = __locals.partition { |k, v| v.is_a?(Proc) }
|
54
|
+
eval __lvars.map { |k, v| "#{k} = __locals[#{k.inspect}];" }.join, __binding
|
55
|
+
## we also support closures for delays evaluation. unfortunately
|
56
|
+
## we have to do this via method calls, so you don't get all the
|
57
|
+
## semantics of a regular variable. not ideal.
|
58
|
+
__lprocs.each do |k, v|
|
59
|
+
self.class.instance_eval do
|
60
|
+
define_method k do
|
61
|
+
@__cache[k] ||= v.call
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
ret = eval __hook, __binding, __filename
|
66
|
+
BufferManager.clear @__say_id if @__say_id
|
67
|
+
@__cache = {}
|
68
|
+
ret
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
include Redwood::Singleton
|
73
|
+
|
74
|
+
@descs = {}
|
75
|
+
|
76
|
+
class << self
|
77
|
+
attr_reader :descs
|
78
|
+
end
|
79
|
+
|
80
|
+
def initialize dir
|
81
|
+
@dir = dir
|
82
|
+
@hooks = {}
|
83
|
+
@contexts = {}
|
84
|
+
@tags = {}
|
85
|
+
|
86
|
+
Dir.mkdir dir unless File.exists? dir
|
87
|
+
end
|
88
|
+
|
89
|
+
attr_reader :tags
|
90
|
+
|
91
|
+
def run name, locals={}
|
92
|
+
hook = hook_for(name) or return
|
93
|
+
context = @contexts[hook] ||= HookContext.new(name)
|
94
|
+
|
95
|
+
result = nil
|
96
|
+
fn = fn_for name
|
97
|
+
begin
|
98
|
+
result = context.__run hook, fn, locals
|
99
|
+
rescue Exception => e
|
100
|
+
log "error running #{fn}: #{e.message}"
|
101
|
+
log e.backtrace.join("\n")
|
102
|
+
@hooks[name] = nil # disable it
|
103
|
+
BufferManager.flash "Error running hook: #{e.message}" if BufferManager.instantiated?
|
104
|
+
end
|
105
|
+
result
|
106
|
+
end
|
107
|
+
|
108
|
+
def self.register name, desc
|
109
|
+
@descs[name] = desc
|
110
|
+
end
|
111
|
+
|
112
|
+
def print_hooks f=$stdout
|
113
|
+
puts <<EOS
|
114
|
+
Have #{HookManager.descs.size} registered hooks:
|
115
|
+
|
116
|
+
EOS
|
117
|
+
|
118
|
+
HookManager.descs.sort.each do |name, desc|
|
119
|
+
f.puts <<EOS
|
120
|
+
#{name}
|
121
|
+
#{"-" * name.length}
|
122
|
+
File: #{fn_for name}
|
123
|
+
#{desc}
|
124
|
+
EOS
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def enabled? name; !hook_for(name).nil? end
|
129
|
+
|
130
|
+
def clear; @hooks.clear; BufferManager.flash "Hooks cleared" end
|
131
|
+
def clear_one k; @hooks.delete k; end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def hook_for name
|
136
|
+
unless @hooks.member? name
|
137
|
+
@hooks[name] = begin
|
138
|
+
returning IO.read(fn_for(name)) do
|
139
|
+
debug "read '#{name}' from #{fn_for(name)}"
|
140
|
+
end
|
141
|
+
rescue SystemCallError => e
|
142
|
+
#debug "disabled hook for '#{name}': #{e.message}"
|
143
|
+
nil
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
@hooks[name]
|
148
|
+
end
|
149
|
+
|
150
|
+
def fn_for name
|
151
|
+
File.join @dir, "#{name}.rb"
|
152
|
+
end
|
153
|
+
|
154
|
+
def log m
|
155
|
+
info("hook: " + m)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Redwood
|
2
|
+
|
3
|
+
class HorizontalSelector
|
4
|
+
class UnknownValue < StandardError; end
|
5
|
+
|
6
|
+
attr_accessor :label, :changed_by_user
|
7
|
+
|
8
|
+
def initialize label, vals, labels, base_color=:horizontal_selector_unselected_color, selected_color=:horizontal_selector_selected_color
|
9
|
+
@label = label
|
10
|
+
@vals = vals
|
11
|
+
@labels = labels
|
12
|
+
@base_color = base_color
|
13
|
+
@selected_color = selected_color
|
14
|
+
@selection = 0
|
15
|
+
@changed_by_user = false
|
16
|
+
end
|
17
|
+
|
18
|
+
def set_to val
|
19
|
+
raise UnknownValue, val.inspect unless can_set_to? val
|
20
|
+
@selection = @vals.index(val)
|
21
|
+
end
|
22
|
+
|
23
|
+
def can_set_to? val
|
24
|
+
@vals.include? val
|
25
|
+
end
|
26
|
+
|
27
|
+
def val; @vals[@selection] end
|
28
|
+
|
29
|
+
def line width=nil
|
30
|
+
label =
|
31
|
+
if width
|
32
|
+
sprintf "%#{width}s ", @label
|
33
|
+
else
|
34
|
+
"#{@label} "
|
35
|
+
end
|
36
|
+
|
37
|
+
[[@base_color, label]] +
|
38
|
+
(0 ... @labels.length).inject([]) do |array, i|
|
39
|
+
array + [
|
40
|
+
if i == @selection
|
41
|
+
[@selected_color, @labels[i]]
|
42
|
+
else
|
43
|
+
[@base_color, @labels[i]]
|
44
|
+
end] + [[@base_color, " "]]
|
45
|
+
end + [[@base_color, ""]]
|
46
|
+
end
|
47
|
+
|
48
|
+
def roll_left
|
49
|
+
@selection = (@selection - 1) % @labels.length
|
50
|
+
@changed_by_user = true
|
51
|
+
end
|
52
|
+
|
53
|
+
def roll_right
|
54
|
+
@selection = (@selection + 1) % @labels.length
|
55
|
+
@changed_by_user = true
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
data/lib/sup/idle.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
3
|
+
module Redwood
|
4
|
+
|
5
|
+
class IdleManager
|
6
|
+
include Redwood::Singleton
|
7
|
+
|
8
|
+
IDLE_THRESHOLD = 60
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@no_activity_since = Time.now
|
12
|
+
@idle = false
|
13
|
+
@thread = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def ping
|
17
|
+
if @idle
|
18
|
+
UpdateManager.relay self, :unidle, Time.at(@no_activity_since)
|
19
|
+
@idle = false
|
20
|
+
end
|
21
|
+
@no_activity_since = Time.now
|
22
|
+
end
|
23
|
+
|
24
|
+
def start
|
25
|
+
@thread = Redwood::reporting_thread("checking for idleness") do
|
26
|
+
while true
|
27
|
+
sleep 1
|
28
|
+
if !@idle and Time.now.to_i - @no_activity_since.to_i >= IDLE_THRESHOLD
|
29
|
+
UpdateManager.relay self, :idle, Time.at(@no_activity_since)
|
30
|
+
@idle = true
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def stop
|
37
|
+
@thread.kill if @thread
|
38
|
+
@thread = nil
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
data/lib/sup/index.rb
ADDED
@@ -0,0 +1,882 @@
|
|
1
|
+
ENV["XAPIAN_FLUSH_THRESHOLD"] = "1000"
|
2
|
+
ENV["XAPIAN_CJK_NGRAM"] = "1"
|
3
|
+
|
4
|
+
require 'xapian'
|
5
|
+
require 'set'
|
6
|
+
require 'fileutils'
|
7
|
+
require 'monitor'
|
8
|
+
require 'chronic'
|
9
|
+
|
10
|
+
require "sup/util/query"
|
11
|
+
require "sup/interactive_lock"
|
12
|
+
require "sup/hook"
|
13
|
+
require "sup/logger/singleton"
|
14
|
+
|
15
|
+
|
16
|
+
if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,15]) < 0
|
17
|
+
fail <<-EOF
|
18
|
+
\n
|
19
|
+
Xapian version 1.2.15 or higher required.
|
20
|
+
If you have xapian-full-alaveteli installed,
|
21
|
+
Please remove it by running `gem uninstall xapian-full-alaveteli`
|
22
|
+
since it's been replaced by the xapian-ruby gem.
|
23
|
+
|
24
|
+
EOF
|
25
|
+
end
|
26
|
+
|
27
|
+
module Redwood
|
28
|
+
|
29
|
+
# This index implementation uses Xapian for searching and storage. It
|
30
|
+
# tends to be slightly faster than Ferret for indexing and significantly faster
|
31
|
+
# for searching due to precomputing thread membership.
|
32
|
+
class Index
|
33
|
+
include InteractiveLock
|
34
|
+
|
35
|
+
INDEX_VERSION = '4'
|
36
|
+
|
37
|
+
## dates are converted to integers for xapian, and are used for document ids,
|
38
|
+
## so we must ensure they're reasonably valid. this typically only affect
|
39
|
+
## spam.
|
40
|
+
MIN_DATE = Time.at 0
|
41
|
+
MAX_DATE = Time.at(2**31-1)
|
42
|
+
|
43
|
+
HookManager.register "custom-search", <<EOS
|
44
|
+
Executes before a string search is applied to the index,
|
45
|
+
returning a new search string.
|
46
|
+
Variables:
|
47
|
+
subs: The string being searched.
|
48
|
+
EOS
|
49
|
+
|
50
|
+
class LockError < StandardError
|
51
|
+
def initialize h
|
52
|
+
@h = h
|
53
|
+
end
|
54
|
+
|
55
|
+
def method_missing m; @h[m.to_s] end
|
56
|
+
end
|
57
|
+
|
58
|
+
include Redwood::Singleton
|
59
|
+
|
60
|
+
def initialize dir=BASE_DIR
|
61
|
+
@dir = dir
|
62
|
+
FileUtils.mkdir_p @dir
|
63
|
+
@lock = Lockfile.new lockfile, :retries => 0, :max_age => nil
|
64
|
+
@sync_worker = nil
|
65
|
+
@sync_queue = Queue.new
|
66
|
+
@index_mutex = Monitor.new
|
67
|
+
end
|
68
|
+
|
69
|
+
def lockfile; File.join @dir, "lock" end
|
70
|
+
|
71
|
+
def lock
|
72
|
+
debug "locking #{lockfile}..."
|
73
|
+
begin
|
74
|
+
@lock.lock
|
75
|
+
rescue Lockfile::MaxTriesLockError
|
76
|
+
raise LockError, @lock.lockinfo_on_disk
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def start_lock_update_thread
|
81
|
+
@lock_update_thread = Redwood::reporting_thread("lock update") do
|
82
|
+
while true
|
83
|
+
sleep 30
|
84
|
+
@lock.touch_yourself
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def stop_lock_update_thread
|
90
|
+
@lock_update_thread.kill if @lock_update_thread
|
91
|
+
@lock_update_thread = nil
|
92
|
+
end
|
93
|
+
|
94
|
+
def unlock
|
95
|
+
if @lock && @lock.locked?
|
96
|
+
debug "unlocking #{lockfile}..."
|
97
|
+
@lock.unlock
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def load failsafe=false
|
102
|
+
SourceManager.load_sources
|
103
|
+
load_index failsafe
|
104
|
+
end
|
105
|
+
|
106
|
+
def save
|
107
|
+
debug "saving index and sources..."
|
108
|
+
FileUtils.mkdir_p @dir unless File.exists? @dir
|
109
|
+
SourceManager.save_sources
|
110
|
+
save_index
|
111
|
+
end
|
112
|
+
|
113
|
+
def get_xapian
|
114
|
+
@xapian
|
115
|
+
end
|
116
|
+
|
117
|
+
def load_index failsafe=false
|
118
|
+
path = File.join(@dir, 'xapian')
|
119
|
+
if File.exists? path
|
120
|
+
@xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN)
|
121
|
+
db_version = @xapian.get_metadata 'version'
|
122
|
+
db_version = '0' if db_version.empty?
|
123
|
+
if false
|
124
|
+
info "Upgrading index format #{db_version} to #{INDEX_VERSION}"
|
125
|
+
@xapian.set_metadata 'version', INDEX_VERSION
|
126
|
+
elsif db_version != INDEX_VERSION
|
127
|
+
fail "This Sup version expects a v#{INDEX_VERSION} index, but you have an existing v#{db_version} index. Please run sup-dump to save your labels, move #{path} out of the way, and run sup-sync --restore."
|
128
|
+
end
|
129
|
+
else
|
130
|
+
@xapian = Xapian::WritableDatabase.new(path, Xapian::DB_CREATE)
|
131
|
+
@xapian.set_metadata 'version', INDEX_VERSION
|
132
|
+
@xapian.set_metadata 'rescue-version', '0'
|
133
|
+
end
|
134
|
+
@enquire = Xapian::Enquire.new @xapian
|
135
|
+
@enquire.weighting_scheme = Xapian::BoolWeight.new
|
136
|
+
@enquire.docid_order = Xapian::Enquire::ASCENDING
|
137
|
+
end
|
138
|
+
|
139
|
+
def add_message m; sync_message m, true end
|
140
|
+
def update_message m; sync_message m, true end
|
141
|
+
def update_message_state m; sync_message m[0], false, m[1] end
|
142
|
+
|
143
|
+
def save_index
|
144
|
+
info "Flushing Xapian updates to disk. This may take a while..."
|
145
|
+
@xapian.flush
|
146
|
+
end
|
147
|
+
|
148
|
+
def contains_id? id
|
149
|
+
synchronize { find_docid(id) && true }
|
150
|
+
end
|
151
|
+
|
152
|
+
def contains? m; contains_id? m.id end
|
153
|
+
|
154
|
+
def size
|
155
|
+
synchronize { @xapian.doccount }
|
156
|
+
end
|
157
|
+
|
158
|
+
def empty?; size == 0 end
|
159
|
+
|
160
|
+
## Yields a message-id and message-building lambda for each
|
161
|
+
## message that matches the given query, in descending date order.
|
162
|
+
## You should probably not call this on a block that doesn't break
|
163
|
+
## rather quickly because the results can be very large.
|
164
|
+
def each_id_by_date query={}
|
165
|
+
each_id(query) { |id| yield id, lambda { build_message id } }
|
166
|
+
end
|
167
|
+
|
168
|
+
## Return the number of matches for query in the index
|
169
|
+
def num_results_for query={}
|
170
|
+
xapian_query = build_xapian_query query
|
171
|
+
matchset = run_query xapian_query, 0, 0, 100
|
172
|
+
matchset.matches_estimated
|
173
|
+
end
|
174
|
+
|
175
|
+
## check if a message is part of a killed thread
|
176
|
+
## (warning: duplicates code below)
|
177
|
+
## NOTE: We can be more efficient if we assume every
|
178
|
+
## killed message that hasn't been initially added
|
179
|
+
## to the indexi s this way
|
180
|
+
def message_joining_killed? m
|
181
|
+
return false unless doc = find_doc(m.id)
|
182
|
+
queue = doc.value(THREAD_VALUENO).split(',')
|
183
|
+
seen_threads = Set.new
|
184
|
+
seen_messages = Set.new [m.id]
|
185
|
+
while not queue.empty?
|
186
|
+
thread_id = queue.pop
|
187
|
+
next if seen_threads.member? thread_id
|
188
|
+
return true if thread_killed?(thread_id)
|
189
|
+
seen_threads << thread_id
|
190
|
+
docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x }
|
191
|
+
docs.each do |doc|
|
192
|
+
msgid = doc.value MSGID_VALUENO
|
193
|
+
next if seen_messages.member? msgid
|
194
|
+
seen_messages << msgid
|
195
|
+
queue.concat doc.value(THREAD_VALUENO).split(',')
|
196
|
+
end
|
197
|
+
end
|
198
|
+
false
|
199
|
+
end
|
200
|
+
|
201
|
+
## yield all messages in the thread containing 'm' by repeatedly
|
202
|
+
## querying the index. yields pairs of message ids and
|
203
|
+
## message-building lambdas, so that building an unwanted message
|
204
|
+
## can be skipped in the block if desired.
|
205
|
+
##
|
206
|
+
## only two options, :limit and :skip_killed. if :skip_killed is
|
207
|
+
## true, stops loading any thread if a message with a :killed flag
|
208
|
+
## is found.
|
209
|
+
def each_message_in_thread_for m, opts={}
|
210
|
+
# TODO thread by subject
|
211
|
+
return unless doc = find_doc(m.id)
|
212
|
+
queue = doc.value(THREAD_VALUENO).split(',')
|
213
|
+
msgids = [m.id]
|
214
|
+
seen_threads = Set.new
|
215
|
+
seen_messages = Set.new [m.id]
|
216
|
+
while not queue.empty?
|
217
|
+
thread_id = queue.pop
|
218
|
+
next if seen_threads.member? thread_id
|
219
|
+
return false if opts[:skip_killed] && thread_killed?(thread_id)
|
220
|
+
seen_threads << thread_id
|
221
|
+
docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x }
|
222
|
+
docs.each do |doc|
|
223
|
+
msgid = doc.value MSGID_VALUENO
|
224
|
+
next if seen_messages.member? msgid
|
225
|
+
msgids << msgid
|
226
|
+
seen_messages << msgid
|
227
|
+
queue.concat doc.value(THREAD_VALUENO).split(',')
|
228
|
+
end
|
229
|
+
end
|
230
|
+
msgids.each { |id| yield id, lambda { build_message id } }
|
231
|
+
true
|
232
|
+
end
|
233
|
+
|
234
|
+
## Load message with the given message-id from the index
|
235
|
+
def build_message id
|
236
|
+
entry = synchronize { get_entry id }
|
237
|
+
return unless entry
|
238
|
+
|
239
|
+
locations = entry[:locations].map do |source_id,source_info|
|
240
|
+
source = SourceManager[source_id]
|
241
|
+
raise "invalid source #{source_id}" unless source
|
242
|
+
Location.new source, source_info
|
243
|
+
end
|
244
|
+
|
245
|
+
m = Message.new :locations => locations,
|
246
|
+
:labels => entry[:labels],
|
247
|
+
:snippet => entry[:snippet]
|
248
|
+
|
249
|
+
# Try to find person from contacts before falling back to
|
250
|
+
# generating it from the address.
|
251
|
+
mk_person = lambda { |x| Person.from_name_and_email(*x.reverse!) }
|
252
|
+
entry[:from] = mk_person[entry[:from]]
|
253
|
+
entry[:to].map!(&mk_person)
|
254
|
+
entry[:cc].map!(&mk_person)
|
255
|
+
entry[:bcc].map!(&mk_person)
|
256
|
+
|
257
|
+
m.load_from_index! entry
|
258
|
+
m
|
259
|
+
end
|
260
|
+
|
261
|
+
## Delete message with the given message-id from the index
|
262
|
+
def delete id
|
263
|
+
synchronize { @xapian.delete_document mkterm(:msgid, id) }
|
264
|
+
end
|
265
|
+
|
266
|
+
## Given an array of email addresses, return an array of Person objects that
|
267
|
+
## have sent mail to or received mail from any of the given addresses.
|
268
|
+
def load_contacts email_addresses, opts={}
|
269
|
+
contacts = Set.new
|
270
|
+
num = opts[:num] || 20
|
271
|
+
each_id_by_date :participants => email_addresses do |id,b|
|
272
|
+
break if contacts.size >= num
|
273
|
+
m = b.call
|
274
|
+
([m.from]+m.to+m.cc+m.bcc).compact.each { |p| contacts << [p.name, p.email] }
|
275
|
+
end
|
276
|
+
contacts.to_a.compact[0...num].map { |n,e| Person.from_name_and_email n, e }
|
277
|
+
end
|
278
|
+
|
279
|
+
## Yield each message-id matching query
|
280
|
+
EACH_ID_PAGE = 100
|
281
|
+
def each_id query={}, ignore_neg_terms = true
|
282
|
+
offset = 0
|
283
|
+
page = EACH_ID_PAGE
|
284
|
+
|
285
|
+
xapian_query = build_xapian_query query, ignore_neg_terms
|
286
|
+
while true
|
287
|
+
ids = run_query_ids xapian_query, offset, (offset+page)
|
288
|
+
ids.each { |id| yield id }
|
289
|
+
break if ids.size < page
|
290
|
+
offset += page
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
## Yield each message matching query
|
295
|
+
## The ignore_neg_terms parameter is used to display result even if
|
296
|
+
## it contains "forbidden" labels such as :deleted, it is used in
|
297
|
+
## Poll#poll_from when we need to get the location of a message that
|
298
|
+
## may contain these labels
|
299
|
+
def each_message query={}, ignore_neg_terms = true, &b
|
300
|
+
each_id query, ignore_neg_terms do |id|
|
301
|
+
yield build_message(id)
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
# Search messages. Returns an Enumerator.
|
306
|
+
def find_messages query_expr
|
307
|
+
enum_for :each_message, parse_query(query_expr)
|
308
|
+
end
|
309
|
+
|
310
|
+
# wrap all future changes inside a transaction so they're done atomically
|
311
|
+
def begin_transaction
|
312
|
+
synchronize { @xapian.begin_transaction }
|
313
|
+
end
|
314
|
+
|
315
|
+
# complete the transaction and write all previous changes to disk
|
316
|
+
def commit_transaction
|
317
|
+
synchronize { @xapian.commit_transaction }
|
318
|
+
end
|
319
|
+
|
320
|
+
# abort the transaction and revert all changes made since begin_transaction
|
321
|
+
def cancel_transaction
|
322
|
+
synchronize { @xapian.cancel_transaction }
|
323
|
+
end
|
324
|
+
|
325
|
+
## xapian-compact takes too long, so this is a no-op
|
326
|
+
## until we think of something better
|
327
|
+
def optimize
|
328
|
+
end
|
329
|
+
|
330
|
+
## Return the id source of the source the message with the given message-id
|
331
|
+
## was synced from
|
332
|
+
def source_for_id id
|
333
|
+
synchronize { get_entry(id)[:source_id] }
|
334
|
+
end
|
335
|
+
|
336
|
+
## Yields each term in the index that starts with prefix
|
337
|
+
def each_prefixed_term prefix
|
338
|
+
term = @xapian._dangerous_allterms_begin prefix
|
339
|
+
lastTerm = @xapian._dangerous_allterms_end prefix
|
340
|
+
until term.equals lastTerm
|
341
|
+
yield term.term
|
342
|
+
term.next
|
343
|
+
end
|
344
|
+
nil
|
345
|
+
end
|
346
|
+
|
347
|
+
## Yields (in lexicographical order) the source infos of all locations from
|
348
|
+
## the given source with the given source_info prefix
|
349
|
+
def each_source_info source_id, prefix='', &b
|
350
|
+
p = mkterm :location, source_id, prefix
|
351
|
+
each_prefixed_term p do |x|
|
352
|
+
yield prefix + x[p.length..-1]
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
class ParseError < StandardError; end
|
357
|
+
|
358
|
+
# Stemmed
|
359
|
+
NORMAL_PREFIX = {
|
360
|
+
'subject' => {:prefix => 'S', :exclusive => false},
|
361
|
+
'body' => {:prefix => 'B', :exclusive => false},
|
362
|
+
'from_name' => {:prefix => 'FN', :exclusive => false},
|
363
|
+
'to_name' => {:prefix => 'TN', :exclusive => false},
|
364
|
+
'name' => {:prefix => %w(FN TN), :exclusive => false},
|
365
|
+
'attachment' => {:prefix => 'A', :exclusive => false},
|
366
|
+
'email_text' => {:prefix => 'E', :exclusive => false},
|
367
|
+
'' => {:prefix => %w(S B FN TN A E), :exclusive => false},
|
368
|
+
}
|
369
|
+
|
370
|
+
# Unstemmed
|
371
|
+
BOOLEAN_PREFIX = {
|
372
|
+
'type' => {:prefix => 'K', :exclusive => true},
|
373
|
+
'from_email' => {:prefix => 'FE', :exclusive => false},
|
374
|
+
'to_email' => {:prefix => 'TE', :exclusive => false},
|
375
|
+
'email' => {:prefix => %w(FE TE), :exclusive => false},
|
376
|
+
'date' => {:prefix => 'D', :exclusive => true},
|
377
|
+
'label' => {:prefix => 'L', :exclusive => false},
|
378
|
+
'source_id' => {:prefix => 'I', :exclusive => true},
|
379
|
+
'attachment_extension' => {:prefix => 'O', :exclusive => false},
|
380
|
+
'msgid' => {:prefix => 'Q', :exclusive => true},
|
381
|
+
'id' => {:prefix => 'Q', :exclusive => true},
|
382
|
+
'thread' => {:prefix => 'H', :exclusive => false},
|
383
|
+
'ref' => {:prefix => 'R', :exclusive => false},
|
384
|
+
'location' => {:prefix => 'J', :exclusive => false},
|
385
|
+
}
|
386
|
+
|
387
|
+
PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
|
388
|
+
|
389
|
+
COMPL_OPERATORS = %w[AND OR NOT]
|
390
|
+
COMPL_PREFIXES = (
|
391
|
+
%w[
|
392
|
+
from to
|
393
|
+
is has label
|
394
|
+
filename filetypem
|
395
|
+
before on in during after
|
396
|
+
limit
|
397
|
+
] + NORMAL_PREFIX.keys + BOOLEAN_PREFIX.keys
|
398
|
+
).map{|p|"#{p}:"} + COMPL_OPERATORS
|
399
|
+
|
400
|
+
## parse a query string from the user. returns a query object
|
401
|
+
## that can be passed to any index method with a 'query'
|
402
|
+
## argument.
|
403
|
+
##
|
404
|
+
## raises a ParseError if something went wrong.
|
405
|
+
def parse_query s
|
406
|
+
query = {}
|
407
|
+
|
408
|
+
subs = HookManager.run("custom-search", :subs => s) || s
|
409
|
+
begin
|
410
|
+
subs = SearchManager.expand subs
|
411
|
+
rescue SearchManager::ExpansionError => e
|
412
|
+
raise ParseError, e.message
|
413
|
+
end
|
414
|
+
subs = subs.gsub(/\b(to|from):(\S+)\b/) do
|
415
|
+
field, value = $1, $2
|
416
|
+
email_field, name_field = %w(email name).map { |x| "#{field}_#{x}" }
|
417
|
+
if(p = ContactManager.contact_for(value))
|
418
|
+
"#{email_field}:#{p.email}"
|
419
|
+
elsif value == "me"
|
420
|
+
'(' + AccountManager.user_emails.map { |e| "#{email_field}:#{e}" }.join(' OR ') + ')'
|
421
|
+
else
|
422
|
+
"(#{email_field}:#{value} OR #{name_field}:#{value})"
|
423
|
+
end
|
424
|
+
end
|
425
|
+
|
426
|
+
## gmail style "is" operator
|
427
|
+
subs = subs.gsub(/\b(is|has):(\S+)\b/) do
|
428
|
+
field, label = $1, $2
|
429
|
+
case label
|
430
|
+
when "read"
|
431
|
+
"-label:unread"
|
432
|
+
when "spam"
|
433
|
+
query[:load_spam] = true
|
434
|
+
"label:spam"
|
435
|
+
when "deleted"
|
436
|
+
query[:load_deleted] = true
|
437
|
+
"label:deleted"
|
438
|
+
else
|
439
|
+
"label:#{$2}"
|
440
|
+
end
|
441
|
+
end
|
442
|
+
|
443
|
+
## labels are stored lower-case in the index
|
444
|
+
subs = subs.gsub(/\blabel:(\S+)\b/) do
|
445
|
+
label = $1
|
446
|
+
"label:#{label.downcase}"
|
447
|
+
end
|
448
|
+
|
449
|
+
## if we see a label:deleted or a label:spam term anywhere in the query
|
450
|
+
## string, we set the extra load_spam or load_deleted options to true.
|
451
|
+
## bizarre? well, because the query allows arbitrary parenthesized boolean
|
452
|
+
## expressions, without fully parsing the query, we can't tell whether
|
453
|
+
## the user is explicitly directing us to search spam messages or not.
|
454
|
+
## e.g. if the string is -(-(-(-(-label:spam)))), does the user want to
|
455
|
+
## search spam messages or not?
|
456
|
+
##
|
457
|
+
## so, we rely on the fact that turning these extra options ON turns OFF
|
458
|
+
## the adding of "-label:deleted" or "-label:spam" terms at the very
|
459
|
+
## final stage of query processing. if the user wants to search spam
|
460
|
+
## messages, not adding that is the right thing; if he doesn't want to
|
461
|
+
## search spam messages, then not adding it won't have any effect.
|
462
|
+
query[:load_spam] = true if subs =~ /\blabel:spam\b/
|
463
|
+
query[:load_deleted] = true if subs =~ /\blabel:deleted\b/
|
464
|
+
query[:load_killed] = true if subs =~ /\blabel:killed\b/
|
465
|
+
|
466
|
+
## gmail style attachments "filename" and "filetype" searches
|
467
|
+
subs = subs.gsub(/\b(filename|filetype):(\((.+?)\)\B|(\S+)\b)/) do
|
468
|
+
field, name = $1, ($3 || $4)
|
469
|
+
case field
|
470
|
+
when "filename"
|
471
|
+
debug "filename: translated #{field}:#{name} to attachment:\"#{name.downcase}\""
|
472
|
+
"attachment:\"#{name.downcase}\""
|
473
|
+
when "filetype"
|
474
|
+
debug "filetype: translated #{field}:#{name} to attachment_extension:#{name.downcase}"
|
475
|
+
"attachment_extension:#{name.downcase}"
|
476
|
+
end
|
477
|
+
end
|
478
|
+
|
479
|
+
lastdate = 2<<32 - 1
|
480
|
+
firstdate = 0
|
481
|
+
subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
|
482
|
+
field, datestr = $1, ($3 || $4)
|
483
|
+
realdate = Chronic.parse datestr, :guess => false, :context => :past
|
484
|
+
if realdate
|
485
|
+
case field
|
486
|
+
when "after"
|
487
|
+
debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
|
488
|
+
"date:#{realdate.end.to_i}..#{lastdate}"
|
489
|
+
when "before"
|
490
|
+
debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
|
491
|
+
"date:#{firstdate}..#{realdate.end.to_i}"
|
492
|
+
else
|
493
|
+
debug "chronic: translated #{field}:#{datestr} to #{realdate}"
|
494
|
+
"date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
|
495
|
+
end
|
496
|
+
else
|
497
|
+
raise ParseError, "can't understand date #{datestr.inspect}"
|
498
|
+
end
|
499
|
+
end
|
500
|
+
|
501
|
+
## limit:42 restrict the search to 42 results
|
502
|
+
subs = subs.gsub(/\blimit:(\S+)\b/) do
|
503
|
+
lim = $1
|
504
|
+
if lim =~ /^\d+$/
|
505
|
+
query[:limit] = lim.to_i
|
506
|
+
''
|
507
|
+
else
|
508
|
+
raise ParseError, "non-numeric limit #{lim.inspect}"
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
debug "translated query: #{subs.inspect}"
|
513
|
+
|
514
|
+
qp = Xapian::QueryParser.new
|
515
|
+
qp.database = @xapian
|
516
|
+
qp.stemmer = Xapian::Stem.new($config[:stem_language])
|
517
|
+
qp.stemming_strategy = Xapian::QueryParser::STEM_SOME
|
518
|
+
qp.default_op = Xapian::Query::OP_AND
|
519
|
+
qp.add_valuerangeprocessor(Xapian::NumberValueRangeProcessor.new(DATE_VALUENO, 'date:', true))
|
520
|
+
NORMAL_PREFIX.each { |k,info| info[:prefix].each { |v| qp.add_prefix k, v } }
|
521
|
+
BOOLEAN_PREFIX.each { |k,info| info[:prefix].each { |v| qp.add_boolean_prefix k, v, info[:exclusive] } }
|
522
|
+
|
523
|
+
begin
|
524
|
+
xapian_query = qp.parse_query(subs, Xapian::QueryParser::FLAG_PHRASE|Xapian::QueryParser::FLAG_BOOLEAN|Xapian::QueryParser::FLAG_LOVEHATE|Xapian::QueryParser::FLAG_WILDCARD)
|
525
|
+
rescue RuntimeError => e
|
526
|
+
raise ParseError, "xapian query parser error: #{e}"
|
527
|
+
end
|
528
|
+
|
529
|
+
debug "parsed xapian query: #{Util::Query.describe(xapian_query, subs)}"
|
530
|
+
|
531
|
+
raise ParseError if xapian_query.nil? or xapian_query.empty?
|
532
|
+
query[:qobj] = xapian_query
|
533
|
+
query[:text] = s
|
534
|
+
query
|
535
|
+
end
|
536
|
+
|
537
|
+
def save_message m, sync_back = true
|
538
|
+
if @sync_worker
|
539
|
+
@sync_queue << [m, sync_back]
|
540
|
+
else
|
541
|
+
update_message_state [m, sync_back]
|
542
|
+
end
|
543
|
+
m.clear_dirty
|
544
|
+
end
|
545
|
+
|
546
|
+
def save_thread t, sync_back = true
|
547
|
+
t.each_dirty_message do |m|
|
548
|
+
save_message m, sync_back
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
552
|
+
def start_sync_worker
|
553
|
+
@sync_worker = Redwood::reporting_thread('index sync') { run_sync_worker }
|
554
|
+
end
|
555
|
+
|
556
|
+
def stop_sync_worker
|
557
|
+
return unless worker = @sync_worker
|
558
|
+
@sync_worker = nil
|
559
|
+
@sync_queue << :die
|
560
|
+
worker.join
|
561
|
+
end
|
562
|
+
|
563
|
+
def run_sync_worker
|
564
|
+
while m = @sync_queue.deq
|
565
|
+
return if m == :die
|
566
|
+
update_message_state m
|
567
|
+
# Necessary to keep Xapian calls from lagging the UI too much.
|
568
|
+
sleep 0.03
|
569
|
+
end
|
570
|
+
end
|
571
|
+
|
572
|
+
private
|
573
|
+
|
574
|
+
MSGID_VALUENO = 0
|
575
|
+
THREAD_VALUENO = 1
|
576
|
+
DATE_VALUENO = 2
|
577
|
+
|
578
|
+
MAX_TERM_LENGTH = 245
|
579
|
+
|
580
|
+
# Xapian can very efficiently sort in ascending docid order. Sup always wants
|
581
|
+
# to sort by descending date, so this method maps between them. In order to
|
582
|
+
# handle multiple messages per second, we use a logistic curve centered
|
583
|
+
# around MIDDLE_DATE so that the slope (docid/s) is greatest in this time
|
584
|
+
# period. A docid collision is not an error - the code will pick the next
|
585
|
+
# smallest unused one.
|
586
|
+
DOCID_SCALE = 2.0**32
|
587
|
+
TIME_SCALE = 2.0**27
|
588
|
+
MIDDLE_DATE = Time.gm(2011)
|
589
|
+
def assign_docid m, truncated_date
|
590
|
+
t = (truncated_date.to_i - MIDDLE_DATE.to_i).to_f
|
591
|
+
docid = (DOCID_SCALE - DOCID_SCALE/(Math::E**(-(t/TIME_SCALE)) + 1)).to_i
|
592
|
+
while docid > 0 and docid_exists? docid
|
593
|
+
docid -= 1
|
594
|
+
end
|
595
|
+
docid > 0 ? docid : nil
|
596
|
+
end
|
597
|
+
|
598
|
+
# XXX is there a better way?
|
599
|
+
def docid_exists? docid
|
600
|
+
begin
|
601
|
+
@xapian.doclength docid
|
602
|
+
true
|
603
|
+
rescue RuntimeError #Xapian::DocNotFoundError
|
604
|
+
raise unless $!.message =~ /DocNotFoundError/
|
605
|
+
false
|
606
|
+
end
|
607
|
+
end
|
608
|
+
|
609
|
+
def term_docids term
|
610
|
+
@xapian.postlist(term).map { |x| x.docid }
|
611
|
+
end
|
612
|
+
|
613
|
+
def find_docid id
|
614
|
+
docids = term_docids(mkterm(:msgid,id))
|
615
|
+
fail unless docids.size <= 1
|
616
|
+
docids.first
|
617
|
+
end
|
618
|
+
|
619
|
+
def find_doc id
|
620
|
+
return unless docid = find_docid(id)
|
621
|
+
@xapian.document docid
|
622
|
+
end
|
623
|
+
|
624
|
+
def get_id docid
|
625
|
+
return unless doc = @xapian.document(docid)
|
626
|
+
doc.value MSGID_VALUENO
|
627
|
+
end
|
628
|
+
|
629
|
+
def get_entry id
|
630
|
+
return unless doc = find_doc(id)
|
631
|
+
doc.entry
|
632
|
+
end
|
633
|
+
|
634
|
+
def thread_killed? thread_id
|
635
|
+
not run_query(Q.new(Q::OP_AND, mkterm(:thread, thread_id), mkterm(:label, :Killed)), 0, 1).empty?
|
636
|
+
end
|
637
|
+
|
638
|
+
def synchronize &b
|
639
|
+
@index_mutex.synchronize &b
|
640
|
+
end
|
641
|
+
|
642
|
+
def run_query xapian_query, offset, limit, checkatleast=0
|
643
|
+
synchronize do
|
644
|
+
@enquire.query = xapian_query
|
645
|
+
@enquire.mset(offset, limit-offset, checkatleast)
|
646
|
+
end
|
647
|
+
end
|
648
|
+
|
649
|
+
def run_query_ids xapian_query, offset, limit
|
650
|
+
matchset = run_query xapian_query, offset, limit
|
651
|
+
matchset.matches.map { |r| r.document.value MSGID_VALUENO }
|
652
|
+
end
|
653
|
+
|
654
|
+
Q = Xapian::Query
|
655
|
+
def build_xapian_query opts, ignore_neg_terms = true
|
656
|
+
labels = ([opts[:label]] + (opts[:labels] || [])).compact
|
657
|
+
neglabels = [:spam, :deleted, :killed].reject { |l| (labels.include? l) || opts.member?("load_#{l}".intern) }
|
658
|
+
pos_terms, neg_terms = [], []
|
659
|
+
|
660
|
+
pos_terms << mkterm(:type, 'mail')
|
661
|
+
pos_terms.concat(labels.map { |l| mkterm(:label,l) })
|
662
|
+
pos_terms << opts[:qobj] if opts[:qobj]
|
663
|
+
pos_terms << mkterm(:source_id, opts[:source_id]) if opts[:source_id]
|
664
|
+
pos_terms << mkterm(:location, *opts[:location]) if opts[:location]
|
665
|
+
|
666
|
+
if opts[:participants]
|
667
|
+
participant_terms = opts[:participants].map { |p| [:from,:to].map { |d| mkterm(:email, d, (Redwood::Person === p) ? p.email : p) } }.flatten
|
668
|
+
pos_terms << Q.new(Q::OP_OR, participant_terms)
|
669
|
+
end
|
670
|
+
|
671
|
+
neg_terms.concat(neglabels.map { |l| mkterm(:label,l) }) if ignore_neg_terms
|
672
|
+
|
673
|
+
pos_query = Q.new(Q::OP_AND, pos_terms)
|
674
|
+
neg_query = Q.new(Q::OP_OR, neg_terms)
|
675
|
+
|
676
|
+
if neg_query.empty?
|
677
|
+
pos_query
|
678
|
+
else
|
679
|
+
Q.new(Q::OP_AND_NOT, [pos_query, neg_query])
|
680
|
+
end
|
681
|
+
end
|
682
|
+
|
683
|
+
def sync_message m, overwrite, sync_back = true
|
684
|
+
## TODO: we should not save the message if the sync_back failed
|
685
|
+
## since it would overwrite the location field
|
686
|
+
m.sync_back if sync_back
|
687
|
+
|
688
|
+
doc = synchronize { find_doc(m.id) }
|
689
|
+
existed = doc != nil
|
690
|
+
doc ||= Xapian::Document.new
|
691
|
+
do_index_static = overwrite || !existed
|
692
|
+
old_entry = !do_index_static && doc.entry
|
693
|
+
snippet = do_index_static ? m.snippet : old_entry[:snippet]
|
694
|
+
|
695
|
+
entry = {
|
696
|
+
:message_id => m.id,
|
697
|
+
:locations => m.locations.map { |x| [x.source.id, x.info] },
|
698
|
+
:date => truncate_date(m.date),
|
699
|
+
:snippet => snippet,
|
700
|
+
:labels => m.labels.to_a,
|
701
|
+
:from => [m.from.email, m.from.name],
|
702
|
+
:to => m.to.map { |p| [p.email, p.name] },
|
703
|
+
:cc => m.cc.map { |p| [p.email, p.name] },
|
704
|
+
:bcc => m.bcc.map { |p| [p.email, p.name] },
|
705
|
+
:subject => m.subj,
|
706
|
+
:refs => m.refs.to_a,
|
707
|
+
:replytos => m.replytos.to_a,
|
708
|
+
}
|
709
|
+
|
710
|
+
if do_index_static
|
711
|
+
doc.clear_terms
|
712
|
+
doc.clear_values
|
713
|
+
index_message_static m, doc, entry
|
714
|
+
end
|
715
|
+
|
716
|
+
index_message_locations doc, entry, old_entry
|
717
|
+
index_message_threading doc, entry, old_entry
|
718
|
+
index_message_labels doc, entry[:labels], (do_index_static ? [] : old_entry[:labels])
|
719
|
+
doc.entry = entry
|
720
|
+
|
721
|
+
synchronize do
|
722
|
+
unless docid = existed ? doc.docid : assign_docid(m, truncate_date(m.date))
|
723
|
+
# Could be triggered by spam
|
724
|
+
warn "docid underflow, dropping #{m.id.inspect}"
|
725
|
+
return
|
726
|
+
end
|
727
|
+
@xapian.replace_document docid, doc
|
728
|
+
end
|
729
|
+
|
730
|
+
m.labels.each { |l| LabelManager << l }
|
731
|
+
true
|
732
|
+
end
|
733
|
+
|
734
|
+
## Index content that can't be changed by the user
|
735
|
+
def index_message_static m, doc, entry
|
736
|
+
# Person names are indexed with several prefixes
|
737
|
+
person_termer = lambda do |d|
|
738
|
+
lambda do |p|
|
739
|
+
doc.index_text p.name, PREFIX["#{d}_name"][:prefix] if p.name
|
740
|
+
doc.index_text p.email, PREFIX['email_text'][:prefix]
|
741
|
+
doc.add_term mkterm(:email, d, p.email)
|
742
|
+
end
|
743
|
+
end
|
744
|
+
|
745
|
+
person_termer[:from][m.from] if m.from
|
746
|
+
(m.to+m.cc+m.bcc).each(&(person_termer[:to]))
|
747
|
+
|
748
|
+
# Full text search content
|
749
|
+
subject_text = m.indexable_subject
|
750
|
+
body_text = m.indexable_body
|
751
|
+
doc.index_text subject_text, PREFIX['subject'][:prefix]
|
752
|
+
doc.index_text body_text, PREFIX['body'][:prefix]
|
753
|
+
m.attachments.each { |a| doc.index_text a, PREFIX['attachment'][:prefix] }
|
754
|
+
|
755
|
+
# Miscellaneous terms
|
756
|
+
doc.add_term mkterm(:date, m.date) if m.date
|
757
|
+
doc.add_term mkterm(:type, 'mail')
|
758
|
+
doc.add_term mkterm(:msgid, m.id)
|
759
|
+
m.attachments.each do |a|
|
760
|
+
a =~ /\.(\w+)$/ or next
|
761
|
+
doc.add_term mkterm(:attachment_extension, $1)
|
762
|
+
end
|
763
|
+
|
764
|
+
# Date value for range queries
|
765
|
+
date_value = begin
|
766
|
+
Xapian.sortable_serialise m.date.to_i
|
767
|
+
rescue TypeError
|
768
|
+
Xapian.sortable_serialise 0
|
769
|
+
end
|
770
|
+
|
771
|
+
doc.add_value MSGID_VALUENO, m.id
|
772
|
+
doc.add_value DATE_VALUENO, date_value
|
773
|
+
end
|
774
|
+
|
775
|
+
def index_message_locations doc, entry, old_entry
|
776
|
+
old_entry[:locations].map { |x| x[0] }.uniq.each { |x| doc.remove_term mkterm(:source_id, x) } if old_entry
|
777
|
+
entry[:locations].map { |x| x[0] }.uniq.each { |x| doc.add_term mkterm(:source_id, x) }
|
778
|
+
old_entry[:locations].each { |x| (doc.remove_term mkterm(:location, *x) rescue nil) } if old_entry
|
779
|
+
entry[:locations].each { |x| doc.add_term mkterm(:location, *x) }
|
780
|
+
end
|
781
|
+
|
782
|
+
def index_message_labels doc, new_labels, old_labels
|
783
|
+
return if new_labels == old_labels
|
784
|
+
added = new_labels.to_a - old_labels.to_a
|
785
|
+
removed = old_labels.to_a - new_labels.to_a
|
786
|
+
added.each { |t| doc.add_term mkterm(:label,t) }
|
787
|
+
removed.each { |t| doc.remove_term mkterm(:label,t) }
|
788
|
+
end
|
789
|
+
|
790
|
+
## Assign a set of thread ids to the document. This is a hybrid of the runtime
|
791
|
+
## search done by the Ferret index and the index-time union done by previous
|
792
|
+
## versions of the Xapian index. We first find the thread ids of all messages
|
793
|
+
## with a reference to or from us. If that set is empty, we use our own
|
794
|
+
## message id. Otherwise, we use all the thread ids we previously found. In
|
795
|
+
## the common case there's only one member in that set, but if we're the
|
796
|
+
## missing link between multiple previously unrelated threads we can have
|
797
|
+
## more. XapianIndex#each_message_in_thread_for follows the thread ids when
|
798
|
+
## searching so the user sees a single unified thread.
|
799
|
+
def index_message_threading doc, entry, old_entry
|
800
|
+
return if old_entry && (entry[:refs] == old_entry[:refs]) && (entry[:replytos] == old_entry[:replytos])
|
801
|
+
children = term_docids(mkterm(:ref, entry[:message_id])).map { |docid| @xapian.document docid }
|
802
|
+
parent_ids = entry[:refs] + entry[:replytos]
|
803
|
+
parents = parent_ids.map { |id| find_doc id }.compact
|
804
|
+
thread_members = SavingHash.new { [] }
|
805
|
+
(children + parents).each do |doc2|
|
806
|
+
thread_ids = doc2.value(THREAD_VALUENO).split ','
|
807
|
+
thread_ids.each { |thread_id| thread_members[thread_id] << doc2 }
|
808
|
+
end
|
809
|
+
thread_ids = thread_members.empty? ? [entry[:message_id]] : thread_members.keys
|
810
|
+
thread_ids.each { |thread_id| doc.add_term mkterm(:thread, thread_id) }
|
811
|
+
parent_ids.each { |ref| doc.add_term mkterm(:ref, ref) }
|
812
|
+
doc.add_value THREAD_VALUENO, (thread_ids * ',')
|
813
|
+
end
|
814
|
+
|
815
|
+
def truncate_date date
|
816
|
+
if date < MIN_DATE
|
817
|
+
debug "warning: adjusting too-low date #{date} for indexing"
|
818
|
+
MIN_DATE
|
819
|
+
elsif date > MAX_DATE
|
820
|
+
debug "warning: adjusting too-high date #{date} for indexing"
|
821
|
+
MAX_DATE
|
822
|
+
else
|
823
|
+
date
|
824
|
+
end
|
825
|
+
end
|
826
|
+
|
827
|
+
# Construct a Xapian term
|
828
|
+
def mkterm type, *args
|
829
|
+
case type
|
830
|
+
when :label
|
831
|
+
PREFIX['label'][:prefix] + args[0].to_s.downcase
|
832
|
+
when :type
|
833
|
+
PREFIX['type'][:prefix] + args[0].to_s.downcase
|
834
|
+
when :date
|
835
|
+
PREFIX['date'][:prefix] + args[0].getutc.strftime("%Y%m%d%H%M%S")
|
836
|
+
when :email
|
837
|
+
case args[0]
|
838
|
+
when :from then PREFIX['from_email'][:prefix]
|
839
|
+
when :to then PREFIX['to_email'][:prefix]
|
840
|
+
else raise "Invalid email term type #{args[0]}"
|
841
|
+
end + args[1].to_s.downcase
|
842
|
+
when :source_id
|
843
|
+
PREFIX['source_id'][:prefix] + args[0].to_s.downcase
|
844
|
+
when :location
|
845
|
+
PREFIX['location'][:prefix] + [args[0]].pack('n') + args[1].to_s
|
846
|
+
when :attachment_extension
|
847
|
+
PREFIX['attachment_extension'][:prefix] + args[0].to_s.downcase
|
848
|
+
when :msgid, :ref, :thread
|
849
|
+
PREFIX[type.to_s][:prefix] + args[0][0...(MAX_TERM_LENGTH-1)]
|
850
|
+
else
|
851
|
+
raise "Invalid term type #{type}"
|
852
|
+
end
|
853
|
+
end
|
854
|
+
end
|
855
|
+
|
856
|
+
end
|
857
|
+
|
858
|
+
class Xapian::Document
|
859
|
+
def entry
|
860
|
+
Marshal.load data
|
861
|
+
end
|
862
|
+
|
863
|
+
def entry=(x)
|
864
|
+
self.data = Marshal.dump x
|
865
|
+
end
|
866
|
+
|
867
|
+
def index_text text, prefix, weight=1
|
868
|
+
term_generator = Xapian::TermGenerator.new
|
869
|
+
term_generator.stemmer = Xapian::Stem.new($config[:stem_language])
|
870
|
+
term_generator.document = self
|
871
|
+
term_generator.index_text text, weight, prefix
|
872
|
+
end
|
873
|
+
|
874
|
+
alias old_add_term add_term
|
875
|
+
def add_term term
|
876
|
+
if term.length <= Redwood::Index::MAX_TERM_LENGTH
|
877
|
+
old_add_term term, 0
|
878
|
+
else
|
879
|
+
warn "dropping excessively long term #{term}"
|
880
|
+
end
|
881
|
+
end
|
882
|
+
end
|