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