]> git.cworth.org Git - sup/blob - bin/sup-sync
dump and restore functionality
[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.
47
48 Supported source URIs:
49   mbox://<path to mbox file>,      e.g. mbox:///var/spool/mail/me
50   maildir://<path to maildir dir>, e.g. maildir:///home/me/Maildir
51   mbox+ssh://<machine>/<path to mbox file>
52   imap://<machine>/[<folder>]
53   imaps://<machine>/[<folder>]
54
55 Options controlling WHICH messages sup-sync operates on:
56 EOS
57   opt :new, "Operate on new messages only. Don't scan over the entire source. (Default.)", :short => :none
58   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.)"
59   opt :restored, "Operate only on those messages included in a dump file as specified by --restore which have changed state."
60   opt :all, "Operate on all messages in the source, regardless of newness or changedness."
61   opt :start_at, "For --changed and --all, start at a particular offset.", :type => :int
62
63 text <<EOS
64
65 Options controlling HOW message state is altered:
66 EOS
67   opt :asis, "If the message is already in the index, preserve its state. Otherwise, use default source state. (Default.)", :short => :none
68   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
69   opt :discard, "Discard any message state in the index and use the default source state. Dangerous!", :short => :none
70   opt :archive, "When using the default source state, mark messages as archived.", :short => "-x"
71   opt :read, "When using the default source state, mark messages as read."
72   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
73
74 text <<EOS
75
76 Other options:
77 EOS
78   opt :verbose, "Print message ids as they're processed."
79   opt :optimize, "As the final operation, optimize the index."
80   opt :all_sources, "Scan over all sources.", :short => :none
81   opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n"
82   opt :version, "Show version information", :short => :none
83
84   conflicts :changed, :all, :new, :restored
85   conflicts :asis, :restore, :discard
86 end
87 Trollop::die :restored, "requires --restore" if opts[:restored] unless opts[:restore]
88 if opts[:start_at]
89   Trollop::die :start_at, "must be non-negative" if opts[:start_at] < 0
90   Trollop::die :start_at, "requires either --changed or --all" unless opts[:changed] || opts[:all]
91 end
92
93 target = [:new, :changed, :all, :restored].find { |x| opts[x] } || :new
94 op = [:asis, :restore, :discard].find { |x| opts[x] } || :asis
95
96 Redwood::start
97 index = Redwood::Index.new
98 index.load
99
100 restored_state =
101   if opts[:restore]
102     dump = {}
103     $stderr.puts "Loading state dump from #{opts[:restore]}..."
104     IO.foreach opts[:restore] do |l|
105       l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}"
106       mid, labels = $1, $2
107       dump[mid] = labels.split(" ").map { |x| x.intern }
108     end
109     $stderr.puts "Read #{dump.size} entries from dump file."
110     dump
111   else
112     {}
113   end
114
115 sources = ARGV.map do |uri|
116   uri = "mbox://#{uri}" unless uri =~ %r!://!
117   index.source_for uri or Trollop::die "Unknown source: #{uri}. Did you add it with sup-add first?"
118 end
119
120 sources = index.usual_sources if sources.empty?
121 sources = index.sources if opts[:all_sources]
122
123 unless target == :new
124   if opts[:start_at]
125     sources.each { |s| s.seek_to! opts[:start_at] }
126   else
127     sources.each { |s| s.reset! }
128   end
129 end
130
131 seen = {}
132 begin
133   sources.each do |source|
134     $stderr.puts "Scanning #{source}..."
135     num_added = num_updated = num_scanned = num_restored = 0
136     last_info_time = start_time = Time.now
137
138     Redwood::PollManager.add_messages_from source do |m, offset, entry|
139       num_scanned += 1
140       seen[m.id] = true
141
142       ## skip if we're operating only on changed messages, the message
143       ## is in the index, and it's unchanged from what the source is
144       ## reporting.
145       next if target == :changed && entry && entry[:source_id].to_i == source.id && entry[:source_info].to_i == offset
146
147       ## get the state currently in the index
148       index_state =
149         if entry
150           entry[:label].split(/\s+/).map { |x| x.intern }
151         else
152           nil
153         end
154
155       ## skip if we're operating on restored messages, and this one
156       ## ain't.
157       next if target == :restored && (!restored_state[m.id] || restored_state[m.id].sort_by { |s| s.to_s } == index_state.sort_by { |s| s.to_s })
158
159       ## m.labels is the default source labels. tweak these according
160       ## to default source state modification flags.
161       m.labels -= [:inbox] if opts[:archive]
162       m.labels -= [:unread] if opts[:read]
163       m.labels += opts[:extra_labels].split(/\s*,\s*/).map { |x| x.intern } if opts[:extra_labels]
164
165       ## assign message labels based on the operation we're performing
166       case op
167       when :asis
168         m.labels = index_state if index_state
169       when :restore
170         ## if the entry exists on disk
171         if restored_state[m.id]
172           m.labels = restored_state[m.id]
173           num_restored += 1
174         elsif index_state
175           m.labels = index_state
176         end
177       when :discard
178         ## nothin! use default source labels
179       end
180
181       if Time.now - last_info_time > 60
182         last_info_time = Time.now
183         elapsed = last_info_time - start_time
184         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
185         remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
186         $stderr.puts "## #{num_added + num_updated} (#{pctdone}% done) read; #{elapsed.to_time_s} elapsed; est. #{remaining.to_time_s} remaining (for this source)"
187       end
188
189       if index_state.nil?
190         puts "Adding message #{source}##{offset} with state {#{m.labels * ', '}}" if opts[:verbose]
191         num_added += 1
192       else
193         puts "Updating message #{source}##{offset}, source #{entry[:source_id]} => #{source.id}, offset #{entry[:source_info]} => #{offset}, state {#{index_state * ', '}} => {#{m.labels * ', '}}" if opts[:verbose]
194         num_updated += 1
195       end
196
197       opts[:dry_run] ? nil : m
198     end
199     $stderr.puts "Scanned #{num_scanned}, added #{num_added}, updated #{num_updated} messages from #{source}."
200     $stderr.puts "Restored state on #{num_restored} (#{100.0 * num_restored / num_scanned}%) messages." if num_restored > 0
201   end
202 ensure
203   $stderr.puts "Saving index and sources..."
204   index.save
205   Redwood::finish
206 end
207
208 ## delete any messages in the index that claim they're from one of
209 ## these sources, but that we didn't see.
210 ##
211 ## kinda crappy code here, because we delve directly into the Ferret
212 ## API.
213 ##
214 ## TODO: move this to Index, i suppose.
215 if target == :all || target == :changed
216   $stderr.puts "Deleting missing messages from the index..."
217   num_del, num_scanned = 0, 0
218   sources.each do |source|
219     raise "no source id for #{source}" unless source.id
220     q = "+source_id:#{source.id}"
221     q += " +source_info: >= #{opts[:start_at]}" if opts[:start_at]
222     index.index.search_each(q, :limit => :all) do |docid, score|
223       num_scanned += 1
224       mid = index.index[docid][:message_id]
225       unless seen[mid]
226         puts "Deleting #{mid}" if opts[:verbose]
227         index.index.delete docid unless opts[:dry_run]
228         num_del += 1
229       end
230     end
231   end
232   $stderr.puts "Deleted #{num_del} / #{num_scanned} messages"
233 end
234
235 if opts[:optimize]
236   $stderr.puts "Optimizing index..."
237   optt = time { index.index.optimize unless opts[:dry_run] }
238   $stderr.puts "Optimized index of size #{index.size} in #{optt}s."
239 end