]> git.cworth.org Git - sup/blob - bin/sup-sync
Merge branch 'speedy-index' into next
[sup] / bin / sup-sync
1 #!/usr/bin/env ruby
2
3 require 'uri'
4 require 'rubygems'
5 require 'trollop'
6 require "sup"
7
8 class Float
9   def to_s; sprintf '%.2f', self; end
10    def to_time_s
11      infinite? ? "unknown" : super
12    end
13 end
14
15 class Numeric
16   def to_time_s
17     i = to_i
18     sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60
19   end
20 end
21
22 def time
23   startt = Time.now
24   yield
25   Time.now - startt
26 end
27
28 opts = Trollop::options do
29   version "sup-sync (sup #{Redwood::VERSION})"
30   banner <<EOS
31 Synchronizes the Sup index with one or more message sources by adding
32 messages, deleting messages, or changing message state in the index as
33 appropriate.
34
35 "Message state" means read/unread, archived/inbox, starred/unstarred,
36 and all user-defined labels on each message.
37
38 "Default source state" refers to any state that a source itself has
39 keeps about a message. Sup-sync uses this information when adding a
40 new message to the index. The source state is typically limited to
41 read/unread, archived/inbox status and a single label based on the
42 source name. Messages using the default source state are placed in
43 the inbox (i.e. not archived) and unstarred.
44
45 Usage:
46   sup-sync [options] <source>*
47
48 where <source>* is zero or more source URIs. If no sources are given,
49 sync from all usual sources. Supported source URI schemes can be seen
50 by running "sup-add --help".
51
52 Options controlling WHICH messages sup-sync operates on:
53 EOS
54   opt :new, "Operate on new messages only. Don't scan over the entire source. (Default.)", :short => :none
55   opt :changed, "Scan over the entire source for messages that have been deleted, altered, or moved from another source. (In the case of mbox sources, this includes all messages AFTER an altered message.)"
56   opt :restored, "Operate only on those messages included in a dump file as specified by --restore which have changed state."
57   opt :all, "Operate on all messages in the source, regardless of newness or changedness."
58   opt :start_at, "For --changed and --all, start at a particular offset.", :type => :int
59
60 text <<EOS
61
62 Options controlling HOW message state is altered:
63 EOS
64   opt :asis, "If the message is already in the index, preserve its state. Otherwise, use default source state. (Default.)", :short => :none
65   opt :restore, "Restore message state from a dump file created with sup-dump. If a message is not in this dumpfile, act as --asis.", :type => String, :short => :none
66   opt :discard, "Discard any message state in the index and use the default source state. Dangerous!", :short => :none
67   opt :archive, "When using the default source state, mark messages as archived.", :short => "-x"
68   opt :read, "When using the default source state, mark messages as read."
69   opt :extra_labels, "When using the default source state, also apply these user-defined labels. Should be a comma-separated list.", :type => String, :short => :none
70
71 text <<EOS
72
73 Other options:
74 EOS
75   opt :verbose, "Print message ids as they're processed."
76   opt :optimize, "As the final operation, optimize the index."
77   opt :all_sources, "Scan over all sources.", :short => :none
78   opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n"
79   opt :version, "Show version information", :short => :none
80
81   conflicts :changed, :all, :new, :restored
82   conflicts :asis, :restore, :discard
83 end
84 Trollop::die :restored, "requires --restore" if opts[:restored] unless opts[:restore]
85 if opts[:start_at]
86   Trollop::die :start_at, "must be non-negative" if opts[:start_at] < 0
87   Trollop::die :start_at, "requires either --changed or --all" unless opts[:changed] || opts[:all]
88 end
89
90 target = [:new, :changed, :all, :restored].find { |x| opts[x] } || :new
91 op = [:asis, :restore, :discard].find { |x| opts[x] } || :asis
92
93 Redwood::start
94 index = Redwood::Index.new
95
96 restored_state =
97   if opts[:restore]
98     dump = {}
99     $stderr.puts "Loading state dump from #{opts[:restore]}..."
100     IO.foreach opts[:restore] do |l|
101       l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}"
102       mid, labels = $1, $2
103       dump[mid] = labels.split(" ").map { |x| x.intern }
104     end
105     $stderr.puts "Read #{dump.size} entries from dump file."
106     dump
107   else
108     {}
109   end
110
111 seen = {}
112 index.lock_or_die
113 begin
114   index.load
115
116   sources = ARGV.map do |uri|
117     index.source_for uri or Trollop::die "Unknown source: #{uri}. Did you add it with sup-add first?"
118   end
119   
120   sources = index.usual_sources if sources.empty?
121   sources = index.sources if opts[:all_sources]
122
123   unless target == :new
124     if opts[:start_at]
125       sources.each { |s| s.seek_to! opts[:start_at] }
126     else
127       sources.each { |s| s.reset! }
128     end
129   end
130   
131   sources.each do |source|
132     $stderr.puts "Scanning #{source}..."
133     num_added = num_updated = num_scanned = num_restored = 0
134     last_info_time = start_time = Time.now
135
136     Redwood::PollManager.add_messages_from source, :force_overwrite => true do |m, offset, entry|
137       num_scanned += 1
138       seen[m.id] = true
139
140       ## skip if we're operating only on changed messages, the message
141       ## is in the index, and it's unchanged from what the source is
142       ## reporting.
143       next if target == :changed && entry && entry[:source_id].to_i == source.id && entry[:source_info].to_i == offset
144
145       ## get the state currently in the index
146       index_state =
147         if entry
148           entry[:label].split(/\s+/).map { |x| x.intern }
149         else
150           nil
151         end
152
153       ## skip if we're operating on restored messages, and this one
154       ## ain't.
155       next if target == :restored && (!restored_state[m.id] || (index_state && restored_state[m.id].sort_by { |s| s.to_s } == index_state.sort_by { |s| s.to_s }))
156
157       ## m.labels is the default source labels. tweak these according
158       ## to default source state modification flags.
159       m.labels -= [:inbox] if opts[:archive]
160       m.labels -= [:unread] if opts[:read]
161       m.labels += opts[:extra_labels].split(/\s*,\s*/).map { |x| x.intern } if opts[:extra_labels]
162
163       ## assign message labels based on the operation we're performing
164       case op
165       when :asis
166         m.labels = index_state if index_state
167       when :restore
168         ## if the entry exists on disk
169         if restored_state[m.id]
170           m.labels = restored_state[m.id]
171           num_restored += 1
172         elsif index_state
173           m.labels = index_state
174         end
175       when :discard
176         ## nothin! use default source labels
177       end
178
179       if Time.now - last_info_time > 60
180         last_info_time = Time.now
181         elapsed = last_info_time - start_time
182         pctdone = source.respond_to?(:pct_done) ? source.pct_done : 100.0 * (source.cur_offset.to_f - source.start_offset).to_f / (source.end_offset - source.start_offset).to_f
183         remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
184         $stderr.puts "## #{num_scanned} (#{pctdone}%) read; #{elapsed.to_time_s} elapsed; #{remaining.to_time_s} remaining"
185       end
186
187       if index_state.nil?
188         puts "Adding message #{source}##{offset} with state {#{m.labels * ', '}}" if opts[:verbose]
189         num_added += 1
190       else
191         puts "Updating message #{source}##{offset}, source #{entry[:source_id]} => #{source.id}, offset #{entry[:source_info]} => #{offset}, state {#{index_state * ', '}} => {#{m.labels * ', '}}" if opts[:verbose]
192         num_updated += 1
193       end
194
195       opts[:dry_run] ? nil : m
196     end
197     $stderr.puts "Scanned #{num_scanned}, added #{num_added}, updated #{num_updated} messages from #{source}."
198     $stderr.puts "Restored state on #{num_restored} (#{100.0 * num_restored / num_scanned}%) messages." if num_restored > 0
199   end
200
201   ## delete any messages in the index that claim they're from one of
202   ## these sources, but that we didn't see.
203   ##
204   ## kinda crappy code here, because we delve directly into the Ferret
205   ## API.
206   ##
207   ## TODO: move this to Index, i suppose.
208
209
210   if target == :all || target == :changed
211     $stderr.puts "Deleting missing messages from the index..."
212     num_del, num_scanned = 0, 0
213     sources.each do |source|
214       raise "no source id for #{source}" unless source.id
215       q = "+source_id:#{source.id}"
216       q += " +source_info: >= #{opts[:start_at]}" if opts[:start_at]
217       index.index.search_each(q, :limit => :all) do |docid, score|
218         num_scanned += 1
219         mid = index.index[docid][:message_id]
220         unless seen[mid]
221           puts "Deleting #{mid}" if opts[:verbose]
222           index.index.delete docid unless opts[:dry_run]
223           num_del += 1
224         end
225       end
226     end
227     $stderr.puts "Deleted #{num_del} / #{num_scanned} messages"
228   end
229
230   index.save
231
232   if opts[:optimize]
233     $stderr.puts "Optimizing index..."
234     optt = time { index.index.optimize unless opts[:dry_run] }
235     $stderr.puts "Optimized index of size #{index.size} in #{optt}s."
236   end
237 rescue Redwood::FatalSourceError => e
238   $stderr.puts "Sorry, I couldn't communicate with a source: #{e.message}"
239 rescue Exception => e
240   File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace }
241   raise
242 ensure
243   Redwood::finish
244   index.unlock
245 end