abbu 0.1.2 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a2a430e28fa7792cb9f22a2e30f808a5d5752b0144cec428e64a88bde5740f9
4
- data.tar.gz: 0c1e039e876c8a82b6a8ec52a046afe421f51d0236ceed9eab67f99a09261695
3
+ metadata.gz: e93fb2c992d75b467a430083bd474aef632c6f759ae858653e9b3ed44e261cc9
4
+ data.tar.gz: f346d67e1789116120ec804881b0f9660d6853c55102557e084d8be78184a0cf
5
5
  SHA512:
6
- metadata.gz: 5978d9208cac2c0a6bff53184b1dcd1fa016c204a34aa4a7912f9a814d616d4bc7a482d868bf3dc47a4fc2a1b6671bbf7c8092432c28eae2fb1ec3f628248822
7
- data.tar.gz: bd915874f2b2987640a2942beaec9fee6e034411c81fbfa0df127136e60353c5cc0327d638a84a2e959600209d2e9ba72143768b39568484ce4bcbb498e486f4
6
+ metadata.gz: ae3d1565ac2203067a1e2da4cb304eba8b112bfbd94b78f65c0a39a58f6a007835da1929fcfbc58cea13fdde43ac23eae1338b77fcf4466b31a2a2f40f31a3a6
7
+ data.tar.gz: 6a904be665a31a2254c139aced555dfcfd92af6612bec463ad776e5e99f939210954625c5141b17ee095518b0bd85d29f9dbfa795ec0f0ac34a9b8dd8231c6b0
data/README.md CHANGED
@@ -2,16 +2,26 @@
2
2
 
3
3
  # abbu
4
4
 
5
- Read and process Apple Contacts `.abbu` archives in Ruby.
5
+ Read-only Apple Contacts toolkit for `.abbu` archives and opt-in live macOS stores.
6
+ Version 0.4.0 adds querying, diagnostics, identity evidence, and safe image extraction
7
+ alongside CSV, JSON, and vCard export. The public API remains pre-1.0.
6
8
 
7
9
  ## Features
8
10
 
9
11
  - Parse ABBU (Apple Contacts export) bundles
12
+ - Opt-in, read-only access to a local macOS Contacts store
10
13
  - SQLite-backed contact extraction (modern macOS)
11
- - Legacy plist format detection (stub, v0.2 roadmap)
12
- - Export to CSV, JSON, vCard
14
+ - Legacy plist `.abcdp` parsing (older macOS)
15
+ - Evidence-backed Apple Contacts fields: names, nicknames, prefix/suffix, job title, department, phonetics, pronouns, and more
16
+ - Rich relational data: addresses, URLs, notes, related names, social profiles
17
+ - Export to CSV, JSON, vCard 3.0
13
18
  - CLI + Ruby API
14
- - Duplicate detection
19
+ - Source provenance and creation/modification timestamps
20
+ - Lossless raw labels alongside human-friendly normalization
21
+ - Schema introspection, tolerant diagnostics, and strict mode
22
+ - Chainable archive queries, exact identifier lookup, and TSV/JSON CLI search
23
+ - Provenance-aware duplicate suggestions without automatic merging
24
+ - Safe photo extraction with content detection and no destination overwrites
15
25
 
16
26
  ## Installation
17
27
 
@@ -34,12 +44,66 @@ require "abbu"
34
44
 
35
45
  archive = Abbu.open("Contacts.abbu")
36
46
  contacts = archive.contacts
47
+ schema = archive.schema_report # Evidence-only SQLite schema diagnostics
37
48
 
38
- contacts.first.full_name # => "Stan Carver"
39
- contacts.first.emails # => ["stan@example.com"]
40
- contacts.first.phones # => ["555-1234"]
49
+ contacts.first.full_name # => "Honorable Stan \"Stretch\" Carver II"
50
+ contacts.first.emails # => [{ address: "stan@example.com", label: "Work", raw_label: "_$!<Work>!$_" }]
51
+ contacts.first.phones # => [{ number: "555-1234", label: "Mobile", raw_label: "Mobile" }]
52
+ contacts.first.job_title # => "Engineer"
53
+
54
+ # Copy resolved photos using safe, content-derived filenames.
55
+ result = archive.extract_images("exported-photos")
56
+ result.files # copied-file metadata, including source_path and media_type
57
+ result.diagnostics # image errors or destination_exists; may contain contact identifiers
58
+
59
+ # Recover safe records and inspect non-fatal data loss.
60
+ archive.diagnostics.each { |diagnostic| warn diagnostic.to_h }
61
+
62
+ # Or fail on the first corrupt/unsupported optional input.
63
+ strict_contacts = Abbu.open("Contacts.abbu", strict: true).contacts
64
+ ```
65
+
66
+ Live-store access is a separate, explicit API and never changes `Abbu.open` archive
67
+ validation:
68
+
69
+ ```ruby
70
+ # Auto-discover the current macOS user's AddressBook directory
71
+ live_contacts = Abbu.open_live.contacts
72
+
73
+ # Or supply a directory for automation and platform-independent testing
74
+ live_contacts = Abbu.open_live("/path/to/AddressBook").contacts
75
+ ```
76
+
77
+ Live databases are opened with SQLite's read-only mode. The process may require
78
+ Full Disk Access under **System Settings → Privacy & Security → Full Disk Access**.
79
+ Live inputs expose parser `diagnostics` and accept `strict: true` (CLI `--strict`).
80
+ The live CLI supports stats, deduplication, and exports; archive-only search, schema,
81
+ and image-extraction options are rejected explicitly.
82
+
83
+ Live access does not write Contacts databases. WAL/concurrent-writer consistency
84
+ remains unverified ([#28](https://github.com/scarver2/abbu/issues/28)).
85
+
86
+ Labeled values expose a normalized `label` for display and retain the source
87
+ value in `raw_label`. For example, `_$!<Mobile>!$_` becomes `Mobile` while the
88
+ original wrapper remains available in `raw_label`.
89
+
90
+ ### Search and identifier lookup
91
+
92
+ ```ruby
93
+ # Exact lookup normalizes email case/whitespace and phone punctuation.
94
+ archive.find_by_email("STAN@EXAMPLE.COM").each { |contact| puts contact.full_name }
95
+ archive.find_by_phone("(555) 123-4567").each { |contact| puts contact.full_name }
96
+
97
+ # Name and email search is case-insensitive and can be chained with `where`.
98
+ archive.where(company: "Acme Corp").search("stan").each do |contact|
99
+ puts [contact.full_name, contact.source[:relative_path]].join("\t")
100
+ end
41
101
  ```
42
102
 
103
+ Lookup methods return every match as an `Abbu::Query`; they never silently pick
104
+ one contact when the same identifier appears in multiple sources. Returned
105
+ contacts retain their parser-provided source provenance.
106
+
43
107
  ### Export
44
108
 
45
109
  ```ruby
@@ -63,6 +127,23 @@ dupes.each do |email, contacts|
63
127
  end
64
128
  ```
65
129
 
130
+ For provenance-aware suggestions, use `#matches`. Results preserve both contacts,
131
+ their source records, raw and normalized evidence, confidence, and ambiguity:
132
+
133
+ ```ruby
134
+ matches = Abbu::Utils::Deduplicator.new(archive.contacts).matches
135
+ matches.each do |match|
136
+ puts "#{match.confidence}: #{match.left.full_name} / #{match.right.full_name}"
137
+ pp match.sources
138
+ pp match.evidence
139
+ end
140
+
141
+ # Matching never mutates or collapses contacts. Merging requires a caller policy:
142
+ merged = matches.first.merge(policy: ->(left, right, evidence:) {
143
+ MyContactMerge.call(left, right, evidence: evidence)
144
+ })
145
+ ```
146
+
66
147
  ## CLI
67
148
 
68
149
  ```bash
@@ -75,13 +156,41 @@ abbu Contacts.abbu -f json | jq .
75
156
  # vCard export
76
157
  abbu Contacts.abbu -f vcard -o contacts.vcf
77
158
 
159
+ # Copy contact photos to a selected directory
160
+ abbu Contacts.abbu --extract-images exported-photos
161
+
78
162
  # Stats
79
163
  abbu Contacts.abbu --stats
80
164
 
81
165
  # Find duplicates
82
166
  abbu Contacts.abbu --dedupe
167
+
168
+ # Read the current macOS user's live Contacts store
169
+ abbu --live --stats
170
+
171
+ # Read a caller-supplied AddressBook directory
172
+ abbu --live-path /path/to/AddressBook -f json
173
+
174
+ # Fail on the first corrupt or unsupported optional record/table.
175
+ abbu Contacts.abbu --stats --strict
176
+
177
+ # Inspect each SQLite schema without inferring undocumented semantics
178
+ abbu Contacts.abbu --schema
179
+
180
+ # Tab-separated search output: name, emails, phones, source-relative path
181
+ abbu Contacts.abbu --search stan
182
+ abbu Contacts.abbu --email stan@example.com
183
+ abbu Contacts.abbu --phone '(555) 123-4567'
184
+
185
+ # Stable structured search output using the regular contact JSON schema
186
+ abbu Contacts.abbu --search stan --json | jq .
83
187
  ```
84
188
 
189
+ CLI search defaults to tab-separated output and exits successfully when at least
190
+ one contact matches. A search with no matches exits with status 1; TSV mode emits
191
+ no output, while `--json` emits a valid empty array. This makes both modes
192
+ suitable for shell conditionals, pipelines, and agent integrations.
193
+
85
194
  ## Rake Tasks
86
195
 
87
196
  ```ruby
@@ -102,25 +211,26 @@ SQLite table schema, and format history.
102
211
 
103
212
  ## Roadmap
104
213
 
105
- | Version | Features |
106
- |---------|---------------------------------------------|
107
- | v0.1.0 | SQLite parsing, CSV/JSON/vCard export, CLI |
108
- | v0.2.0 | Plist parser, image extraction |
109
- | v0.3.0 | Fuzzy dedupe (Levenshtein), merge engine |
110
- | v1.0.0 | Sync adapters (Printavo, HubSpot, CRM) |
214
+ See [`docs/TODO.md`](docs/TODO.md) for the full release schedule and feature checklist.
215
+
216
+ ## Ruby Compatibility
217
+
218
+ `abbu` supports Ruby 3.3 and newer. CI exercises Ruby 3.3, 3.4, and 4.0;
219
+ Ruby 3.3 is the compatibility floor and designated lint/tooling job.
111
220
 
112
221
  ## Development
113
222
 
114
223
  ```bash
115
224
  mise exec -- bundle install
116
- mise exec -- bundle exec guard # DX loop: auto-test + auto-lint
117
- mise exec -- bundle exec rspec # run specs
118
- mise exec -- bundle exec rubocop # lint
225
+ bin/dev # Guard feedback loop
226
+ bin/spec # RSpec with the 100% coverage gate
227
+ bin/lint # RuboCop
228
+ bin/package # build and verify the gem in isolation
119
229
  ```
120
230
 
121
231
  ## Contributing
122
232
 
123
- See [CONTRIBUTING.md](CONTRIBUTING.md).
233
+ See [CONTRIBUTING.md](docs/CONTRIBUTING.md).
124
234
 
125
235
  ## License
126
236
 
data/bin/abbu CHANGED
@@ -4,90 +4,221 @@
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
44
- end
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('--email ADDRESS', 'Find contacts by exact normalized email') do |address|
63
+ options[:email] = address
64
+ end
65
+
66
+ parser.on('--phone NUMBER', 'Find contacts by exact normalized phone number') do |number|
67
+ options[:phone] = number
68
+ end
69
+
70
+ parser.on('--json', 'Emit search results as structured JSON instead of TSV') do
71
+ options[:json] = true
72
+ end
73
+
74
+ parser.on('-v', '--version', 'Print version') do
75
+ puts "abbu #{Abbu::VERSION}"
76
+ exit
77
+ end
78
+
79
+ parser.on('-h', '--help', 'Print this help') do
80
+ puts parser
81
+ exit
82
+ end
83
+
84
+ parser.on('--live', 'Auto-discover the current macOS Contacts store') do
85
+ options[:live] = true
86
+ end
87
+
88
+ parser.on('--live-path PATH', 'Read the specified live AddressBook directory') do |path|
89
+ options[:live_path] = path
45
90
  end
46
91
 
47
92
  parser.parse!
48
93
 
94
+ live_mode = options[:live] || !options[:live_path].nil?
95
+ live_path = options[:live_path]
96
+
97
+ if options[:live] && live_path
98
+ warn 'Use only one of --live or --live-path PATH.'
99
+ exit 1
100
+ end
101
+
102
+ if live_mode && ARGV.any?
103
+ warn 'Unexpected positional arguments in live mode; use --live-path PATH for an explicit store.'
104
+ exit 1
105
+ end
106
+
49
107
  file = ARGV.shift
50
108
 
51
- if file.nil?
109
+ if file.nil? && !live_mode
52
110
  puts parser
53
111
  exit 1
54
112
  end
55
113
 
56
- archive = Abbu.open(file)
114
+ archive = nil
57
115
 
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
116
+ at_exit do
117
+ next if archive.nil? || archive.diagnostics.empty?
118
+
119
+ warn "Diagnostics: #{archive.diagnostics.count}"
120
+ archive.diagnostics.each do |diagnostic|
121
+ warn "- #{diagnostic.category}: #{diagnostic.message} [#{diagnostic.source}]"
122
+ end
64
123
  end
65
124
 
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}" }
125
+ begin
126
+ if live_mode && options.values_at(:extract_images, :schema, :search, :email, :phone, :json).any?
127
+ warn '--live supports --stats, --dedupe, --format, and --strict; archive-only options are not supported.'
128
+ exit 1
129
+ end
130
+ archive = live_mode ? Abbu.open_live(live_path, strict: options[:strict]) : Abbu.open(file, strict: options[:strict])
131
+
132
+ if options[:extract_images]
133
+ result = archive.extract_images(options[:extract_images])
134
+ puts "Extracted #{result.files.count} image(s) to #{File.expand_path(options[:extract_images])}"
135
+ result.diagnostics.each do |diagnostic|
136
+ warn "Image #{diagnostic[:code]}: #{diagnostic[:contact_name]} (#{diagnostic[:image_uri]})"
75
137
  end
138
+ exit unless options[:format] || options[:stats] || options[:dedupe]
76
139
  end
77
- exit
78
- end
79
140
 
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
141
+ if options[:schema]
142
+ puts JSON.pretty_generate(archive.schema_report)
143
+ exit
144
+ end
145
+
146
+ search_options = options.values_at(:search, :email, :phone).compact
147
+ if search_options.length > 1
148
+ warn 'Use only one of --search, --email, or --phone.'
149
+ exit 1
150
+ end
151
+
152
+ if options[:json] && search_options.empty?
153
+ warn '--json requires --search, --email, or --phone.'
154
+ exit 1
155
+ end
156
+
157
+ if search_options.any?
158
+ results = if options[:search]
159
+ archive.search(options[:search])
160
+ elsif options[:email]
161
+ archive.find_by_email(options[:email])
162
+ else
163
+ archive.find_by_phone(options[:phone])
164
+ end
165
+
166
+ if options[:json]
167
+ Abbu::Exporters::JsonExporter.new(results).to_stdout
168
+ else
169
+ results.each do |contact|
170
+ fields = [
171
+ contact.full_name,
172
+ contact.emails.filter_map { |email| email[:address] }.join(','),
173
+ contact.phones.filter_map { |phone| phone[:number] }.join(','),
174
+ contact.source&.fetch(:relative_path, nil)
175
+ ]
176
+ puts fields.map { |field| field.to_s.gsub(/[\t\r\n]/, ' ') }.join("\t")
177
+ end
178
+ end
179
+ exit(results.any? ? 0 : 1)
180
+ end
181
+
182
+ if options[:stats]
183
+ contacts = archive.contacts
184
+ puts "Total contacts : #{contacts.count}"
185
+ puts "With email : #{contacts.count { |c| c.emails.any? }}"
186
+ puts "With phone : #{contacts.count { |c| c.phones.any? }}"
187
+ exit
188
+ end
189
+
190
+ if options[:dedupe]
191
+ require 'abbu/utils/deduplicator'
192
+ dupes = Abbu::Utils::Deduplicator.new(archive.contacts).duplicates
193
+ if dupes.empty?
194
+ puts 'No duplicates found.'
195
+ else
196
+ dupes.each do |email, contacts|
197
+ puts "Duplicate: #{email}"
198
+ contacts.each { |c| puts " - #{c.full_name}" }
199
+ end
200
+ end
201
+ exit
202
+ end
203
+
204
+ case options[:format]
205
+ when 'csv'
206
+ exporter = Abbu::Exporters::CsvExporter.new(archive.contacts)
207
+ options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
208
+ when 'json'
209
+ exporter = Abbu::Exporters::JsonExporter.new(archive.contacts)
210
+ options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
211
+ when 'vcard'
212
+ exporter = Abbu::Exporters::VcardExporter.new(archive.contacts)
213
+ options[:output] ? exporter.to_file(options[:output]) : exporter.to_stdout
214
+ else
215
+ puts parser
216
+ exit 1
217
+ end
218
+ rescue Abbu::LiveStore::Error => e
219
+ warn "abbu: #{e.message}"
92
220
  exit 1
221
+ rescue Abbu::ParseError => e
222
+ warn "abbu: #{e.message}"
223
+ exit 2
93
224
  end