abbu 0.1.1 → 0.1.2

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: 50bca77bc23aaa22b0ba903a7a209158464bf8c9e7599723e7a3252708dd8338
4
- data.tar.gz: a42bdb76e5acfd9f99c9c9bc0a53fa797cf093ff3e4f18e4441eeee06ad65322
3
+ metadata.gz: 6a2a430e28fa7792cb9f22a2e30f808a5d5752b0144cec428e64a88bde5740f9
4
+ data.tar.gz: 0c1e039e876c8a82b6a8ec52a046afe421f51d0236ceed9eab67f99a09261695
5
5
  SHA512:
6
- metadata.gz: 3fcc9599424ce7d5577aa546ed42ab760efcdb45ea292ceb7a4e83404fd4726667df0b34020ed8f8dcbfdd21a73ed408bde38a0f8feb565bd0114fc44559433e
7
- data.tar.gz: 90b0de3e8cf215467525b9e9520dc0af50c3cb40ae1a2c46ee5076663a3571b5e89f6a79e3fe96d52d12789e743703a23e90ccfac23ad6ae2d9a7cd4b9312905
6
+ metadata.gz: 5978d9208cac2c0a6bff53184b1dcd1fa016c204a34aa4a7912f9a814d616d4bc7a482d868bf3dc47a4fc2a1b6671bbf7c8092432c28eae2fb1ec3f628248822
7
+ data.tar.gz: bd915874f2b2987640a2942beaec9fee6e034411c81fbfa0df127136e60353c5cc0327d638a84a2e959600209d2e9ba72143768b39568484ce4bcbb498e486f4
data/CHANGELOG.md CHANGED
@@ -11,6 +11,34 @@ Versioning follows [Semantic Versioning](https://semver.org/).
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.1.2] - 2026-04-26
15
+
16
+ ### Added
17
+
18
+ - Full Apple Contacts schema support: job title, department, maiden name, phonetic names, pronouns, ringtone, texttone
19
+ - Relational table parsing: URLs, notes, related names (family/business), social profiles (Twitter, etc.)
20
+ - Nickname, prefix, and suffix fields with smart `full_name` formatting
21
+ - Hash-based email/phone data preserving custom labels (e.g. "Direct Line", "Work")
22
+ - Address, group, URL, notes, related names, and social profiles in CSV export
23
+ - Comprehensive JSON export with all contact fields
24
+ - vCard 3.0 export with ADR, URL, NICKNAME, TITLE, NOTE, X-SOCIALPROFILE
25
+ - `rubocop-rspec` plugin integration
26
+ - 100% line coverage across all 44 specs
27
+
28
+ ### Changed
29
+
30
+ - Refactored `SqliteParser` to use `RECORD_FIELD_MAP` constant for maintainability
31
+ - Refactored `CsvExporter` into `core_fields`/`extended_fields` for cleaner ABC metrics
32
+ - All specs comply with rubocop-rspec conventions
33
+
34
+ ## [0.1.1] - 2026-04-23
35
+
36
+ ### Fixed
37
+
38
+ - `require 'pathname'` missing in `archive.rb` causing `NameError` in isolation
39
+ - Added regression guard spec for file require isolation
40
+
41
+
14
42
  ## [0.1.0] - 2026-04-12
15
43
 
16
44
  ### Added
data/docs/ABBU.md CHANGED
@@ -44,20 +44,39 @@ AddressBook-v22.abcddb
44
44
 
45
45
  Key tables:
46
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`) |
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
+ | `ZABCDPOSTALADDRESS` | Street addresses (linked by `ZOWNER`) |
53
+ | `Z_ABCDCONTACTGROUP` | Group membership join table |
54
+ | `ZABCDURLADDRESS` | URLs (linked by `ZOWNER`) |
55
+ | `ZABCDNOTE` | Notes (linked by `ZCONTACT`) |
56
+ | `ZABCDRELATEDNAME` | Related names (linked by `ZOWNER`) |
57
+ | `ZABCDSOCIALPROFILE` | Social profiles (linked by `ZOWNER`) |
52
58
 
53
59
  Notable columns in `ZABCDRECORD`:
54
60
 
55
- | Column | Description |
56
- |-----------------|------------------|
57
- | `Z_PK` | Primary key |
58
- | `ZFIRSTNAME` | First name |
59
- | `ZLASTNAME` | Last name |
60
- | `ZORGANIZATION` | Company / org |
61
+ | Column | Description |
62
+ |--------------------------|--------------------------|
63
+ | `Z_PK` | Primary key |
64
+ | `Z_ENT` | Entity type (14=contact) |
65
+ | `ZFIRSTNAME` | First name |
66
+ | `ZLASTNAME` | Last name |
67
+ | `ZNICKNAME` | Nickname |
68
+ | `ZTITLE` | Prefix (e.g. "Dr.") |
69
+ | `ZSUFFIX` | Suffix (e.g. "Jr.") |
70
+ | `ZORGANIZATION` | Company / org |
71
+ | `ZJOBTITLE` | Job title |
72
+ | `ZDEPARTMENT` | Department |
73
+ | `ZMAIDENNAME` | Maiden name |
74
+ | `ZPHONETICFIRSTNAME` | Phonetic first name |
75
+ | `ZPHONETICLASTNAME` | Phonetic last name |
76
+ | `ZPHONETICORGANIZATION` | Phonetic company |
77
+ | `ZPRONOUNS` | Pronouns |
78
+ | `ZRINGTONE` | Ringtone |
79
+ | `ZTEXTTONE` | Text tone |
61
80
 
62
81
  ### 2. Plist / `.abcdp` (legacy macOS)
63
82
 
data/lib/abbu/contact.rb CHANGED
@@ -3,15 +3,24 @@
3
3
 
4
4
  module Abbu
5
5
  class Contact
6
- attr_accessor :first_name, :last_name, :emails, :phones, :company
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
7
9
 
8
10
  def initialize
9
11
  @emails = []
10
12
  @phones = []
13
+ @addresses = []
14
+ @groups = []
15
+ @urls = []
16
+ @notes = []
17
+ @related_names = []
18
+ @social_profiles = []
11
19
  end
12
20
 
13
21
  def full_name
14
- [first_name, last_name].compact.join(' ')
22
+ quoted_nickname = nickname ? "\"#{nickname}\"" : nil
23
+ [prefix, first_name, quoted_nickname, last_name, suffix].compact.join(' ')
15
24
  end
16
25
 
17
26
  def to_s
@@ -27,11 +27,45 @@ module Abbu
27
27
  private
28
28
 
29
29
  def headers
30
- %w[Name Email Phone Company]
30
+ %w[Name Email Phone Company Address Groups URLs Notes RelatedNames SocialProfiles]
31
31
  end
32
32
 
33
33
  def row(contact)
34
- [contact.full_name, contact.emails.first, contact.phones.first, contact.company]
34
+ core_fields(contact) + extended_fields(contact)
35
+ end
36
+
37
+ def core_fields(contact)
38
+ [
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(', ')
45
+ ]
46
+ end
47
+
48
+ def extended_fields(contact)
49
+ [
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)
54
+ ]
55
+ end
56
+
57
+ def format_address(addr)
58
+ return nil unless addr
59
+
60
+ [addr[:street], addr[:city], addr[:state], addr[:zip], addr[:country]].compact.join(', ')
61
+ end
62
+
63
+ def format_related_names(names)
64
+ names.map { |rn| "#{rn[:name]} (#{rn[:label]})" }.join(', ')
65
+ end
66
+
67
+ def format_social_profiles(profiles)
68
+ profiles.map { |sp| "#{sp[:username]} on #{sp[:service]}" }.join(', ')
35
69
  end
36
70
  end
37
71
  end
@@ -21,14 +21,29 @@ module Abbu
21
21
  private
22
22
 
23
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
24
+ @contacts.map { |c| contact_hash(c) }
25
+ end
26
+
27
+ def contact_hash(contact) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
28
+ {
29
+ name: contact.full_name,
30
+ first_name: contact.first_name,
31
+ last_name: contact.last_name,
32
+ nickname: contact.nickname,
33
+ prefix: contact.prefix,
34
+ suffix: contact.suffix,
35
+ company: contact.company,
36
+ job_title: contact.job_title,
37
+ department: contact.department,
38
+ emails: contact.emails,
39
+ phones: contact.phones,
40
+ addresses: contact.addresses,
41
+ groups: contact.groups,
42
+ urls: contact.urls,
43
+ notes: contact.notes,
44
+ related_names: contact.related_names,
45
+ social_profiles: contact.social_profiles
46
+ }.compact
32
47
  end
33
48
  end
34
49
  end
@@ -22,16 +22,55 @@ module Abbu
22
22
  @contacts.map { |c| vcard_for(c) }.join("\n")
23
23
  end
24
24
 
25
- def vcard_for(contact)
25
+ def vcard_for(contact) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
26
26
  lines = ['BEGIN:VCARD', 'VERSION:3.0']
27
27
  lines << "FN:#{contact.full_name}"
28
28
  lines << "N:#{contact.last_name};#{contact.first_name};;;"
29
+ lines << "NICKNAME:#{contact.nickname}" if contact.nickname
29
30
  lines << "ORG:#{contact.company}" if contact.company
30
- contact.emails.each { |e| lines << "EMAIL:#{e}" }
31
- contact.phones.each { |p| lines << "TEL:#{p}" }
31
+ lines << "TITLE:#{contact.job_title}" if contact.job_title
32
+ append_emails(lines, contact)
33
+ append_phones(lines, contact)
34
+ append_addresses(lines, contact)
35
+ append_urls(lines, contact)
36
+ append_social_profiles(lines, contact)
37
+ contact.notes.each { |n| lines << "NOTE:#{n}" }
32
38
  lines << 'END:VCARD'
33
39
  lines.join("\n")
34
40
  end
41
+
42
+ def append_emails(lines, contact)
43
+ contact.emails.each do |e|
44
+ label = e[:label] || 'INTERNET'
45
+ lines << "EMAIL;TYPE=#{label}:#{e[:address]}"
46
+ end
47
+ end
48
+
49
+ def append_phones(lines, contact)
50
+ contact.phones.each do |p|
51
+ label = p[:label] || 'VOICE'
52
+ lines << "TEL;TYPE=#{label}:#{p[:number]}"
53
+ end
54
+ end
55
+
56
+ def append_addresses(lines, contact)
57
+ contact.addresses.each do |a|
58
+ label = a[:label] || 'HOME'
59
+ lines << "ADR;TYPE=#{label}:;;#{a[:street]};#{a[:city]};#{a[:state]};#{a[:zip]};#{a[:country]}"
60
+ end
61
+ end
62
+
63
+ def append_urls(lines, contact)
64
+ contact.urls.each do |u|
65
+ lines << "URL:#{u[:url]}"
66
+ end
67
+ end
68
+
69
+ def append_social_profiles(lines, contact)
70
+ contact.social_profiles.each do |sp|
71
+ lines << "X-SOCIALPROFILE;TYPE=#{sp[:service]}:#{sp[:username]}"
72
+ end
73
+ end
35
74
  end
36
75
  end
37
76
  end
@@ -6,7 +6,21 @@ require_relative '../contact'
6
6
 
7
7
  module Abbu
8
8
  module Parsers
9
- class SqliteParser
9
+ class SqliteParser # rubocop:disable Metrics/ClassLength
10
+ # Column-name → attr_accessor mapping for flat fields on ZABCDRECORD
11
+ RECORD_FIELD_MAP = {
12
+ 'ZFIRSTNAME' => :first_name, 'ZLASTNAME' => :last_name,
13
+ 'ZNICKNAME' => :nickname, 'ZTITLE' => :prefix,
14
+ 'ZSUFFIX' => :suffix, 'ZORGANIZATION' => :company,
15
+ 'ZJOBTITLE' => :job_title, 'ZDEPARTMENT' => :department,
16
+ 'ZMAIDENNAME' => :maiden_name,
17
+ 'ZPHONETICFIRSTNAME' => :phonetic_first_name,
18
+ 'ZPHONETICLASTNAME' => :phonetic_last_name,
19
+ 'ZPHONETICORGANIZATION' => :phonetic_company,
20
+ 'ZPRONOUNS' => :pronouns,
21
+ 'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone
22
+ }.freeze
23
+
10
24
  def initialize(db_paths)
11
25
  @db_paths = Array(db_paths)
12
26
  end
@@ -28,32 +42,111 @@ module Abbu
28
42
  end
29
43
 
30
44
  def records(db)
31
- db.execute('SELECT * FROM ZABCDRECORD')
45
+ # Exclude groups (typically Z_ENT = 15 in this schema version)
46
+ db.execute('SELECT * FROM ZABCDRECORD WHERE Z_ENT != 15')
32
47
  end
33
48
 
34
49
  def emails_for(db, record_id)
35
50
  db.execute(
36
- 'SELECT ZADDRESSNORMALIZED FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
51
+ 'SELECT ZADDRESSNORMALIZED, ZLABEL FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
37
52
  record_id
38
- ).filter_map { |row| row['ZADDRESSNORMALIZED'] }
53
+ ).map { |row| { address: row['ZADDRESSNORMALIZED'], label: row['ZLABEL'] } }
39
54
  end
40
55
 
41
56
  def phones_for(db, record_id)
42
57
  db.execute(
43
- 'SELECT ZFULLNUMBER FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
58
+ 'SELECT ZFULLNUMBER, ZLABEL FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
59
+ record_id
60
+ ).map { |row| { number: row['ZFULLNUMBER'], label: row['ZLABEL'] } }
61
+ end
62
+
63
+ def addresses_for(db, record_id) # rubocop:disable Metrics/MethodLength
64
+ db.execute(
65
+ 'SELECT ZSTREET, ZCITY, ZSTATE, ZZIPCODE, ZCOUNTRYNAME, ZLABEL FROM ZABCDPOSTALADDRESS WHERE ZOWNER = ?',
44
66
  record_id
45
- ).filter_map { |row| row['ZFULLNUMBER'] }
67
+ ).map do |row|
68
+ {
69
+ street: row['ZSTREET'],
70
+ city: row['ZCITY'],
71
+ state: row['ZSTATE'],
72
+ zip: row['ZZIPCODE'],
73
+ country: row['ZCOUNTRYNAME'],
74
+ label: row['ZLABEL']
75
+ }
76
+ end
77
+ end
78
+
79
+ def groups_for(db, record_id)
80
+ query = <<-SQL
81
+ SELECT g.ZFIRSTNAME
82
+ FROM Z_ABCDCONTACTGROUP j
83
+ JOIN ZABCDRECORD g ON j.Z_GROUP = g.Z_PK
84
+ WHERE j.Z_CONTACT = ?
85
+ SQL
86
+ db.execute(query, record_id).map { |row| row['ZFIRSTNAME'] }
87
+ rescue SQLite3::SQLException
88
+ []
89
+ end
90
+
91
+ def urls_for(db, record_id)
92
+ db.execute(
93
+ 'SELECT ZURL, ZLABEL FROM ZABCDURLADDRESS WHERE ZOWNER = ?',
94
+ record_id
95
+ ).map { |row| { url: row['ZURL'], label: row['ZLABEL'] } }
96
+ rescue SQLite3::SQLException
97
+ []
98
+ end
99
+
100
+ def notes_for(db, record_id)
101
+ db.execute(
102
+ 'SELECT ZTEXT FROM ZABCDNOTE WHERE ZCONTACT = ?',
103
+ record_id
104
+ ).filter_map { |row| row['ZTEXT'] }
105
+ rescue SQLite3::SQLException
106
+ []
107
+ end
108
+
109
+ def related_names_for(db, record_id)
110
+ db.execute(
111
+ 'SELECT ZNAME, ZLABEL FROM ZABCDRELATEDNAME WHERE ZOWNER = ?',
112
+ record_id
113
+ ).map { |row| { name: row['ZNAME'], label: row['ZLABEL'] } }
114
+ rescue SQLite3::SQLException
115
+ []
116
+ end
117
+
118
+ def social_profiles_for(db, record_id)
119
+ db.execute(
120
+ 'SELECT ZSERVICENAME, ZUSERNAME FROM ZABCDSOCIALPROFILE WHERE ZOWNER = ?',
121
+ record_id
122
+ ).map { |row| { service: row['ZSERVICENAME'], username: row['ZUSERNAME'] } }
123
+ rescue SQLite3::SQLException
124
+ []
46
125
  end
47
126
 
48
127
  def build_contact(db, row)
49
128
  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'])
129
+ assign_flat_fields(contact, row)
130
+ assign_relational_fields(contact, db, row['Z_PK'])
55
131
  contact
56
132
  end
133
+
134
+ def assign_flat_fields(contact, row)
135
+ RECORD_FIELD_MAP.each do |column, attr|
136
+ contact.public_send(:"#{attr}=", row[column])
137
+ end
138
+ end
139
+
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)
149
+ end
57
150
  end
58
151
  end
59
152
  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.1'
5
+ VERSION = '0.1.2'
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.1
4
+ version: 0.1.2
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-24 00:00:00.000000000 Z
11
+ date: 2026-04-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -150,6 +150,20 @@ dependencies:
150
150
  - - "~>"
151
151
  - !ruby/object:Gem::Version
152
152
  version: '0.6'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop-rspec
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '3.0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '3.0'
153
167
  - !ruby/object:Gem::Dependency
154
168
  name: simplecov
155
169
  requirement: !ruby/object:Gem::Requirement