sup 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|