9 def to_s; sprintf '%.2f', self; end
15 sprintf "%d:%02d:%02d", i / 3600, (i / 60) % 60, i % 60
25 opts = Trollop::options do
26 version "sup-sync (sup #{Redwood::VERSION})"
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
32 "Message state" means read/unread, archived/inbox, starred/unstarred,
33 and all user-defined labels on each message.
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.
43 sup-sync [options] <source>*
45 where <source>* is zero or more source URIs. If no sources are given,
46 sync from all usual sources.
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>]
55 Options controlling WHICH messages sup-sync operates on:
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
65 Options controlling HOW message state is altered:
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
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
84 conflicts :changed, :all, :new, :restored
85 conflicts :asis, :restore, :discard
87 Trollop::die :restored, "requires --restore" if opts[:restored] unless opts[:restore]
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]
93 target = [:new, :changed, :all, :restored].find { |x| opts[x] } || :new
94 op = [:asis, :restore, :discard].find { |x| opts[x] } || :asis
97 index = Redwood::Index.new
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}"
107 dump[mid] = labels.split(" ").map { |x| x.intern }
109 $stderr.puts "Read #{dump.size} entries from dump file."
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?"
120 sources = index.usual_sources if sources.empty?
121 sources = index.sources if opts[:all_sources]
123 unless target == :new
125 sources.each { |s| s.seek_to! opts[:start_at] }
127 sources.each { |s| s.reset! }
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
138 Redwood::PollManager.add_messages_from source do |m, offset, entry|
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
145 next if target == :changed && entry && entry[:source_id].to_i == source.id && entry[:source_info].to_i == offset
147 ## get the state currently in the index
150 entry[:label].split(/\s+/).map { |x| x.intern }
155 ## skip if we're operating on restored messages, and this one
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 })
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]
165 ## assign message labels based on the operation we're performing
168 m.labels = index_state if index_state
170 ## if the entry exists on disk
171 if restored_state[m.id]
172 m.labels = restored_state[m.id]
175 m.labels = index_state
178 ## nothin! use default source labels
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)"
190 puts "Adding message #{source}##{offset} with state {#{m.labels * ', '}}" if opts[:verbose]
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]
197 opts[:dry_run] ? nil : m
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
203 $stderr.puts "Saving index and sources..."
208 ## delete any messages in the index that claim they're from one of
209 ## these sources, but that we didn't see.
211 ## kinda crappy code here, because we delve directly into the Ferret
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|
224 mid = index.index[docid][:message_id]
226 puts "Deleting #{mid}" if opts[:verbose]
227 index.index.delete docid unless opts[:dry_run]
232 $stderr.puts "Deleted #{num_del} / #{num_scanned} messages"
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."