]> git.cworth.org Git - sup/blob - bin/sup-sync
Merge branch 'labels-before-subj'
[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   ##
212   ## kinda crappy code here, because we delve directly into the Ferret
213   ## API.
214   ##
215   ## TODO: move this to Index, i suppose.
216   if (target == :all || target == :changed) && !opts[:start_at]
217     $stderr.puts "Deleting missing messages from the index..."
218     num_del, num_scanned = 0, 0
219     sources.each do |source|
220       raise "no source id for #{source}" unless source.id
221       q = "+source_id:#{source.id}"
222       q += " +source_info: >= #{opts[:start_at]}" if opts[:start_at]
223       index.index.search_each(q, :limit => :all) do |docid, score|
224         num_scanned += 1
225         mid = index.index[docid][:message_id]
226         unless seen[mid]
227           puts "Deleting #{mid}" if opts[:verbose]
228           index.index.delete docid unless opts[:dry_run]
229           num_del += 1
230         end
231       end
232     end
233     $stderr.puts "Deleted #{num_del} / #{num_scanned} messages"
234   end
235
236   index.save
237
238   if opts[:optimize]
239     $stderr.puts "Optimizing index..."
240     optt = time { index.index.optimize unless opts[:dry_run] }
241     $stderr.puts "Optimized index of size #{index.size} in #{optt}s."
242   end
243 rescue Redwood::FatalSourceError => e
244   $stderr.puts "Sorry, I couldn't communicate with a source: #{e.message}"
245 rescue Exception => e
246   File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace }
247   raise
248 ensure
249   Redwood::finish
250   index.unlock
251 end