abbu 0.2.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +281 -8
  3. data/bin/abbu +230 -53
  4. data/docs/ABBU.md +369 -7
  5. data/docs/API_STABILITY.md +189 -0
  6. data/docs/CHANGELOG.md +179 -0
  7. data/docs/CONTRIBUTING.md +25 -3
  8. data/docs/FORMAT_COMPATIBILITY.md +97 -0
  9. data/docs/RELEASING.md +75 -0
  10. data/docs/TODO.md +73 -62
  11. data/lib/abbu/archive.rb +80 -6
  12. data/lib/abbu/contact.rb +6 -3
  13. data/lib/abbu/diagnostic.rb +27 -0
  14. data/lib/abbu/exporters/csv_exporter.rb +2 -2
  15. data/lib/abbu/exporters/json_exporter.rb +7 -1
  16. data/lib/abbu/exporters/vcard_document.rb +54 -0
  17. data/lib/abbu/exporters/vcard_encoding.rb +54 -0
  18. data/lib/abbu/exporters/vcard_exporter.rb +44 -27
  19. data/lib/abbu/group.rb +28 -0
  20. data/lib/abbu/group_catalog.rb +32 -0
  21. data/lib/abbu/image_extractor.rb +134 -0
  22. data/lib/abbu/live_store.rb +117 -0
  23. data/lib/abbu/parse_error.rb +13 -0
  24. data/lib/abbu/parsers/plist_parser.rb +41 -11
  25. data/lib/abbu/parsers/sqlite_parser.rb +139 -65
  26. data/lib/abbu/query.rb +92 -0
  27. data/lib/abbu/schema_inspector.rb +133 -0
  28. data/lib/abbu/source.rb +53 -0
  29. data/lib/abbu/source_catalog.rb +32 -0
  30. data/lib/abbu/timestamp_range.rb +40 -0
  31. data/lib/abbu/utils/contact_identity.rb +97 -0
  32. data/lib/abbu/utils/deduplicator.rb +130 -2
  33. data/lib/abbu/utils/image_resolver.rb +59 -0
  34. data/lib/abbu/utils/label_normalizer.rb +15 -0
  35. data/lib/abbu/utils/source_descriptor.rb +35 -0
  36. data/lib/abbu/version.rb +1 -1
  37. data/lib/abbu.rb +15 -2
  38. data/sig/group.rbs +36 -0
  39. data/sig/query.rbs +21 -0
  40. data/sig/source.rbs +25 -0
  41. data/sig/vcard_exporter.rbs +10 -0
  42. data/tasks/abbu.rake +1 -1
  43. metadata +33 -15
  44. data/bin/cleanse +0 -7
  45. data/bin/console +0 -10
  46. data/bin/dev +0 -7
  47. data/bin/lint +0 -7
  48. data/bin/outdated +0 -7
  49. data/bin/test +0 -7
data/bin/abbu CHANGED
@@ -4,90 +4,267 @@
4
4
 
5
5
  $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
6
 
7
+ require 'json'
7
8
  require 'optparse'
8
9
  require 'abbu'
9
10
 
10
11
  options = {
11
12
  format: nil,
12
13
  output: nil,
14
+ extract_images: nil,
13
15
  dedupe: false,
14
- stats: false
16
+ email: nil,
17
+ json: false,
18
+ live: nil,
19
+ live_path: nil,
20
+ phone: nil,
21
+ search: nil,
22
+ schema: false,
23
+ stats: false,
24
+ strict: false
15
25
  }
16
26
 
17
- parser = OptionParser.new do |opts|
18
- opts.banner = 'Usage: abbu <file.abbu> [options]'
27
+ parser = OptionParser.new
28
+ parser.banner = 'Usage: abbu <file.abbu> [options] | abbu --live [options] | abbu --live-path PATH [options]'
19
29
 
20
- opts.on('-f', '--format FORMAT', %w[csv json vcard], 'Export format (csv, json, vcard)') do |f|
21
- options[:format] = f
22
- end
30
+ parser.on('-f', '--format FORMAT', %w[csv json vcard], 'Export format (csv, json, vcard)') do |f|
31
+ options[:format] = f
32
+ end
23
33
 
24
- opts.on('-o', '--output FILE', 'Output file (default: stdout)') do |o|
25
- options[:output] = o
26
- end
34
+ parser.on('-o', '--output FILE', 'Output file (default: stdout)') do |o|
35
+ options[:output] = o
36
+ end
27
37
 
28
- opts.on('--stats', 'Print contact statistics') do
29
- options[:stats] = true
30
- end
38
+ parser.on('--extract-images DIR', 'Copy contact images to DIR') do |dir|
39
+ options[:extract_images] = dir
40
+ end
31
41
 
32
- opts.on('--dedupe', 'Find and print duplicate contacts') do
33
- options[:dedupe] = true
34
- end
42
+ parser.on('--stats', 'Print contact statistics') do
43
+ options[:stats] = true
44
+ end
35
45
 
36
- opts.on('-v', '--version', 'Print version') do
37
- puts "abbu #{Abbu::VERSION}"
38
- exit
39
- end
46
+ parser.on('--dedupe', 'Find and print duplicate contacts') do
47
+ options[:dedupe] = true
48
+ end
40
49
 
41
- opts.on('-h', '--help', 'Print this help') do
42
- puts opts
43
- exit
50
+ parser.on('--search TERM', 'Find contacts by partial name or email') do |term|
51
+ options[:search] = term
52
+ end
53
+
54
+ parser.on('--strict', 'Raise on unsupported or corrupt optional data') do
55
+ options[:strict] = true
56
+ end
57
+
58
+ parser.on('--schema', 'Print SQLite schema diagnostics as JSON') do
59
+ options[:schema] = true
60
+ end
61
+
62
+ parser.on('--sources', 'List observed source containers as JSON (archive or live)') do
63
+ options[:sources] = true
64
+ end
65
+
66
+ parser.on('--groups', 'List observed groups with memberships as JSON (archive or live)') do
67
+ options[:groups] = true
68
+ end
69
+
70
+ parser.on('--email ADDRESS', 'Find contacts by exact normalized email') do |address|
71
+ options[:email] = address
72
+ end
73
+
74
+ parser.on('--phone NUMBER', 'Find contacts by exact normalized phone number') do |number|
75
+ options[:phone] = number
76
+ end
77
+
78
+ parser.on('--json', 'Emit search results as JSON (optional for --sources or --groups)') do
79
+ options[:json] = true
80
+ end
81
+
82
+ timestamp_bounds = %w[since before]
83
+ %w[created modified].each do |kind|
84
+ timestamp_bounds.each do |bound|
85
+ parser.on("--#{kind}-#{bound} TIMESTAMP", "Filter #{kind} timestamp (ISO 8601 with timezone)") do |value|
86
+ options[:date_filters] ||= {}
87
+ options[:date_filters][:"#{kind}_at"] ||= {}
88
+ options[:date_filters][:"#{kind}_at"][bound.to_sym] = value
89
+ end
44
90
  end
45
91
  end
46
92
 
93
+ parser.on('-v', '--version', 'Print version') do
94
+ puts "abbu #{Abbu::VERSION}"
95
+ exit
96
+ end
97
+
98
+ parser.on('-h', '--help', 'Print this help') do
99
+ puts parser
100
+ exit
101
+ end
102
+
103
+ parser.on('--live', 'Auto-discover the current macOS Contacts store') do
104
+ options[:live] = true
105
+ end
106
+
107
+ parser.on('--live-path PATH', 'Read the specified live AddressBook directory') do |path|
108
+ options[:live_path] = path
109
+ end
110
+
47
111
  parser.parse!
48
112
 
113
+ live_mode = options[:live] || !options[:live_path].nil?
114
+ live_path = options[:live_path]
115
+
116
+ if options[:live] && live_path
117
+ warn 'Use only one of --live or --live-path PATH.'
118
+ exit 1
119
+ end
120
+
121
+ if live_mode && ARGV.any?
122
+ warn 'Unexpected positional arguments in live mode; use --live-path PATH for an explicit store.'
123
+ exit 1
124
+ end
125
+
49
126
  file = ARGV.shift
50
127
 
51
- if file.nil?
128
+ if file.nil? && !live_mode
52
129
  puts parser
53
130
  exit 1
54
131
  end
55
132
 
56
- archive = Abbu.open(file)
133
+ archive = nil
57
134
 
58
- if options[:stats]
59
- contacts = archive.contacts
60
- puts "Total contacts : #{contacts.count}"
61
- puts "With email : #{contacts.count { |c| c.emails.any? }}"
62
- puts "With phone : #{contacts.count { |c| c.phones.any? }}"
63
- exit
135
+ at_exit do
136
+ next if archive.nil? || archive.diagnostics.empty?
137
+
138
+ warn "Diagnostics: #{archive.diagnostics.count}"
139
+ archive.diagnostics.each do |diagnostic|
140
+ warn "- #{diagnostic.category}: #{diagnostic.message} [#{diagnostic.source}]"
141
+ end
64
142
  end
65
143
 
66
- if options[:dedupe]
67
- require 'abbu/utils/deduplicator'
68
- dupes = Abbu::Utils::Deduplicator.new(archive.contacts).duplicates
69
- if dupes.empty?
70
- puts 'No duplicates found.'
71
- else
72
- dupes.each do |email, contacts|
73
- puts "Duplicate: #{email}"
74
- contacts.each { |c| puts " - #{c.full_name}" }
144
+ begin
145
+ listing = %i[sources groups].select { |key| options[key] }
146
+ if listing.any? && (listing.length > 1 || options.values_at(:extract_images, :schema, :search, :email, :phone,
147
+ :date_filters, :stats, :dedupe, :format, :output).any?)
148
+ raise ArgumentError,
149
+ "--#{listing.first} cannot be combined with other operations; only --json and --strict are supported"
150
+ end
151
+
152
+ if live_mode && listing.empty? &&
153
+ options.values_at(:extract_images, :schema, :search, :email, :phone, :json, :date_filters).any?
154
+ warn '--live supports --sources, --groups, --stats, --dedupe, --format, and --strict; ' \
155
+ 'archive-only options are not supported.'
156
+ exit 1
157
+ end
158
+ if options[:date_filters]
159
+ if options.values_at(:extract_images, :schema, :stats, :dedupe, :format, :output).any?
160
+ raise ArgumentError, 'timestamp filters cannot be combined with export, stats, schema, dedupe, or extraction'
75
161
  end
162
+
163
+ options[:date_filters].each { |field, bounds| Abbu::TimestampRange.new(field, **bounds) }
76
164
  end
77
- exit
78
- end
165
+ archive = live_mode ? Abbu.open_live(live_path, strict: options[:strict]) : Abbu.open(file, strict: options[:strict])
79
166
 
80
- case options[:format]
81
- when 'csv'
82
- exporter = Abbu::Exporters::CsvExporter.new(archive.contacts)
83
- options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
84
- when 'json'
85
- exporter = Abbu::Exporters::JsonExporter.new(archive.contacts)
86
- options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
87
- when 'vcard'
88
- exporter = Abbu::Exporters::VcardExporter.new(archive.contacts)
89
- options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
90
- else
91
- puts parser
167
+ if listing.any?
168
+ puts JSON.pretty_generate(archive.public_send(listing.first).map(&:to_h))
169
+ exit 0
170
+ end
171
+
172
+ if options[:extract_images]
173
+ result = archive.extract_images(options[:extract_images])
174
+ puts "Extracted #{result.files.count} image(s) to #{File.expand_path(options[:extract_images])}"
175
+ result.diagnostics.each do |diagnostic|
176
+ warn "Image #{diagnostic[:code]}: #{diagnostic[:contact_name]} (#{diagnostic[:image_uri]})"
177
+ end
178
+ exit unless options[:format] || options[:stats] || options[:dedupe]
179
+ end
180
+
181
+ if options[:schema]
182
+ puts JSON.pretty_generate(archive.schema_report)
183
+ exit
184
+ end
185
+
186
+ search_options = options.values_at(:search, :email, :phone).compact
187
+ if search_options.length > 1
188
+ warn 'Use only one of --search, --email, or --phone.'
189
+ exit 1
190
+ end
191
+
192
+ if options[:json] && search_options.empty? && !options[:date_filters]
193
+ warn '--json requires --search, --email, or --phone (or a timestamp filter).'
194
+ exit 1
195
+ end
196
+
197
+ if search_options.any? || options[:date_filters]
198
+ results = if options[:search]
199
+ archive.search(options[:search])
200
+ elsif options[:email]
201
+ archive.find_by_email(options[:email])
202
+ elsif options[:phone]
203
+ archive.find_by_phone(options[:phone])
204
+ else
205
+ archive.query
206
+ end
207
+
208
+ options.fetch(:date_filters, {}).each do |field, bounds|
209
+ results = results.date_range(field, **bounds)
210
+ end
211
+
212
+ if options[:json]
213
+ Abbu::Exporters::JsonExporter.new(results).to_stdout
214
+ else
215
+ results.each do |contact|
216
+ fields = [
217
+ contact.full_name,
218
+ contact.emails.filter_map { |email| email[:address] }.join(','),
219
+ contact.phones.filter_map { |phone| phone[:number] }.join(','),
220
+ contact.source&.fetch(:relative_path, nil)
221
+ ]
222
+ puts fields.map { |field| field.to_s.gsub(/[\t\r\n]/, ' ') }.join("\t")
223
+ end
224
+ end
225
+ exit(results.any? ? 0 : 1)
226
+ end
227
+
228
+ if options[:stats]
229
+ contacts = archive.contacts
230
+ puts "Total contacts : #{contacts.count}"
231
+ puts "With email : #{contacts.count { |c| c.emails.any? }}"
232
+ puts "With phone : #{contacts.count { |c| c.phones.any? }}"
233
+ exit
234
+ end
235
+
236
+ if options[:dedupe]
237
+ require 'abbu/utils/deduplicator'
238
+ dupes = Abbu::Utils::Deduplicator.new(archive.contacts).duplicates
239
+ if dupes.empty?
240
+ puts 'No duplicates found.'
241
+ else
242
+ dupes.each do |email, contacts|
243
+ puts "Duplicate: #{email}"
244
+ contacts.each { |c| puts " - #{c.full_name}" }
245
+ end
246
+ end
247
+ exit
248
+ end
249
+
250
+ case options[:format]
251
+ when 'csv'
252
+ exporter = Abbu::Exporters::CsvExporter.new(archive.contacts)
253
+ options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
254
+ when 'json'
255
+ exporter = Abbu::Exporters::JsonExporter.new(archive.contacts)
256
+ options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
257
+ when 'vcard'
258
+ exporter = Abbu::Exporters::VcardExporter.new(archive.contacts)
259
+ options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
260
+ else
261
+ puts parser
262
+ exit 1
263
+ end
264
+ rescue Abbu::LiveStore::Error => e
265
+ warn "abbu: #{e.message}"
92
266
  exit 1
267
+ rescue Abbu::ParseError, ArgumentError => e
268
+ warn "abbu: #{e.message}"
269
+ exit 2
93
270
  end