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.
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