abbu 0.1.2 → 0.2.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: 9926de7b6edc99155f0ddf18c39ebfab80677152caeba28bc7b2bce8b0e870bd
4
+ data.tar.gz: 8c7c8224f7799343d8ac994e4c21c2185b30f53aa8bd87783c5423f2ee09d1d8
5
5
  SHA512:
6
- metadata.gz: 5978d9208cac2c0a6bff53184b1dcd1fa016c204a34aa4a7912f9a814d616d4bc7a482d868bf3dc47a4fc2a1b6671bbf7c8092432c28eae2fb1ec3f628248822
7
- data.tar.gz: bd915874f2b2987640a2942beaec9fee6e034411c81fbfa0df127136e60353c5cc0327d638a84a2e959600209d2e9ba72143768b39568484ce4bcbb498e486f4
6
+ metadata.gz: 32b5f1d5496c8ae68f849756242588d6eac681305fee894156d1ba2a1717bb7e3b3fd8b03ce2504ff3c2340597451217a1b29fea9f7eb78e971d161c6b47072c
7
+ data.tar.gz: b1e62d48dca35bf1d3cb584728e7333f85c31971bcd28c01cc718949b8d093108c407efb159bbbf6017002fa3b6c2676c08906df8502fe1647ad6974787a9c9a
data/README.md CHANGED
@@ -8,8 +8,10 @@ Read and process Apple Contacts `.abbu` archives in Ruby.
8
8
 
9
9
  - Parse ABBU (Apple Contacts export) bundles
10
10
  - SQLite-backed contact extraction (modern macOS)
11
- - Legacy plist format detection (stub, v0.2 roadmap)
12
- - Export to CSV, JSON, vCard
11
+ - Legacy plist `.abcdp` parsing (older macOS)
12
+ - Full Apple Contacts schema: names, nicknames, prefix/suffix, job title, department, phonetics, pronouns, and more
13
+ - Rich relational data: addresses, URLs, notes, related names, social profiles
14
+ - Export to CSV, JSON, vCard 3.0
13
15
  - CLI + Ruby API
14
16
  - Duplicate detection
15
17
 
@@ -35,9 +37,10 @@ require "abbu"
35
37
  archive = Abbu.open("Contacts.abbu")
36
38
  contacts = archive.contacts
37
39
 
38
- contacts.first.full_name # => "Stan Carver"
39
- contacts.first.emails # => ["stan@example.com"]
40
- contacts.first.phones # => ["555-1234"]
40
+ contacts.first.full_name # => "Honorable Stan \"Stretch\" Carver II"
41
+ contacts.first.emails # => [{ address: "stan@example.com", label: "Work" }]
42
+ contacts.first.phones # => [{ number: "555-1234", label: "Mobile" }]
43
+ contacts.first.job_title # => "Engineer"
41
44
  ```
42
45
 
43
46
  ### Export
@@ -102,12 +105,7 @@ SQLite table schema, and format history.
102
105
 
103
106
  ## Roadmap
104
107
 
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) |
108
+ See [`docs/TODO.md`](docs/TODO.md) for the full release schedule and feature checklist.
111
109
 
112
110
  ## Development
113
111
 
@@ -120,7 +118,7 @@ mise exec -- bundle exec rubocop # lint
120
118
 
121
119
  ## Contributing
122
120
 
123
- See [CONTRIBUTING.md](CONTRIBUTING.md).
121
+ See [CONTRIBUTING.md](docs/CONTRIBUTING.md).
124
122
 
125
123
  ## License
126
124
 
@@ -0,0 +1,51 @@
1
+ <!-- CONTRIBUTING.md -->
2
+
3
+ # Contributing to abbu
4
+
5
+ Thank you for your interest in contributing!
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ git clone https://github.com/scarver2/abbu
11
+ cd abbu
12
+ mise exec -- bundle install
13
+ ```
14
+
15
+ ## DX Loop
16
+
17
+ ```bash
18
+ mise exec -- bundle exec guard
19
+ ```
20
+
21
+ This runs RSpec and RuboCop automatically on file changes.
22
+
23
+ ## Running Tests
24
+
25
+ ```bash
26
+ mise exec -- bundle exec rspec
27
+ ```
28
+
29
+ ## Linting
30
+
31
+ ```bash
32
+ mise exec -- bundle exec rubocop
33
+ mise exec -- bundle exec rubocop -a # autocorrect
34
+ ```
35
+
36
+ ## Pull Request Guidelines
37
+
38
+ - Base branch: `master`
39
+ - Commit style: [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `chore:`)
40
+ - All specs must pass and coverage must remain at 100%
41
+ - RuboCop must pass with no offenses
42
+ - Add an entry to `docs/CHANGELOG.md` under `[Unreleased]`
43
+
44
+ ## Reporting Issues
45
+
46
+ Open an issue on GitHub with a minimal reproduction case.
47
+
48
+ ---
49
+ Stan Carver II
50
+ Made in Texas 🤠
51
+ https://stancarver.com
data/docs/TODO.md ADDED
@@ -0,0 +1,172 @@
1
+ <!-- docs/TODO.md -->
2
+
3
+ # Abbu Roadmap & TODO
4
+
5
+ Feature checklist organized by release version.
6
+
7
+ ---
8
+
9
+ ## v0.1.x — Foundation (Released)
10
+
11
+ ### v0.1.0 — Initial Release
12
+ - [x] `Abbu.open(path)` entry point returning an `Archive`
13
+ - [x] `Archive#contacts` — reads contacts from SQLite
14
+ - [x] `Archive#sqlite?` — detects modern `.abcddb` bundles
15
+ - [x] `Contact` model with `first_name`, `last_name`, `emails`, `phones`, `company`
16
+ - [x] `Parsers::SqliteParser` — queries `ZABCDRECORD`, `ZABCDEMAILADDRESS`, `ZABCDPHONENUMBER`
17
+ - [x] `Parsers::PlistParser` — stub with warning
18
+ - [x] `Exporters::CsvExporter` — `to_file` and `to_stdout`
19
+ - [x] `Exporters::JsonExporter` — `to_file` and `to_stdout`
20
+ - [x] `Exporters::VcardExporter` — `to_file` and `to_stdout` (vCard 3.0)
21
+ - [x] `Utils::Deduplicator` — groups contacts by first email
22
+ - [x] `bin/abbu` CLI with `--format`, `--output`, `--stats`, `--dedupe`, `--version`
23
+ - [x] Rake tasks: `abbu:export`, `abbu:dedupe`, `abbu:stats`
24
+ - [x] `docs/ABBU.md` — file format reference
25
+ - [x] RSpec test suite with 100% coverage target
26
+ - [x] Guard + RuboCop DX loop
27
+ - [x] GitHub Actions CI (Ruby 3.2 + 3.3)
28
+
29
+ ### v0.1.1 — Pathname Fix
30
+ - [x] Fix missing `require 'pathname'` in `archive.rb`
31
+ - [x] Regression guard spec for file require isolation
32
+
33
+ ### v0.1.2 — Full Apple Contacts Schema
34
+ - [x] Nickname, prefix (`Title`), suffix fields
35
+ - [x] Smart `full_name` formatting: `Honorable Stan "Stretch" Carver II`
36
+ - [x] Job title, department, maiden name
37
+ - [x] Phonetic first/last name, phonetic company
38
+ - [x] Pronouns, ringtone, texttone
39
+ - [x] Hash-based emails/phones preserving custom labels
40
+ - [x] Address parsing from `ZABCDPOSTALADDRESS`
41
+ - [x] Group membership from `Z_ABCDCONTACTGROUP`
42
+ - [x] URL parsing from `ZABCDURLADDRESS`
43
+ - [x] Notes from `ZABCDNOTE`
44
+ - [x] Related names from `ZABCDRELATEDNAME`
45
+ - [x] Social profiles from `ZABCDSOCIALPROFILE` (Twitter, etc.)
46
+ - [x] CSV export with all fields (addresses, groups, URLs, notes, related names, social profiles)
47
+ - [x] JSON export with all contact fields
48
+ - [x] vCard 3.0 export with `ADR`, `URL`, `NICKNAME`, `TITLE`, `NOTE`, `X-SOCIALPROFILE`
49
+ - [x] `rubocop-rspec` plugin integration
50
+ - [x] SqliteParser refactored with `RECORD_FIELD_MAP` constant
51
+ - [x] CsvExporter refactored into `core_fields` / `extended_fields`
52
+
53
+ ---
54
+
55
+ ## v0.2.0 — Plist Parser (In Progress)
56
+
57
+ - [x] `PlistParser` — parse legacy `.abcdp` plist contact files
58
+ - [x] Full field extraction matching SqliteParser output shape
59
+ - [x] `FIELD_MAP` constant for flat-field mapping
60
+ - [x] Multi-value field extraction (emails, phones, addresses, URLs, notes, related names, social profiles)
61
+ - [x] Flexible input: directory path or array of file paths
62
+ - [x] `Archive` scans `**/*.abcdp` across entire bundle tree
63
+ - [x] `plist` gem (~> 3.7) runtime dependency
64
+ - [x] Plist fixture files for integration testing
65
+ - [x] Birthday / anniversary date parsing (plist `Birthday` key)
66
+ - [x] Lunar birthday support
67
+ - [x] Middle name extraction
68
+ - [x] Instant messaging addresses (AIM, Jabber, etc.)
69
+ - [x] Verification code field support
70
+
71
+ ---
72
+
73
+ ## v0.2.1 — Date Fields
74
+
75
+ - [x] `dates` attribute on `Contact` (array of hashes: `{ label:, date: }`)
76
+ - [x] SqliteParser: parse `ZABCDDATECOMPONENTS` (year/month/day separate columns)
77
+ - [x] PlistParser: parse `Birthday` key
78
+ - [x] Anniversary and custom date labels
79
+ - [x] Lunar birthday handling
80
+ - [x] CSV/JSON/vCard export of date fields (`BDAY`, `ANNIVERSARY` in vCard)
81
+
82
+ ---
83
+
84
+ ## v0.2.2 — Instant Messaging & Verification
85
+
86
+ - [x] `instant_messages` attribute on `Contact`
87
+ - [x] SqliteParser: parse `ZABCDMESSAGINGADDRESS`
88
+ - [x] PlistParser: parse `InstantMessage` key
89
+ - [x] Verification code field
90
+ - [x] CSV/JSON/vCard export (`IMPP` in vCard)
91
+
92
+ ---
93
+
94
+ ## v0.2.3 — Middle Name & Completeness
95
+
96
+ - [x] `middle_name` attribute on `Contact`
97
+ - [x] Update `full_name` to include middle name
98
+ - [x] SqliteParser: `ZMIDDLENAME` column
99
+ - [x] PlistParser: `Middle` key
100
+ - [x] Phonetic middle name support
101
+ - [x] vCard `N` field with middle name component
102
+
103
+ ---
104
+
105
+ ## v0.3.0 — Image Extraction
106
+
107
+ - [ ] Extract contact photos from `Images/` directory
108
+ - [ ] Map image UUIDs to contacts via `ZIMAGEURI` or `ZHASIMAGE`
109
+ - [ ] `Contact#image_path` accessor
110
+ - [ ] CLI: `--extract-images` flag to export photos alongside contacts
111
+ - [ ] Support JPEG, PNG, HEIC formats
112
+ - [ ] Thumbnail vs. full-size image handling
113
+
114
+ ---
115
+
116
+ ## v0.4.0 — Fuzzy Deduplication
117
+
118
+ - [ ] Levenshtein distance matching for name-based deduplication
119
+ - [ ] Phone number normalization (strip formatting, compare digits)
120
+ - [ ] Configurable similarity thresholds
121
+ - [ ] `Deduplicator#fuzzy_duplicates` method
122
+ - [ ] CLI: `--dedupe --fuzzy` flag
123
+ - [ ] Merge suggestions output (side-by-side diff)
124
+
125
+ ---
126
+
127
+ ## v0.5.0 — Merge Engine
128
+
129
+ - [ ] `Contact#merge(other)` — combine two contacts preserving all data
130
+ - [ ] Conflict resolution strategies (keep-first, keep-last, keep-both)
131
+ - [ ] `Archive#deduplicate!` — in-place merge with backup
132
+ - [ ] CLI: `--merge` interactive mode
133
+ - [ ] Export merged results to new `.abbu` bundle
134
+
135
+ ---
136
+
137
+ ## v0.6.0 — Filtering & Querying
138
+
139
+ - [ ] `Archive#where(field: value)` query API
140
+ - [ ] Filter by region (state, city, country)
141
+ - [ ] Filter by group membership
142
+ - [ ] Filter by date range (created, modified)
143
+ - [ ] CLI: `--filter` flag with key=value syntax
144
+ - [ ] Chainable query interface
145
+
146
+ ---
147
+
148
+ ## v0.7.0 — Write Support
149
+
150
+ - [ ] Create new `.abbu` bundles from Contact objects
151
+ - [ ] Write SQLite databases with correct schema
152
+ - [ ] Write `.abcdp` plist files
153
+ - [ ] Round-trip: read → modify → write
154
+ - [ ] `Archive#save(path)` method
155
+
156
+ ---
157
+
158
+ ## v1.0.0 — Sync Adapters & Stable API
159
+
160
+ - [ ] Adapter interface for external CRM sync
161
+ - [ ] Printavo adapter
162
+ - [ ] HubSpot adapter
163
+ - [ ] Generic webhook/API adapter
164
+ - [ ] Stable public API guarantee
165
+ - [ ] Comprehensive API documentation (YARD)
166
+ - [ ] Performance benchmarks for large archives (10k+ contacts)
167
+
168
+ ---
169
+
170
+ Stan Carver II
171
+ Made in Texas 🤠
172
+ https://stancarver.com
data/lib/abbu/archive.rb CHANGED
@@ -33,15 +33,15 @@ module Abbu
33
33
  @db_paths ||= @path.glob('**/*.abcddb')
34
34
  end
35
35
 
36
- def records_path
37
- @records_path ||= @path.join('Records')
36
+ def plist_paths
37
+ @plist_paths ||= @path.glob('**/*.abcdp').sort
38
38
  end
39
39
 
40
40
  def parser
41
41
  if sqlite?
42
42
  Parsers::SqliteParser.new(db_paths)
43
43
  else
44
- Parsers::PlistParser.new(records_path)
44
+ Parsers::PlistParser.new(plist_paths)
45
45
  end
46
46
  end
47
47
  end
data/lib/abbu/contact.rb CHANGED
@@ -3,9 +3,14 @@
3
3
 
4
4
  module Abbu
5
5
  class Contact
6
- attr_accessor :first_name, :last_name, :emails, :phones, :company, :addresses, :groups, :nickname, :prefix, :suffix,
7
- :job_title, :department, :maiden_name, :phonetic_first_name, :phonetic_last_name, :phonetic_company,
8
- :pronouns, :ringtone, :texttone, :urls, :notes, :related_names, :social_profiles
6
+ attr_accessor :first_name, :middle_name, :last_name, :emails,
7
+ :phones, :company, :addresses, :groups, :nickname,
8
+ :prefix, :suffix, :job_title, :department, :maiden_name,
9
+ :phonetic_first_name, :phonetic_middle_name, :phonetic_last_name,
10
+ :phonetic_company, :pronouns, :ringtone, :texttone,
11
+ :urls, :notes, :related_names, :social_profiles,
12
+ :birthday, :anniversary, :dates, :instant_messages,
13
+ :verification_code, :lunar_birthday
9
14
 
10
15
  def initialize
11
16
  @emails = []
@@ -16,11 +21,13 @@ module Abbu
16
21
  @notes = []
17
22
  @related_names = []
18
23
  @social_profiles = []
24
+ @dates = []
25
+ @instant_messages = []
19
26
  end
20
27
 
21
28
  def full_name
22
29
  quoted_nickname = nickname ? "\"#{nickname}\"" : nil
23
- [prefix, first_name, quoted_nickname, last_name, suffix].compact.join(' ')
30
+ [prefix, first_name, middle_name, quoted_nickname, last_name, suffix].compact.join(' ')
24
31
  end
25
32
 
26
33
  def to_s
@@ -27,7 +27,8 @@ module Abbu
27
27
  private
28
28
 
29
29
  def headers
30
- %w[Name Email Phone Company Address Groups URLs Notes RelatedNames SocialProfiles]
30
+ %w[Name First Middle Last Email Phone Company Address Groups URLs Notes RelatedNames SocialProfiles Birthday
31
+ Anniversary InstantMessages VerificationCode LunarBirthday]
31
32
  end
32
33
 
33
34
  def row(contact)
@@ -36,21 +37,20 @@ module Abbu
36
37
 
37
38
  def core_fields(contact)
38
39
  [
39
- contact.full_name,
40
- contact.emails.first&.fetch(:address, nil),
41
- contact.phones.first&.fetch(:number, nil),
42
- contact.company,
43
- format_address(contact.addresses.first),
44
- contact.groups.join(', ')
40
+ contact.full_name, contact.first_name, contact.middle_name,
41
+ contact.last_name, contact.emails.first&.fetch(:address, nil),
42
+ contact.phones.first&.fetch(:number, nil), contact.company,
43
+ format_address(contact.addresses.first), contact.groups.join(', ')
45
44
  ]
46
45
  end
47
46
 
48
- def extended_fields(contact)
47
+ def extended_fields(contact) # rubocop:disable Metrics/AbcSize
49
48
  [
50
- contact.urls.map { |u| u[:url] }.join(', '),
51
- contact.notes.join("\n"),
52
- format_related_names(contact.related_names),
53
- format_social_profiles(contact.social_profiles)
49
+ contact.urls.map { |u| u[:url] }.join(', '), contact.notes.join("\n"),
50
+ format_related_names(contact.related_names), format_social_profiles(contact.social_profiles),
51
+ format_date(contact.birthday), format_date(contact.anniversary),
52
+ format_instant_messages(contact.instant_messages), contact.verification_code,
53
+ format_date(contact.lunar_birthday)
54
54
  ]
55
55
  end
56
56
 
@@ -67,6 +67,20 @@ module Abbu
67
67
  def format_social_profiles(profiles)
68
68
  profiles.map { |sp| "#{sp[:username]} on #{sp[:service]}" }.join(', ')
69
69
  end
70
+
71
+ def format_instant_messages(ims)
72
+ ims.map { |im| "#{im[:address]} (#{im[:service]})" }.join(', ')
73
+ end
74
+
75
+ def format_date(date)
76
+ return nil unless date
77
+
78
+ if date[:year]&.positive?
79
+ format('%<year>04d-%<month>02d-%<day>02d', year: date[:year], month: date[:month], day: date[:day])
80
+ else
81
+ format('--%<month>02d-%<day>02d', month: date[:month], day: date[:day])
82
+ end
83
+ end
70
84
  end
71
85
  end
72
86
  end
@@ -28,7 +28,11 @@ module Abbu
28
28
  {
29
29
  name: contact.full_name,
30
30
  first_name: contact.first_name,
31
+ middle_name: contact.middle_name,
31
32
  last_name: contact.last_name,
33
+ phonetic_first_name: contact.phonetic_first_name,
34
+ phonetic_middle_name: contact.phonetic_middle_name,
35
+ phonetic_last_name: contact.phonetic_last_name,
32
36
  nickname: contact.nickname,
33
37
  prefix: contact.prefix,
34
38
  suffix: contact.suffix,
@@ -42,7 +46,13 @@ module Abbu
42
46
  urls: contact.urls,
43
47
  notes: contact.notes,
44
48
  related_names: contact.related_names,
45
- social_profiles: contact.social_profiles
49
+ social_profiles: contact.social_profiles,
50
+ birthday: contact.birthday,
51
+ anniversary: contact.anniversary,
52
+ dates: contact.dates,
53
+ instant_messages: contact.instant_messages,
54
+ verification_code: contact.verification_code,
55
+ lunar_birthday: contact.lunar_birthday
46
56
  }.compact
47
57
  end
48
58
  end
@@ -22,23 +22,63 @@ module Abbu
22
22
  @contacts.map { |c| vcard_for(c) }.join("\n")
23
23
  end
24
24
 
25
- def vcard_for(contact) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
25
+ def vcard_for(contact) # rubocop:disable Metrics/MethodLength
26
26
  lines = ['BEGIN:VCARD', 'VERSION:3.0']
27
- lines << "FN:#{contact.full_name}"
28
- lines << "N:#{contact.last_name};#{contact.first_name};;;"
29
- lines << "NICKNAME:#{contact.nickname}" if contact.nickname
30
- lines << "ORG:#{contact.company}" if contact.company
31
- lines << "TITLE:#{contact.job_title}" if contact.job_title
27
+
28
+ append_name_fields(lines, contact)
32
29
  append_emails(lines, contact)
33
30
  append_phones(lines, contact)
34
31
  append_addresses(lines, contact)
35
32
  append_urls(lines, contact)
36
33
  append_social_profiles(lines, contact)
37
- contact.notes.each { |n| lines << "NOTE:#{n}" }
34
+ append_dates(lines, contact)
35
+ append_instant_messages(lines, contact)
36
+ append_verification_code(lines, contact)
37
+ append_notes(lines, contact)
38
+
38
39
  lines << 'END:VCARD'
39
40
  lines.join("\n")
40
41
  end
41
42
 
43
+ def append_name_fields(lines, contact)
44
+ lines << "FN:#{contact.full_name}"
45
+ lines << "N:#{name_components(contact).join(';')}"
46
+ append_nickname(lines, contact)
47
+ append_company(lines, contact)
48
+ append_title(lines, contact)
49
+ append_phonetic_names(lines, contact)
50
+ end
51
+
52
+ def name_components(contact)
53
+ [contact.last_name, contact.first_name, contact.middle_name, contact.prefix, contact.suffix]
54
+ end
55
+
56
+ def append_nickname(lines, contact)
57
+ lines << "NICKNAME:#{contact.nickname}" if contact.nickname
58
+ end
59
+
60
+ def append_company(lines, contact)
61
+ lines << "ORG:#{contact.company}" if contact.company
62
+ end
63
+
64
+ def append_title(lines, contact)
65
+ lines << "TITLE:#{contact.job_title}" if contact.job_title
66
+ end
67
+
68
+ def append_phonetic_names(lines, contact)
69
+ lines << "X-PHONETIC-FIRST-NAME:#{contact.phonetic_first_name}" if contact.phonetic_first_name
70
+ lines << "X-PHONETIC-MIDDLE-NAME:#{contact.phonetic_middle_name}" if contact.phonetic_middle_name
71
+ lines << "X-PHONETIC-LAST-NAME:#{contact.phonetic_last_name}" if contact.phonetic_last_name
72
+ end
73
+
74
+ def append_verification_code(lines, contact)
75
+ lines << "X-VERIFICATION-CODE:#{contact.verification_code}" if contact.verification_code
76
+ end
77
+
78
+ def append_notes(lines, contact)
79
+ contact.notes.each { |n| lines << "NOTE:#{n}" }
80
+ end
81
+
42
82
  def append_emails(lines, contact)
43
83
  contact.emails.each do |e|
44
84
  label = e[:label] || 'INTERNET'
@@ -71,6 +111,32 @@ module Abbu
71
111
  lines << "X-SOCIALPROFILE;TYPE=#{sp[:service]}:#{sp[:username]}"
72
112
  end
73
113
  end
114
+
115
+ def append_instant_messages(lines, contact)
116
+ contact.instant_messages.each do |im|
117
+ service = im[:service]&.downcase || 'unknown'
118
+ lines << "IMPP;TYPE=#{im[:label]}:#{service}:#{im[:address]}"
119
+ end
120
+ end
121
+
122
+ def append_dates(lines, contact)
123
+ lines << "BDAY:#{format_vcard_date(contact.birthday)}" if contact.birthday
124
+ lines << "X-LUNAR-BDAY:#{format_vcard_date(contact.lunar_birthday)}" if contact.lunar_birthday
125
+ return unless contact.anniversary
126
+
127
+ lines << "X-ABDATE;type=pref:#{format_vcard_date(contact.anniversary)}"
128
+ lines << 'X-ABLABEL:_$!<Anniversary>!$_'
129
+ end
130
+
131
+ def format_vcard_date(date)
132
+ return nil unless date
133
+
134
+ if date[:year]&.positive?
135
+ format('%<year>04d-%<month>02d-%<day>02d', year: date[:year], month: date[:month], day: date[:day])
136
+ else
137
+ format('--%<month>02d-%<day>02d', month: date[:month], day: date[:day])
138
+ end
139
+ end
74
140
  end
75
141
  end
76
142
  end
@@ -1,16 +1,154 @@
1
1
  # lib/abbu/parsers/plist_parser.rb
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'plist'
5
+ require_relative '../contact'
6
+
4
7
  module Abbu
5
8
  module Parsers
6
9
  class PlistParser
7
- def initialize(path)
8
- @path = path
10
+ # Maps plist keys → Contact attr_accessor names for simple string fields
11
+ FIELD_MAP = {
12
+ 'First' => :first_name, 'Middle' => :middle_name,
13
+ 'Last' => :last_name,
14
+ 'Nickname' => :nickname, 'Title' => :prefix,
15
+ 'Suffix' => :suffix, 'Organization' => :company,
16
+ 'JobTitle' => :job_title, 'Department' => :department,
17
+ 'MaidenName' => :maiden_name, 'VerificationCode' => :verification_code,
18
+ 'PhoneticFirst' => :phonetic_first_name,
19
+ 'PhoneticMiddle' => :phonetic_middle_name,
20
+ 'PhoneticLast' => :phonetic_last_name
21
+ }.freeze
22
+
23
+ # Accepts either a directory path (scans for *.abcdp) or an array of file paths
24
+ def initialize(paths)
25
+ @paths = resolve_paths(paths)
9
26
  end
10
27
 
11
28
  def contacts
12
- warn 'Plist parsing not yet implemented — no .abcddb found in this archive.'
13
- []
29
+ @paths.filter_map { |file| parse_file(file) }
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_paths(paths)
35
+ case paths
36
+ when Array
37
+ paths.map { |p| Pathname.new(p) }
38
+ else
39
+ path = Pathname.new(paths)
40
+ return [] unless path.exist?
41
+
42
+ path.directory? ? path.glob('*.abcdp').sort : [path]
43
+ end
44
+ end
45
+
46
+ def parse_file(file)
47
+ data = Plist.parse_xml(file.to_s)
48
+ return nil unless data
49
+
50
+ build_contact(data)
51
+ end
52
+
53
+ def build_contact(data)
54
+ contact = Contact.new
55
+ assign_flat_fields(contact, data)
56
+ assign_multi_value_fields(contact, data)
57
+ contact
58
+ end
59
+
60
+ def assign_flat_fields(contact, data)
61
+ FIELD_MAP.each do |plist_key, attr|
62
+ contact.public_send(:"#{attr}=", data[plist_key])
63
+ end
64
+ end
65
+
66
+ def assign_multi_value_fields(contact, data) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
67
+ contact.emails = extract_labeled_values(data, 'Email', :address)
68
+ contact.phones = extract_labeled_values(data, 'Phone', :number)
69
+ contact.addresses = extract_addresses(data)
70
+ contact.urls = extract_labeled_values(data, 'URLs', :url)
71
+ contact.notes = extract_notes(data)
72
+ contact.related_names = extract_labeled_values(data, 'RelatedNames', :name)
73
+ contact.social_profiles = extract_social_profiles(data)
74
+ contact.instant_messages = extract_instant_messages(data)
75
+
76
+ contact.birthday = extract_birthday(data)
77
+ contact.lunar_birthday = extract_lunar_birthday(data)
78
+ contact.dates = extract_dates(data)
79
+ contact.anniversary = contact.dates.find { |d| d[:label] == '_$!<Anniversary>!$_' }
80
+ end
81
+
82
+ def extract_labeled_values(data, key, value_key)
83
+ return [] unless data[key]&.dig('values')
84
+
85
+ data[key]['values'].map do |entry|
86
+ { value_key => entry['value'], label: entry['label'] }
87
+ end
88
+ end
89
+
90
+ def extract_addresses(data) # rubocop:disable Metrics/MethodLength
91
+ return [] unless data['Address']&.dig('values')
92
+
93
+ data['Address']['values'].map do |entry|
94
+ addr = entry['value'] || {}
95
+ {
96
+ street: addr['Street'],
97
+ city: addr['City'],
98
+ state: addr['State'],
99
+ zip: addr['ZIP'],
100
+ country: addr['Country'],
101
+ label: entry['label']
102
+ }
103
+ end
104
+ end
105
+
106
+ def extract_notes(data)
107
+ note = data['Note']
108
+ note ? [note] : []
109
+ end
110
+
111
+ def extract_social_profiles(data)
112
+ return [] unless data['SocialProfile']&.dig('values')
113
+
114
+ data['SocialProfile']['values'].map do |entry|
115
+ profile = entry['value'] || {}
116
+ { service: profile['serviceName'], username: profile['username'] }
117
+ end
118
+ end
119
+
120
+ def extract_instant_messages(data)
121
+ return [] unless data['InstantMessage']&.dig('values')
122
+
123
+ data['InstantMessage']['values'].map do |entry|
124
+ msg = entry['value'] || {}
125
+ { address: msg['address'], label: entry['label'], service: msg['serviceName'] }
126
+ end
127
+ end
128
+
129
+ def extract_birthday(data)
130
+ val = data['Birthday']
131
+ return nil unless val.respond_to?(:year)
132
+
133
+ { year: val.year, month: val.month, day: val.day, label: '_$!<Birthday>!$_' }
134
+ end
135
+
136
+ def extract_lunar_birthday(data)
137
+ val = data['LunarBirthday']
138
+ return nil unless val.respond_to?(:year)
139
+
140
+ { year: val.year, month: val.month, day: val.day, label: '_$!<LunarBirthday>!$_' }
141
+ end
142
+
143
+ def extract_dates(data)
144
+ return [] unless data['Dates']&.dig('values')
145
+
146
+ data['Dates']['values'].filter_map do |entry|
147
+ val = entry['value']
148
+ next unless val.respond_to?(:year)
149
+
150
+ { year: val.year, month: val.month, day: val.day, label: entry['label'] }
151
+ end
14
152
  end
15
153
  end
16
154
  end
@@ -9,16 +9,19 @@ module Abbu
9
9
  class SqliteParser # rubocop:disable Metrics/ClassLength
10
10
  # Column-name → attr_accessor mapping for flat fields on ZABCDRECORD
11
11
  RECORD_FIELD_MAP = {
12
- 'ZFIRSTNAME' => :first_name, 'ZLASTNAME' => :last_name,
12
+ 'ZFIRSTNAME' => :first_name, 'ZMIDDLENAME' => :middle_name,
13
+ 'ZLASTNAME' => :last_name,
13
14
  'ZNICKNAME' => :nickname, 'ZTITLE' => :prefix,
14
15
  'ZSUFFIX' => :suffix, 'ZORGANIZATION' => :company,
15
16
  'ZJOBTITLE' => :job_title, 'ZDEPARTMENT' => :department,
16
17
  'ZMAIDENNAME' => :maiden_name,
17
18
  'ZPHONETICFIRSTNAME' => :phonetic_first_name,
19
+ 'ZPHONETICMIDDLENAME' => :phonetic_middle_name,
18
20
  'ZPHONETICLASTNAME' => :phonetic_last_name,
19
21
  'ZPHONETICORGANIZATION' => :phonetic_company,
20
22
  'ZPRONOUNS' => :pronouns,
21
- 'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone
23
+ 'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone,
24
+ 'ZVERIFICATIONCODE' => :verification_code
22
25
  }.freeze
23
26
 
24
27
  def initialize(db_paths)
@@ -124,6 +127,28 @@ module Abbu
124
127
  []
125
128
  end
126
129
 
130
+ def dates_for(db, record_id)
131
+ db.execute(
132
+ 'SELECT ZYEAR, ZMONTH, ZDAY, ZLABEL FROM ZABCDDATECOMPONENTS WHERE ZOWNER = ?',
133
+ record_id
134
+ ).map do |row|
135
+ { year: row['ZYEAR'], month: row['ZMONTH'], day: row['ZDAY'], label: row['ZLABEL'] }
136
+ end
137
+ rescue SQLite3::SQLException
138
+ []
139
+ end
140
+
141
+ def instant_messages_for(db, record_id)
142
+ db.execute(
143
+ 'SELECT ZADDRESS, ZLABEL, ZSERVICENAME FROM ZABCDMESSAGINGADDRESS WHERE ZOWNER = ?',
144
+ record_id
145
+ ).map do |row|
146
+ { address: row['ZADDRESS'], label: row['ZLABEL'], service: row['ZSERVICENAME'] }
147
+ end
148
+ rescue SQLite3::SQLException
149
+ []
150
+ end
151
+
127
152
  def build_contact(db, row)
128
153
  contact = Contact.new
129
154
  assign_flat_fields(contact, row)
@@ -137,15 +162,22 @@ module Abbu
137
162
  end
138
163
  end
139
164
 
140
- def assign_relational_fields(contact, db, record_id) # rubocop:disable Metrics/AbcSize
141
- contact.emails = emails_for(db, record_id)
142
- contact.phones = phones_for(db, record_id)
143
- contact.addresses = addresses_for(db, record_id)
144
- contact.groups = groups_for(db, record_id)
145
- contact.urls = urls_for(db, record_id)
146
- contact.notes = notes_for(db, record_id)
147
- contact.related_names = related_names_for(db, record_id)
148
- contact.social_profiles = social_profiles_for(db, record_id)
165
+ def assign_relational_fields(contact, db, record_id) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
166
+ contact.emails = emails_for(db, record_id)
167
+ contact.phones = phones_for(db, record_id)
168
+ contact.addresses = addresses_for(db, record_id)
169
+ contact.groups = groups_for(db, record_id)
170
+ contact.urls = urls_for(db, record_id)
171
+ contact.notes = notes_for(db, record_id)
172
+ contact.related_names = related_names_for(db, record_id)
173
+ contact.social_profiles = social_profiles_for(db, record_id)
174
+ contact.instant_messages = instant_messages_for(db, record_id)
175
+
176
+ all_dates = dates_for(db, record_id)
177
+ contact.dates = all_dates
178
+ contact.birthday = all_dates.find { |d| d[:label] == '_$!<Birthday>!$_' }
179
+ contact.anniversary = all_dates.find { |d| d[:label] == '_$!<Anniversary>!$_' }
180
+ contact.lunar_birthday = all_dates.find { |d| d[:label] == '_$!<LunarBirthday>!$_' }
149
181
  end
150
182
  end
151
183
  end
data/lib/abbu/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Abbu
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.0'
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abbu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stan Carver II
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-26 00:00:00.000000000 Z
11
+ date: 2026-05-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: plist
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.7'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: guard
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +122,20 @@ dependencies:
108
122
  - - "~>"
109
123
  - !ruby/object:Gem::Version
110
124
  version: '3.13'
125
+ - !ruby/object:Gem::Dependency
126
+ name: ruby-lsp
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.1'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.1'
111
139
  - !ruby/object:Gem::Dependency
112
140
  name: rubocop
113
141
  requirement: !ruby/object:Gem::Requirement
@@ -188,7 +216,6 @@ executables:
188
216
  extensions: []
189
217
  extra_rdoc_files: []
190
218
  files:
191
- - CHANGELOG.md
192
219
  - LICENSE
193
220
  - README.md
194
221
  - bin/abbu
@@ -199,6 +226,9 @@ files:
199
226
  - bin/outdated
200
227
  - bin/test
201
228
  - docs/ABBU.md
229
+ - docs/CHANGELOG.md
230
+ - docs/CONTRIBUTING.md
231
+ - docs/TODO.md
202
232
  - examples/deduplicate_contacts.rb
203
233
  - examples/export_to_api.rb
204
234
  - examples/export_to_csv.rb
@@ -224,7 +254,7 @@ metadata:
224
254
  allowed_push_host: https://rubygems.org
225
255
  homepage_uri: https://github.com/scarver2/abbu
226
256
  source_code_uri: https://github.com/scarver2/abbu
227
- changelog_uri: https://github.com/scarver2/abbu/blob/master/CHANGELOG.md
257
+ changelog_uri: https://github.com/scarver2/abbu/blob/master/docs/CHANGELOG.md
228
258
  rubygems_mfa_required: 'true'
229
259
  post_install_message:
230
260
  rdoc_options: []
File without changes