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