abbu 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4e7f9f82489460fb204dd66e345d8ce69e3a81944a9568cb81ae2dc84b41ed0e
4
+ data.tar.gz: 379defbf4b95f173d6afeaa255787e9f4a051562edf4428626f9048c32c3af66
5
+ SHA512:
6
+ metadata.gz: 11c08d30dbf68e633f072784e6d9a505bebc1dea90b3d527ec5472425588fe169dacb81b960469af9bd431b74b457ac9ef7ac637cad7357cd2cf25c9c1417f1f
7
+ data.tar.gz: 1eaf8b0d1b0733f4cb53fcbc6fe5dc11d0725acbb96878ec7677e1830501550bad2a035cf37c2c66c5e2b3da81576a10f0a3498192d0481c46deed5c8a7aacb5
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ <!-- CHANGELOG.md -->
2
+
3
+ # Changelog
4
+
5
+ All notable changes to `abbu` are documented here.
6
+
7
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
8
+ Versioning follows [Semantic Versioning](https://semver.org/).
9
+
10
+ ---
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.1.0] - 2026-04-12
15
+
16
+ ### Added
17
+
18
+ - `Abbu.open(path)` entry point returning an `Archive`
19
+ - `Archive#contacts` — reads contacts from SQLite or falls back to plist stub
20
+ - `Archive#sqlite?` — detects modern `.abcddb` bundles
21
+ - `Contact` object with `first_name`, `last_name`, `emails`, `phones`, `company`, `full_name`
22
+ - `Parsers::SqliteParser` — queries `ZABCDRECORD`, `ZABCDEMAILADDRESS`, `ZABCDPHONENUMBER`
23
+ - `Parsers::PlistParser` — stub with warning (legacy `.abcdp` support in v0.2)
24
+ - `Exporters::CsvExporter` — `to_file` and `to_stdout`
25
+ - `Exporters::JsonExporter` — `to_file` and `to_stdout`
26
+ - `Exporters::VcardExporter` — `to_file` and `to_stdout` (vCard 3.0)
27
+ - `Utils::Deduplicator` — groups contacts by first email, returns duplicates hash
28
+ - `bin/abbu` CLI with `--format`, `--output`, `--stats`, `--dedupe`, `--version`
29
+ - Rake tasks: `abbu:export`, `abbu:dedupe`, `abbu:stats`
30
+ - Example scripts: CSV, JSON, vCard, API, CRM sync, stats, dedupe
31
+ - `docs/ABBU.md` — file format reference
32
+ - RSpec test suite with 100% coverage target
33
+ - Guard + RuboCop DX loop
34
+ - GitHub Actions CI (Ruby 3.2 + 3.3)
35
+
36
+ ---
37
+ Stan Carver II
38
+ Made in Texas 🤠
39
+ https://stancarver.com
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stan Carver II
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ <!-- README.md -->
2
+
3
+ # abbu
4
+
5
+ Read and process Apple Contacts `.abbu` archives in Ruby.
6
+
7
+ ## Features
8
+
9
+ - Parse ABBU (Apple Contacts export) bundles
10
+ - SQLite-backed contact extraction (modern macOS)
11
+ - Legacy plist format detection (stub, v0.2 roadmap)
12
+ - Export to CSV, JSON, vCard
13
+ - CLI + Ruby API
14
+ - Duplicate detection
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ gem install abbu
20
+ ```
21
+
22
+ Or add to your `Gemfile`:
23
+
24
+ ```ruby
25
+ gem "abbu"
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Ruby API
31
+
32
+ ```ruby
33
+ require "abbu"
34
+
35
+ archive = Abbu.open("Contacts.abbu")
36
+ contacts = archive.contacts
37
+
38
+ contacts.first.full_name # => "Stan Carver"
39
+ contacts.first.emails # => ["stan@example.com"]
40
+ contacts.first.phones # => ["555-1234"]
41
+ ```
42
+
43
+ ### Export
44
+
45
+ ```ruby
46
+ # CSV
47
+ Abbu::Exporters::CsvExporter.new(archive.contacts).to_file("contacts.csv")
48
+
49
+ # JSON
50
+ Abbu::Exporters::JsonExporter.new(archive.contacts).to_file("contacts.json")
51
+
52
+ # vCard
53
+ Abbu::Exporters::VcardExporter.new(archive.contacts).to_file("contacts.vcf")
54
+ ```
55
+
56
+ ### Duplicate Detection
57
+
58
+ ```ruby
59
+ dupes = Abbu::Utils::Deduplicator.new(archive.contacts).duplicates
60
+ dupes.each do |email, contacts|
61
+ puts "Duplicate: #{email}"
62
+ contacts.each { |c| puts " - #{c.full_name}" }
63
+ end
64
+ ```
65
+
66
+ ## CLI
67
+
68
+ ```bash
69
+ # Export to CSV
70
+ abbu Contacts.abbu -f csv -o contacts.csv
71
+
72
+ # JSON to stdout (pipeable)
73
+ abbu Contacts.abbu -f json | jq .
74
+
75
+ # vCard export
76
+ abbu Contacts.abbu -f vcard -o contacts.vcf
77
+
78
+ # Stats
79
+ abbu Contacts.abbu --stats
80
+
81
+ # Find duplicates
82
+ abbu Contacts.abbu --dedupe
83
+ ```
84
+
85
+ ## Rake Tasks
86
+
87
+ ```ruby
88
+ # In your Rakefile:
89
+ load "tasks/abbu.rake"
90
+ ```
91
+
92
+ ```bash
93
+ rake abbu:export[Contacts.abbu]
94
+ rake abbu:dedupe[Contacts.abbu]
95
+ rake abbu:stats[Contacts.abbu]
96
+ ```
97
+
98
+ ## ABBU File Format
99
+
100
+ See [`docs/ABBU.md`](docs/ABBU.md) for a full explanation of the archive structure,
101
+ SQLite table schema, and format history.
102
+
103
+ ## Roadmap
104
+
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) |
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ 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
119
+ ```
120
+
121
+ ## Contributing
122
+
123
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
124
+
125
+ ## License
126
+
127
+ MIT. See [LICENSE](LICENSE).
128
+
129
+ ---
130
+ Stan Carver II
131
+ Made in Texas 🤠
132
+ https://stancarver.com
data/bin/abbu ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # bin/abbu
3
+ # frozen_string_literal: true
4
+
5
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
+
7
+ require 'optparse'
8
+ require 'abbu'
9
+
10
+ options = {
11
+ format: nil,
12
+ output: nil,
13
+ dedupe: false,
14
+ stats: false
15
+ }
16
+
17
+ parser = OptionParser.new do |opts|
18
+ opts.banner = 'Usage: abbu <file.abbu> [options]'
19
+
20
+ opts.on('-f', '--format FORMAT', %w[csv json vcard], 'Export format (csv, json, vcard)') do |f|
21
+ options[:format] = f
22
+ end
23
+
24
+ opts.on('-o', '--output FILE', 'Output file (default: stdout)') do |o|
25
+ options[:output] = o
26
+ end
27
+
28
+ opts.on('--stats', 'Print contact statistics') do
29
+ options[:stats] = true
30
+ end
31
+
32
+ opts.on('--dedupe', 'Find and print duplicate contacts') do
33
+ options[:dedupe] = true
34
+ end
35
+
36
+ opts.on('-v', '--version', 'Print version') do
37
+ puts "abbu #{Abbu::VERSION}"
38
+ exit
39
+ end
40
+
41
+ opts.on('-h', '--help', 'Print this help') do
42
+ puts opts
43
+ exit
44
+ end
45
+ end
46
+
47
+ parser.parse!
48
+
49
+ file = ARGV.shift
50
+
51
+ if file.nil?
52
+ puts parser
53
+ exit 1
54
+ end
55
+
56
+ archive = Abbu.open(file)
57
+
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
64
+ end
65
+
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}" }
75
+ end
76
+ end
77
+ exit
78
+ end
79
+
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
92
+ exit 1
93
+ end
data/bin/cleanse ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # bin/cleanse
3
+ set -euo pipefail
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle exec rubocop -A "$@"
data/bin/console ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # bin/console
3
+ # frozen_string_literal: true
4
+
5
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
+
7
+ require "irb"
8
+ require "abbu"
9
+
10
+ IRB.start(__FILE__)
data/bin/dev ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # bin/dev
3
+ set -euo pipefail
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle exec guard
data/bin/lint ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # bin/lint
3
+ set -euo pipefail
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle exec rubocop "$@"
data/bin/outdated ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # bin/outdated
3
+ set -euo pipefail
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle outdated "$@"
data/bin/test ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # bin/test
3
+ set -euo pipefail
4
+
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle exec rspec "$@"
data/docs/ABBU.md ADDED
@@ -0,0 +1,87 @@
1
+ <!-- docs/ABBU.md -->
2
+
3
+ # ABBU File Format (Apple Contacts Archive)
4
+
5
+ ## Overview
6
+
7
+ `.abbu` files are exported from Apple Contacts.app and represent a full address book archive.
8
+
9
+ They are **not** a single file format — they are a macOS "package" (a directory bundle that Finder
10
+ presents as a single file). This means you can inspect the contents with `ls` or `open -a Finder`.
11
+
12
+ ## Structure
13
+
14
+ Typical contents of a `.abbu` bundle:
15
+
16
+ ```text
17
+ Contacts.abbu/
18
+ ├── AddressBook-v22.abcddb ← SQLite database for "Local" contacts (often mostly empty)
19
+ ├── Metadata/ ← plist files (bundle metadata)
20
+ │ └── *.abcdp
21
+ ├── Images/ ← contact photos (JPEG/PNG)
22
+ │ └── <uuid>.jpg
23
+ ├── Sources/ ← Remote synced accounts (iCloud, Exchange, Google)
24
+ │ ├── <account_uuid>/
25
+ │ │ ├── AddressBook-v22.abcddb ← SQLite database for this specific account
26
+ │ │ ├── Metadata/
27
+ │ │ └── Images/
28
+ │ └── <another_uuid>/...
29
+ └── Records/ ← legacy plist-based contact records (older macOS)
30
+ └── <uuid>.abcdp
31
+ ```
32
+
33
+ > **Note:** The most common pitfall when parsing `.abbu` files is only reading the root `AddressBook-v22.abcddb`. For users syncing via iCloud or Exchange, the root database will be nearly empty. Parsers must recursively scan the `Sources/` directory to discover and extract all contacts from all `.abcddb` files.
34
+
35
+ ## Formats
36
+
37
+ ### 1. SQLite (modern macOS)
38
+
39
+ Newer macOS versions store the address book in a single SQLite database:
40
+
41
+ ```
42
+ AddressBook-v22.abcddb
43
+ ```
44
+
45
+ Key tables:
46
+
47
+ | Table | Purpose |
48
+ |------------------------|--------------------------------------|
49
+ | `ZABCDRECORD` | One row per contact (name, company) |
50
+ | `ZABCDEMAILADDRESS` | Email addresses (linked by `ZOWNER`) |
51
+ | `ZABCDPHONENUMBER` | Phone numbers (linked by `ZOWNER`) |
52
+
53
+ Notable columns in `ZABCDRECORD`:
54
+
55
+ | Column | Description |
56
+ |-----------------|------------------|
57
+ | `Z_PK` | Primary key |
58
+ | `ZFIRSTNAME` | First name |
59
+ | `ZLASTNAME` | Last name |
60
+ | `ZORGANIZATION` | Company / org |
61
+
62
+ ### 2. Plist / `.abcdp` (legacy macOS)
63
+
64
+ Older macOS versions stored each contact as a separate binary plist file under `Records/`.
65
+ Each file is a serialised `ABPerson` dictionary. The `abbu` gem currently stubs this parser
66
+ and returns an empty array with a warning.
67
+
68
+ ## Export Steps
69
+
70
+ To create a `.abbu` file:
71
+
72
+ 1. Open **Contacts.app** on macOS
73
+ 2. Select all contacts (`⌘A`)
74
+ 3. File → Export → **Export vCard** *(or)* File → Export → **Contacts Archive…**
75
+
76
+ The "Contacts Archive" option produces a `.abbu` bundle.
77
+
78
+ ## References
79
+
80
+ - [Apple Contacts Framework (private)](https://developer.apple.com/documentation/contacts)
81
+ - [SQLite3 gem](https://github.com/sparklemotion/sqlite3-ruby)
82
+ - macOS `AddressBook.framework` private headers (reverse-engineered)
83
+
84
+ ---
85
+ Stan Carver II
86
+ Made in Texas 🤠
87
+ https://stancarver.com
@@ -0,0 +1,25 @@
1
+ # examples/deduplicate_contacts.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Find duplicate contacts by email address
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/deduplicate_contacts.rb Contacts.abbu
8
+
9
+ require 'abbu'
10
+ require 'abbu/utils/deduplicator'
11
+
12
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
13
+
14
+ dupes = Abbu::Utils::Deduplicator.new(archive.contacts).duplicates
15
+
16
+ if dupes.empty?
17
+ puts 'No duplicates found.'
18
+ else
19
+ puts "Found #{dupes.size} duplicate email(s):\n\n"
20
+ dupes.each do |email, contacts|
21
+ puts "Duplicate: #{email}"
22
+ contacts.each { |c| puts " - #{c.full_name}" }
23
+ puts
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # examples/export_to_api.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Sync contacts from a .abbu archive to a JSON API (CRM / Rodeo pattern)
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/export_to_api.rb Contacts.abbu
8
+
9
+ require 'abbu'
10
+ require 'net/http'
11
+ require 'json'
12
+
13
+ API_ENDPOINT = 'https://api.example.com/contacts'
14
+
15
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
16
+
17
+ archive.contacts.each do |c|
18
+ uri = URI(API_ENDPOINT)
19
+
20
+ response = Net::HTTP.post(
21
+ uri,
22
+ { name: c.full_name, email: c.emails.first, phone: c.phones.first }.to_json,
23
+ 'Content-Type' => 'application/json'
24
+ )
25
+
26
+ puts "#{c.full_name} → #{response.code}"
27
+ end
@@ -0,0 +1,22 @@
1
+ # examples/export_to_csv.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Export all contacts from a .abbu archive to contacts.csv
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/export_to_csv.rb Contacts.abbu
8
+
9
+ require 'abbu'
10
+ require 'csv'
11
+
12
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
13
+
14
+ CSV.open('contacts.csv', 'w') do |csv|
15
+ csv << %w[Name Email Phone Company]
16
+
17
+ archive.contacts.each do |c|
18
+ csv << [c.full_name, c.emails.first, c.phones.first, c.company]
19
+ end
20
+ end
21
+
22
+ puts "Exported #{archive.contacts.count} contacts to contacts.csv"
@@ -0,0 +1,18 @@
1
+ # examples/export_to_json.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Export all contacts from a .abbu archive to contacts.json
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/export_to_json.rb Contacts.abbu
8
+ # bundle exec ruby examples/export_to_json.rb Contacts.abbu | jq .
9
+
10
+ require 'abbu'
11
+
12
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
13
+
14
+ Abbu::Exporters::JsonExporter
15
+ .new(archive.contacts)
16
+ .to_file('contacts.json')
17
+
18
+ puts "Exported #{archive.contacts.count} contacts to contacts.json"
@@ -0,0 +1,17 @@
1
+ # examples/export_to_vcard.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Export all contacts from a .abbu archive to contacts.vcf
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/export_to_vcard.rb Contacts.abbu
8
+
9
+ require 'abbu'
10
+
11
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
12
+
13
+ Abbu::Exporters::VcardExporter
14
+ .new(archive.contacts)
15
+ .to_file('contacts.vcf')
16
+
17
+ puts "Exported #{archive.contacts.count} contacts to contacts.vcf"
@@ -0,0 +1,18 @@
1
+ # examples/stats_report.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Print a summary report of contacts in a .abbu archive
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/stats_report.rb Contacts.abbu
8
+
9
+ require 'abbu'
10
+
11
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
12
+ contacts = archive.contacts
13
+
14
+ puts '=== Contacts Report ==='
15
+ puts "Total contacts : #{contacts.count}"
16
+ puts "With email : #{contacts.count { |c| c.emails.any? }}"
17
+ puts "With phone : #{contacts.count { |c| c.phones.any? }}"
18
+ puts "With company : #{contacts.count(&:company)}"
@@ -0,0 +1,22 @@
1
+ # examples/sync_to_crm.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Stub CRM sync pattern — replace CRM.upsert with your adapter
5
+ #
6
+ # Usage:
7
+ # bundle exec ruby examples/sync_to_crm.rb Contacts.abbu
8
+
9
+ require 'abbu'
10
+
11
+ # Replace this class with your real CRM adapter (Printavo, HubSpot, Rodeo, etc.)
12
+ class CRM
13
+ def self.upsert(contact)
14
+ puts "Syncing #{contact.full_name}"
15
+ end
16
+ end
17
+
18
+ archive = Abbu.open(ARGV[0] || 'Contacts.abbu')
19
+
20
+ archive.contacts.each do |c|
21
+ CRM.upsert(c)
22
+ end
@@ -0,0 +1,47 @@
1
+ # lib/abbu/archive.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'parsers/sqlite_parser'
5
+ require_relative 'parsers/plist_parser'
6
+
7
+ module Abbu
8
+ class Archive
9
+ attr_reader :path
10
+
11
+ def initialize(path)
12
+ @path = Pathname.new(path)
13
+ validate!
14
+ end
15
+
16
+ def contacts
17
+ parser.contacts
18
+ end
19
+
20
+ def sqlite?
21
+ db_paths.any?
22
+ end
23
+
24
+ private
25
+
26
+ def validate!
27
+ raise ArgumentError, "ABBU path not found: #{@path}" unless @path.exist?
28
+ raise ArgumentError, "Not a directory bundle: #{@path}" unless @path.directory?
29
+ end
30
+
31
+ def db_paths
32
+ @db_paths ||= @path.glob('**/*.abcddb')
33
+ end
34
+
35
+ def records_path
36
+ @records_path ||= @path.join('Records')
37
+ end
38
+
39
+ def parser
40
+ if sqlite?
41
+ Parsers::SqliteParser.new(db_paths)
42
+ else
43
+ Parsers::PlistParser.new(records_path)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,26 @@
1
+ # lib/abbu/contact.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ class Contact
6
+ attr_accessor :first_name, :last_name, :emails, :phones, :company
7
+
8
+ def initialize
9
+ @emails = []
10
+ @phones = []
11
+ end
12
+
13
+ def full_name
14
+ [first_name, last_name].compact.join(' ')
15
+ end
16
+
17
+ def to_s
18
+ "#<Abbu::Contact first_name=#{first_name.inspect} last_name=#{last_name.inspect} " \
19
+ "emails=#{emails.inspect} phones=#{phones.inspect}>"
20
+ end
21
+
22
+ def inspect
23
+ to_s
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # lib/abbu/exporters/csv_exporter.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'csv'
5
+
6
+ module Abbu
7
+ module Exporters
8
+ class CsvExporter
9
+ def initialize(contacts)
10
+ @contacts = contacts
11
+ end
12
+
13
+ def to_file(path)
14
+ CSV.open(path, 'w') do |csv|
15
+ csv << headers
16
+ @contacts.each { |c| csv << row(c) }
17
+ end
18
+ end
19
+
20
+ def to_stdout
21
+ puts(CSV.generate do |csv|
22
+ csv << headers
23
+ @contacts.each { |c| csv << row(c) }
24
+ end)
25
+ end
26
+
27
+ private
28
+
29
+ def headers
30
+ %w[Name Email Phone Company]
31
+ end
32
+
33
+ def row(contact)
34
+ [contact.full_name, contact.emails.first, contact.phones.first, contact.company]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # lib/abbu/exporters/json_exporter.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+
6
+ module Abbu
7
+ module Exporters
8
+ class JsonExporter
9
+ def initialize(contacts)
10
+ @contacts = contacts
11
+ end
12
+
13
+ def to_file(path)
14
+ File.write(path, JSON.pretty_generate(payload))
15
+ end
16
+
17
+ def to_stdout
18
+ puts JSON.pretty_generate(payload)
19
+ end
20
+
21
+ private
22
+
23
+ def payload
24
+ @contacts.map do |c|
25
+ {
26
+ name: c.full_name,
27
+ emails: c.emails,
28
+ phones: c.phones,
29
+ company: c.company
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ # lib/abbu/exporters/vcard_exporter.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Exporters
6
+ class VcardExporter
7
+ def initialize(contacts)
8
+ @contacts = contacts
9
+ end
10
+
11
+ def to_file(path)
12
+ File.write(path, generate)
13
+ end
14
+
15
+ def to_stdout
16
+ puts generate
17
+ end
18
+
19
+ private
20
+
21
+ def generate
22
+ @contacts.map { |c| vcard_for(c) }.join("\n")
23
+ end
24
+
25
+ def vcard_for(contact)
26
+ lines = ['BEGIN:VCARD', 'VERSION:3.0']
27
+ lines << "FN:#{contact.full_name}"
28
+ lines << "N:#{contact.last_name};#{contact.first_name};;;"
29
+ lines << "ORG:#{contact.company}" if contact.company
30
+ contact.emails.each { |e| lines << "EMAIL:#{e}" }
31
+ contact.phones.each { |p| lines << "TEL:#{p}" }
32
+ lines << 'END:VCARD'
33
+ lines.join("\n")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ # lib/abbu/parsers/plist_parser.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Parsers
6
+ class PlistParser
7
+ def initialize(path)
8
+ @path = path
9
+ end
10
+
11
+ def contacts
12
+ warn 'Plist parsing not yet implemented — no .abcddb found in this archive.'
13
+ []
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,59 @@
1
+ # lib/abbu/parsers/sqlite_parser.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'sqlite3'
5
+ require_relative '../contact'
6
+
7
+ module Abbu
8
+ module Parsers
9
+ class SqliteParser
10
+ def initialize(db_paths)
11
+ @db_paths = Array(db_paths)
12
+ end
13
+
14
+ def contacts
15
+ @db_paths.flat_map do |db_path|
16
+ parse_db(db_path)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def parse_db(db_path)
23
+ db = SQLite3::Database.new(db_path.to_s)
24
+ db.results_as_hash = true
25
+ records(db).map { |row| build_contact(db, row) }
26
+ ensure
27
+ db&.close
28
+ end
29
+
30
+ def records(db)
31
+ db.execute('SELECT * FROM ZABCDRECORD')
32
+ end
33
+
34
+ def emails_for(db, record_id)
35
+ db.execute(
36
+ 'SELECT ZADDRESSNORMALIZED FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
37
+ record_id
38
+ ).filter_map { |row| row['ZADDRESSNORMALIZED'] }
39
+ end
40
+
41
+ def phones_for(db, record_id)
42
+ db.execute(
43
+ 'SELECT ZFULLNUMBER FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
44
+ record_id
45
+ ).filter_map { |row| row['ZFULLNUMBER'] }
46
+ end
47
+
48
+ def build_contact(db, row)
49
+ contact = Contact.new
50
+ contact.first_name = row['ZFIRSTNAME']
51
+ contact.last_name = row['ZLASTNAME']
52
+ contact.company = row['ZORGANIZATION']
53
+ contact.emails = emails_for(db, row['Z_PK'])
54
+ contact.phones = phones_for(db, row['Z_PK'])
55
+ contact
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,19 @@
1
+ # lib/abbu/utils/deduplicator.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Utils
6
+ class Deduplicator
7
+ def initialize(contacts)
8
+ @contacts = contacts
9
+ end
10
+
11
+ def duplicates
12
+ @contacts
13
+ .group_by { |c| c.emails.first }
14
+ .reject { |k, _| k.nil? }
15
+ .select { |_, v| v.size > 1 }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ # lib/abbu/version.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ VERSION = '0.1.0'
6
+ end
data/lib/abbu.rb ADDED
@@ -0,0 +1,18 @@
1
+ # lib/abbu.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'abbu/version'
5
+ require_relative 'abbu/contact'
6
+ require_relative 'abbu/archive'
7
+ require_relative 'abbu/parsers/sqlite_parser'
8
+ require_relative 'abbu/parsers/plist_parser'
9
+ require_relative 'abbu/exporters/csv_exporter'
10
+ require_relative 'abbu/exporters/json_exporter'
11
+ require_relative 'abbu/exporters/vcard_exporter'
12
+ require_relative 'abbu/utils/deduplicator'
13
+
14
+ module Abbu
15
+ def self.open(path)
16
+ Archive.new(path)
17
+ end
18
+ end
data/tasks/abbu.rake ADDED
@@ -0,0 +1,49 @@
1
+ # tasks/abbu.rake
2
+ # frozen_string_literal: true
3
+
4
+ require 'abbu'
5
+ require 'csv'
6
+
7
+ namespace :abbu do
8
+ desc 'Export contacts to CSV — rake abbu:export[file=Contacts.abbu]'
9
+ task :export, [:file] do |_, args|
10
+ archive = Abbu.open(args[:file] || 'Contacts.abbu')
11
+
12
+ CSV.open('contacts.csv', 'w') do |csv|
13
+ csv << %w[Name Email Phone Company]
14
+ archive.contacts.each do |c|
15
+ csv << [c.full_name, c.emails.first, c.phones.first, c.company]
16
+ end
17
+ end
18
+
19
+ puts "Exported #{archive.contacts.count} contacts to contacts.csv"
20
+ end
21
+
22
+ desc 'Find duplicate contacts — rake abbu:dedupe[file=Contacts.abbu]'
23
+ task :dedupe, [:file] do |_, args|
24
+ require 'abbu/utils/deduplicator'
25
+
26
+ archive = Abbu.open(args[:file] || 'Contacts.abbu')
27
+ dupes = Abbu::Utils::Deduplicator.new(archive.contacts).duplicates
28
+
29
+ if dupes.empty?
30
+ puts 'No duplicates found.'
31
+ else
32
+ dupes.each do |email, contacts|
33
+ puts "Duplicate: #{email}"
34
+ contacts.each { |c| puts " - #{c.full_name}" }
35
+ end
36
+ end
37
+ end
38
+
39
+ desc 'Print contact stats — rake abbu:stats[file=Contacts.abbu]'
40
+ task :stats, [:file] do |_, args|
41
+ archive = Abbu.open(args[:file] || 'Contacts.abbu')
42
+ contacts = archive.contacts
43
+
44
+ puts "Total contacts : #{contacts.count}"
45
+ puts "With email : #{contacts.count { |c| c.emails.any? }}"
46
+ puts "With phone : #{contacts.count { |c| c.phones.any? }}"
47
+ puts "With company : #{contacts.count(&:company)}"
48
+ end
49
+ end
metadata ADDED
@@ -0,0 +1,234 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: abbu
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stan Carver II
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sqlite3
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: csv
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: guard
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.18'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.18'
55
+ - !ruby/object:Gem::Dependency
56
+ name: guard-rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '4.7'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '4.7'
69
+ - !ruby/object:Gem::Dependency
70
+ name: guard-rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.5'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.5'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.13'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.13'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.65'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.65'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-performance
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.21'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.21'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop-rake
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.6'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.6'
153
+ - !ruby/object:Gem::Dependency
154
+ name: simplecov
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '0.22'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '0.22'
167
+ description: Parse Apple Address Book Archive (.abbu) files and export contacts to
168
+ CSV, JSON, or vCard. Supports modern SQLite-backed archives and legacy plist-based
169
+ records.
170
+ email:
171
+ - stan@a1webconsulting.com
172
+ executables:
173
+ - abbu
174
+ extensions: []
175
+ extra_rdoc_files: []
176
+ files:
177
+ - CHANGELOG.md
178
+ - LICENSE
179
+ - README.md
180
+ - bin/abbu
181
+ - bin/cleanse
182
+ - bin/console
183
+ - bin/dev
184
+ - bin/lint
185
+ - bin/outdated
186
+ - bin/test
187
+ - docs/ABBU.md
188
+ - examples/deduplicate_contacts.rb
189
+ - examples/export_to_api.rb
190
+ - examples/export_to_csv.rb
191
+ - examples/export_to_json.rb
192
+ - examples/export_to_vcard.rb
193
+ - examples/stats_report.rb
194
+ - examples/sync_to_crm.rb
195
+ - lib/abbu.rb
196
+ - lib/abbu/archive.rb
197
+ - lib/abbu/contact.rb
198
+ - lib/abbu/exporters/csv_exporter.rb
199
+ - lib/abbu/exporters/json_exporter.rb
200
+ - lib/abbu/exporters/vcard_exporter.rb
201
+ - lib/abbu/parsers/plist_parser.rb
202
+ - lib/abbu/parsers/sqlite_parser.rb
203
+ - lib/abbu/utils/deduplicator.rb
204
+ - lib/abbu/version.rb
205
+ - tasks/abbu.rake
206
+ homepage: https://github.com/scarver2/abbu
207
+ licenses:
208
+ - MIT
209
+ metadata:
210
+ allowed_push_host: https://rubygems.org
211
+ homepage_uri: https://github.com/scarver2/abbu
212
+ source_code_uri: https://github.com/scarver2/abbu
213
+ changelog_uri: https://github.com/scarver2/abbu/blob/master/CHANGELOG.md
214
+ rubygems_mfa_required: 'true'
215
+ post_install_message:
216
+ rdoc_options: []
217
+ require_paths:
218
+ - lib
219
+ required_ruby_version: !ruby/object:Gem::Requirement
220
+ requirements:
221
+ - - ">="
222
+ - !ruby/object:Gem::Version
223
+ version: '3.2'
224
+ required_rubygems_version: !ruby/object:Gem::Requirement
225
+ requirements:
226
+ - - ">="
227
+ - !ruby/object:Gem::Version
228
+ version: '0'
229
+ requirements: []
230
+ rubygems_version: 3.4.19
231
+ signing_key:
232
+ specification_version: 4
233
+ summary: Read and process Apple Contacts .abbu archives in Ruby.
234
+ test_files: []