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.
- checksums.yaml +4 -4
- data/README.md +281 -8
- data/bin/abbu +230 -53
- data/docs/ABBU.md +369 -7
- data/docs/API_STABILITY.md +189 -0
- data/docs/CHANGELOG.md +179 -0
- data/docs/CONTRIBUTING.md +25 -3
- data/docs/FORMAT_COMPATIBILITY.md +97 -0
- data/docs/RELEASING.md +75 -0
- data/docs/TODO.md +73 -62
- data/lib/abbu/archive.rb +80 -6
- data/lib/abbu/contact.rb +6 -3
- data/lib/abbu/diagnostic.rb +27 -0
- data/lib/abbu/exporters/csv_exporter.rb +2 -2
- data/lib/abbu/exporters/json_exporter.rb +7 -1
- data/lib/abbu/exporters/vcard_document.rb +54 -0
- data/lib/abbu/exporters/vcard_encoding.rb +54 -0
- data/lib/abbu/exporters/vcard_exporter.rb +44 -27
- data/lib/abbu/group.rb +28 -0
- data/lib/abbu/group_catalog.rb +32 -0
- data/lib/abbu/image_extractor.rb +134 -0
- data/lib/abbu/live_store.rb +117 -0
- data/lib/abbu/parse_error.rb +13 -0
- data/lib/abbu/parsers/plist_parser.rb +41 -11
- data/lib/abbu/parsers/sqlite_parser.rb +139 -65
- data/lib/abbu/query.rb +92 -0
- data/lib/abbu/schema_inspector.rb +133 -0
- data/lib/abbu/source.rb +53 -0
- data/lib/abbu/source_catalog.rb +32 -0
- data/lib/abbu/timestamp_range.rb +40 -0
- data/lib/abbu/utils/contact_identity.rb +97 -0
- data/lib/abbu/utils/deduplicator.rb +130 -2
- data/lib/abbu/utils/image_resolver.rb +59 -0
- data/lib/abbu/utils/label_normalizer.rb +15 -0
- data/lib/abbu/utils/source_descriptor.rb +35 -0
- data/lib/abbu/version.rb +1 -1
- data/lib/abbu.rb +15 -2
- data/sig/group.rbs +36 -0
- data/sig/query.rbs +21 -0
- data/sig/source.rbs +25 -0
- data/sig/vcard_exporter.rbs +10 -0
- data/tasks/abbu.rake +1 -1
- metadata +33 -15
- data/bin/cleanse +0 -7
- data/bin/console +0 -10
- data/bin/dev +0 -7
- data/bin/lint +0 -7
- data/bin/outdated +0 -7
- 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
|
-
|
|
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
|
|
18
|
-
|
|
27
|
+
parser = OptionParser.new
|
|
28
|
+
parser.banner = 'Usage: abbu <file.abbu> [options] | abbu --live [options] | abbu --live-path PATH [options]'
|
|
19
29
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
parser.on('-o', '--output FILE', 'Output file (default: stdout)') do |o|
|
|
35
|
+
options[:output] = o
|
|
36
|
+
end
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
parser.on('--extract-images DIR', 'Copy contact images to DIR') do |dir|
|
|
39
|
+
options[:extract_images] = dir
|
|
40
|
+
end
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
parser.on('--stats', 'Print contact statistics') do
|
|
43
|
+
options[:stats] = true
|
|
44
|
+
end
|
|
35
45
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
46
|
+
parser.on('--dedupe', 'Find and print duplicate contacts') do
|
|
47
|
+
options[:dedupe] = true
|
|
48
|
+
end
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
133
|
+
archive = nil
|
|
57
134
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
end
|
|
165
|
+
archive = live_mode ? Abbu.open_live(live_path, strict: options[:strict]) : Abbu.open(file, strict: options[:strict])
|
|
79
166
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|