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