sup 0.19.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +12 -0
  4. data/CONTRIBUTORS +84 -0
  5. data/Gemfile +3 -0
  6. data/HACKING +42 -0
  7. data/History.txt +361 -0
  8. data/LICENSE +280 -0
  9. data/README.md +70 -0
  10. data/Rakefile +12 -0
  11. data/ReleaseNotes +231 -0
  12. data/bin/sup +434 -0
  13. data/bin/sup-add +118 -0
  14. data/bin/sup-config +243 -0
  15. data/bin/sup-dump +43 -0
  16. data/bin/sup-import-dump +101 -0
  17. data/bin/sup-psych-ify-config-files +21 -0
  18. data/bin/sup-recover-sources +87 -0
  19. data/bin/sup-sync +210 -0
  20. data/bin/sup-sync-back-maildir +127 -0
  21. data/bin/sup-tweak-labels +140 -0
  22. data/contrib/colorpicker.rb +100 -0
  23. data/contrib/completion/_sup.zsh +114 -0
  24. data/devel/console.sh +3 -0
  25. data/devel/count-loc.sh +3 -0
  26. data/devel/load-index.rb +9 -0
  27. data/devel/profile.rb +12 -0
  28. data/devel/start-console.rb +5 -0
  29. data/doc/FAQ.txt +119 -0
  30. data/doc/Hooks.txt +79 -0
  31. data/doc/Philosophy.txt +69 -0
  32. data/lib/sup.rb +467 -0
  33. data/lib/sup/account.rb +90 -0
  34. data/lib/sup/buffer.rb +768 -0
  35. data/lib/sup/colormap.rb +239 -0
  36. data/lib/sup/contact.rb +67 -0
  37. data/lib/sup/crypto.rb +461 -0
  38. data/lib/sup/draft.rb +119 -0
  39. data/lib/sup/hook.rb +159 -0
  40. data/lib/sup/horizontal_selector.rb +59 -0
  41. data/lib/sup/idle.rb +42 -0
  42. data/lib/sup/index.rb +882 -0
  43. data/lib/sup/interactive_lock.rb +89 -0
  44. data/lib/sup/keymap.rb +140 -0
  45. data/lib/sup/label.rb +87 -0
  46. data/lib/sup/logger.rb +77 -0
  47. data/lib/sup/logger/singleton.rb +10 -0
  48. data/lib/sup/maildir.rb +257 -0
  49. data/lib/sup/mbox.rb +187 -0
  50. data/lib/sup/message.rb +803 -0
  51. data/lib/sup/message_chunks.rb +328 -0
  52. data/lib/sup/mode.rb +140 -0
  53. data/lib/sup/modes/buffer_list_mode.rb +50 -0
  54. data/lib/sup/modes/completion_mode.rb +55 -0
  55. data/lib/sup/modes/compose_mode.rb +38 -0
  56. data/lib/sup/modes/console_mode.rb +125 -0
  57. data/lib/sup/modes/contact_list_mode.rb +148 -0
  58. data/lib/sup/modes/edit_message_async_mode.rb +110 -0
  59. data/lib/sup/modes/edit_message_mode.rb +728 -0
  60. data/lib/sup/modes/file_browser_mode.rb +109 -0
  61. data/lib/sup/modes/forward_mode.rb +82 -0
  62. data/lib/sup/modes/help_mode.rb +19 -0
  63. data/lib/sup/modes/inbox_mode.rb +85 -0
  64. data/lib/sup/modes/label_list_mode.rb +138 -0
  65. data/lib/sup/modes/label_search_results_mode.rb +38 -0
  66. data/lib/sup/modes/line_cursor_mode.rb +203 -0
  67. data/lib/sup/modes/log_mode.rb +57 -0
  68. data/lib/sup/modes/person_search_results_mode.rb +12 -0
  69. data/lib/sup/modes/poll_mode.rb +19 -0
  70. data/lib/sup/modes/reply_mode.rb +228 -0
  71. data/lib/sup/modes/resume_mode.rb +52 -0
  72. data/lib/sup/modes/scroll_mode.rb +252 -0
  73. data/lib/sup/modes/search_list_mode.rb +204 -0
  74. data/lib/sup/modes/search_results_mode.rb +59 -0
  75. data/lib/sup/modes/text_mode.rb +76 -0
  76. data/lib/sup/modes/thread_index_mode.rb +1033 -0
  77. data/lib/sup/modes/thread_view_mode.rb +941 -0
  78. data/lib/sup/person.rb +134 -0
  79. data/lib/sup/poll.rb +272 -0
  80. data/lib/sup/rfc2047.rb +56 -0
  81. data/lib/sup/search.rb +110 -0
  82. data/lib/sup/sent.rb +58 -0
  83. data/lib/sup/service/label_service.rb +45 -0
  84. data/lib/sup/source.rb +244 -0
  85. data/lib/sup/tagger.rb +50 -0
  86. data/lib/sup/textfield.rb +253 -0
  87. data/lib/sup/thread.rb +452 -0
  88. data/lib/sup/time.rb +93 -0
  89. data/lib/sup/undo.rb +38 -0
  90. data/lib/sup/update.rb +30 -0
  91. data/lib/sup/util.rb +747 -0
  92. data/lib/sup/util/ncurses.rb +274 -0
  93. data/lib/sup/util/path.rb +9 -0
  94. data/lib/sup/util/query.rb +17 -0
  95. data/lib/sup/util/uri.rb +15 -0
  96. data/lib/sup/version.rb +3 -0
  97. data/sup.gemspec +53 -0
  98. data/test/dummy_source.rb +61 -0
  99. data/test/gnupg_test_home/gpg.conf +1 -0
  100. data/test/gnupg_test_home/pubring.gpg +0 -0
  101. data/test/gnupg_test_home/receiver_pubring.gpg +0 -0
  102. data/test/gnupg_test_home/receiver_secring.gpg +0 -0
  103. data/test/gnupg_test_home/receiver_trustdb.gpg +0 -0
  104. data/test/gnupg_test_home/secring.gpg +0 -0
  105. data/test/gnupg_test_home/sup-test-2@foo.bar.asc +20 -0
  106. data/test/gnupg_test_home/trustdb.gpg +0 -0
  107. data/test/integration/test_label_service.rb +18 -0
  108. data/test/messages/bad-content-transfer-encoding-1.eml +8 -0
  109. data/test/messages/binary-content-transfer-encoding-2.eml +21 -0
  110. data/test/messages/missing-line.eml +9 -0
  111. data/test/test_crypto.rb +109 -0
  112. data/test/test_header_parsing.rb +168 -0
  113. data/test/test_helper.rb +7 -0
  114. data/test/test_message.rb +532 -0
  115. data/test/test_messages_dir.rb +147 -0
  116. data/test/test_yaml_migration.rb +85 -0
  117. data/test/test_yaml_regressions.rb +17 -0
  118. data/test/unit/service/test_label_service.rb +19 -0
  119. data/test/unit/test_horizontal_selector.rb +40 -0
  120. data/test/unit/util/test_query.rb +46 -0
  121. data/test/unit/util/test_string.rb +57 -0
  122. data/test/unit/util/test_uri.rb +19 -0
  123. metadata +423 -0
@@ -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
@@ -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
@@ -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
@@ -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