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