]> git.cworth.org Git - sup/blob - bin/sup-sync
improve sup-sync output
[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.split(" ").map { |x| x.intern }
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].split(/\s+/).map { |x| x.intern } 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].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 index_state.nil?
188         puts "Adding message #{source}##{offset} from #{m.from} 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   if (target == :all || target == :changed) && !opts[:start_at]
209     $stderr.puts "Deleting missing messages from the index..."
210     num_del, num_scanned = 0, 0
211     sources.each do |source|
212       raise "no source id for #{source}" unless source.id
213       q = "+source_id:#{source.id}"
214       q += " +source_info: >= #{opts[:start_at]}" if opts[:start_at]
215       index.index.search_each(q, :limit => :all) do |docid, score|
216         num_scanned += 1
217         mid = index.index[docid][:message_id]
218         unless seen[mid]
219           puts "Deleting #{mid}" if opts[:verbose]
220           index.index.delete docid unless opts[:dry_run]
221           num_del += 1
222         end
223       end
224     end
225     $stderr.puts "Deleted #{num_del} / #{num_scanned} messages"
226   end
227
228   index.save
229
230   if opts[:optimize]
231     $stderr.puts "Optimizing index..."
232     optt = time { index.index.optimize unless opts[:dry_run] }
233     $stderr.puts "Optimized index of size #{index.size} in #{optt}s."
234   end
235 rescue Redwood::FatalSourceError => e
236   $stderr.puts "Sorry, I couldn't communicate with a source: #{e.message}"
237 rescue Exception => e
238   File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace }
239   raise
240 ensure
241   Redwood::finish
242   index.unlock
243 end