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