abbu 0.1.1 → 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: 50bca77bc23aaa22b0ba903a7a209158464bf8c9e7599723e7a3252708dd8338
4
- data.tar.gz: a42bdb76e5acfd9f99c9c9bc0a53fa797cf093ff3e4f18e4441eeee06ad65322
3
+ metadata.gz: 9926de7b6edc99155f0ddf18c39ebfab80677152caeba28bc7b2bce8b0e870bd
4
+ data.tar.gz: 8c7c8224f7799343d8ac994e4c21c2185b30f53aa8bd87783c5423f2ee09d1d8
5
5
  SHA512:
6
- metadata.gz: 3fcc9599424ce7d5577aa546ed42ab760efcdb45ea292ceb7a4e83404fd4726667df0b34020ed8f8dcbfdd21a73ed408bde38a0f8feb565bd0114fc44559433e
7
- data.tar.gz: 90b0de3e8cf215467525b9e9520dc0af50c3cb40ae1a2c46ee5076663a3571b5e89f6a79e3fe96d52d12789e743703a23e90ccfac23ad6ae2d9a7cd4b9312905
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
 
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
 
@@ -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
@@ -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,15 +3,31 @@
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, :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
7
14
 
8
15
  def initialize
9
16
  @emails = []
10
17
  @phones = []
18
+ @addresses = []
19
+ @groups = []
20
+ @urls = []
21
+ @notes = []
22
+ @related_names = []
23
+ @social_profiles = []
24
+ @dates = []
25
+ @instant_messages = []
11
26
  end
12
27
 
13
28
  def full_name
14
- [first_name, last_name].compact.join(' ')
29
+ quoted_nickname = nickname ? "\"#{nickname}\"" : nil
30
+ [prefix, first_name, middle_name, quoted_nickname, last_name, suffix].compact.join(' ')
15
31
  end
16
32
 
17
33
  def to_s
@@ -27,11 +27,59 @@ module Abbu
27
27
  private
28
28
 
29
29
  def headers
30
- %w[Name Email Phone Company]
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)
34
- [contact.full_name, contact.emails.first, contact.phones.first, contact.company]
35
+ core_fields(contact) + extended_fields(contact)
36
+ end
37
+
38
+ def core_fields(contact)
39
+ [
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(', ')
44
+ ]
45
+ end
46
+
47
+ def extended_fields(contact) # rubocop:disable Metrics/AbcSize
48
+ [
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
+ ]
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(', ')
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
35
83
  end
36
84
  end
37
85
  end
@@ -21,14 +21,39 @@ 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
+ middle_name: contact.middle_name,
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,
36
+ nickname: contact.nickname,
37
+ prefix: contact.prefix,
38
+ suffix: contact.suffix,
39
+ company: contact.company,
40
+ job_title: contact.job_title,
41
+ department: contact.department,
42
+ emails: contact.emails,
43
+ phones: contact.phones,
44
+ addresses: contact.addresses,
45
+ groups: contact.groups,
46
+ urls: contact.urls,
47
+ notes: contact.notes,
48
+ related_names: contact.related_names,
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
56
+ }.compact
32
57
  end
33
58
  end
34
59
  end
@@ -22,16 +22,121 @@ 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/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 << "ORG:#{contact.company}" if contact.company
30
- contact.emails.each { |e| lines << "EMAIL:#{e}" }
31
- contact.phones.each { |p| lines << "TEL:#{p}" }
27
+
28
+ append_name_fields(lines, contact)
29
+ append_emails(lines, contact)
30
+ append_phones(lines, contact)
31
+ append_addresses(lines, contact)
32
+ append_urls(lines, contact)
33
+ append_social_profiles(lines, contact)
34
+ append_dates(lines, contact)
35
+ append_instant_messages(lines, contact)
36
+ append_verification_code(lines, contact)
37
+ append_notes(lines, contact)
38
+
32
39
  lines << 'END:VCARD'
33
40
  lines.join("\n")
34
41
  end
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
+
82
+ def append_emails(lines, contact)
83
+ contact.emails.each do |e|
84
+ label = e[:label] || 'INTERNET'
85
+ lines << "EMAIL;TYPE=#{label}:#{e[:address]}"
86
+ end
87
+ end
88
+
89
+ def append_phones(lines, contact)
90
+ contact.phones.each do |p|
91
+ label = p[:label] || 'VOICE'
92
+ lines << "TEL;TYPE=#{label}:#{p[:number]}"
93
+ end
94
+ end
95
+
96
+ def append_addresses(lines, contact)
97
+ contact.addresses.each do |a|
98
+ label = a[:label] || 'HOME'
99
+ lines << "ADR;TYPE=#{label}:;;#{a[:street]};#{a[:city]};#{a[:state]};#{a[:zip]};#{a[:country]}"
100
+ end
101
+ end
102
+
103
+ def append_urls(lines, contact)
104
+ contact.urls.each do |u|
105
+ lines << "URL:#{u[:url]}"
106
+ end
107
+ end
108
+
109
+ def append_social_profiles(lines, contact)
110
+ contact.social_profiles.each do |sp|
111
+ lines << "X-SOCIALPROFILE;TYPE=#{sp[:service]}:#{sp[:username]}"
112
+ end
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
35
140
  end
36
141
  end
37
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
@@ -6,7 +6,24 @@ 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, 'ZMIDDLENAME' => :middle_name,
13
+ 'ZLASTNAME' => :last_name,
14
+ 'ZNICKNAME' => :nickname, 'ZTITLE' => :prefix,
15
+ 'ZSUFFIX' => :suffix, 'ZORGANIZATION' => :company,
16
+ 'ZJOBTITLE' => :job_title, 'ZDEPARTMENT' => :department,
17
+ 'ZMAIDENNAME' => :maiden_name,
18
+ 'ZPHONETICFIRSTNAME' => :phonetic_first_name,
19
+ 'ZPHONETICMIDDLENAME' => :phonetic_middle_name,
20
+ 'ZPHONETICLASTNAME' => :phonetic_last_name,
21
+ 'ZPHONETICORGANIZATION' => :phonetic_company,
22
+ 'ZPRONOUNS' => :pronouns,
23
+ 'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone,
24
+ 'ZVERIFICATIONCODE' => :verification_code
25
+ }.freeze
26
+
10
27
  def initialize(db_paths)
11
28
  @db_paths = Array(db_paths)
12
29
  end
@@ -28,32 +45,140 @@ module Abbu
28
45
  end
29
46
 
30
47
  def records(db)
31
- db.execute('SELECT * FROM ZABCDRECORD')
48
+ # Exclude groups (typically Z_ENT = 15 in this schema version)
49
+ db.execute('SELECT * FROM ZABCDRECORD WHERE Z_ENT != 15')
32
50
  end
33
51
 
34
52
  def emails_for(db, record_id)
35
53
  db.execute(
36
- 'SELECT ZADDRESSNORMALIZED FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
54
+ 'SELECT ZADDRESSNORMALIZED, ZLABEL FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
37
55
  record_id
38
- ).filter_map { |row| row['ZADDRESSNORMALIZED'] }
56
+ ).map { |row| { address: row['ZADDRESSNORMALIZED'], label: row['ZLABEL'] } }
39
57
  end
40
58
 
41
59
  def phones_for(db, record_id)
42
60
  db.execute(
43
- 'SELECT ZFULLNUMBER FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
61
+ 'SELECT ZFULLNUMBER, ZLABEL FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
62
+ record_id
63
+ ).map { |row| { number: row['ZFULLNUMBER'], label: row['ZLABEL'] } }
64
+ end
65
+
66
+ def addresses_for(db, record_id) # rubocop:disable Metrics/MethodLength
67
+ db.execute(
68
+ 'SELECT ZSTREET, ZCITY, ZSTATE, ZZIPCODE, ZCOUNTRYNAME, ZLABEL FROM ZABCDPOSTALADDRESS WHERE ZOWNER = ?',
69
+ record_id
70
+ ).map do |row|
71
+ {
72
+ street: row['ZSTREET'],
73
+ city: row['ZCITY'],
74
+ state: row['ZSTATE'],
75
+ zip: row['ZZIPCODE'],
76
+ country: row['ZCOUNTRYNAME'],
77
+ label: row['ZLABEL']
78
+ }
79
+ end
80
+ end
81
+
82
+ def groups_for(db, record_id)
83
+ query = <<-SQL
84
+ SELECT g.ZFIRSTNAME
85
+ FROM Z_ABCDCONTACTGROUP j
86
+ JOIN ZABCDRECORD g ON j.Z_GROUP = g.Z_PK
87
+ WHERE j.Z_CONTACT = ?
88
+ SQL
89
+ db.execute(query, record_id).map { |row| row['ZFIRSTNAME'] }
90
+ rescue SQLite3::SQLException
91
+ []
92
+ end
93
+
94
+ def urls_for(db, record_id)
95
+ db.execute(
96
+ 'SELECT ZURL, ZLABEL FROM ZABCDURLADDRESS WHERE ZOWNER = ?',
97
+ record_id
98
+ ).map { |row| { url: row['ZURL'], label: row['ZLABEL'] } }
99
+ rescue SQLite3::SQLException
100
+ []
101
+ end
102
+
103
+ def notes_for(db, record_id)
104
+ db.execute(
105
+ 'SELECT ZTEXT FROM ZABCDNOTE WHERE ZCONTACT = ?',
106
+ record_id
107
+ ).filter_map { |row| row['ZTEXT'] }
108
+ rescue SQLite3::SQLException
109
+ []
110
+ end
111
+
112
+ def related_names_for(db, record_id)
113
+ db.execute(
114
+ 'SELECT ZNAME, ZLABEL FROM ZABCDRELATEDNAME WHERE ZOWNER = ?',
44
115
  record_id
45
- ).filter_map { |row| row['ZFULLNUMBER'] }
116
+ ).map { |row| { name: row['ZNAME'], label: row['ZLABEL'] } }
117
+ rescue SQLite3::SQLException
118
+ []
119
+ end
120
+
121
+ def social_profiles_for(db, record_id)
122
+ db.execute(
123
+ 'SELECT ZSERVICENAME, ZUSERNAME FROM ZABCDSOCIALPROFILE WHERE ZOWNER = ?',
124
+ record_id
125
+ ).map { |row| { service: row['ZSERVICENAME'], username: row['ZUSERNAME'] } }
126
+ rescue SQLite3::SQLException
127
+ []
128
+ end
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
+ []
46
150
  end
47
151
 
48
152
  def build_contact(db, row)
49
153
  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'])
154
+ assign_flat_fields(contact, row)
155
+ assign_relational_fields(contact, db, row['Z_PK'])
55
156
  contact
56
157
  end
158
+
159
+ def assign_flat_fields(contact, row)
160
+ RECORD_FIELD_MAP.each do |column, attr|
161
+ contact.public_send(:"#{attr}=", row[column])
162
+ end
163
+ end
164
+
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>!$_' }
181
+ end
57
182
  end
58
183
  end
59
184
  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.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.1
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-24 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
@@ -150,6 +178,20 @@ dependencies:
150
178
  - - "~>"
151
179
  - !ruby/object:Gem::Version
152
180
  version: '0.6'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rubocop-rspec
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '3.0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '3.0'
153
195
  - !ruby/object:Gem::Dependency
154
196
  name: simplecov
155
197
  requirement: !ruby/object:Gem::Requirement
@@ -174,7 +216,6 @@ executables:
174
216
  extensions: []
175
217
  extra_rdoc_files: []
176
218
  files:
177
- - CHANGELOG.md
178
219
  - LICENSE
179
220
  - README.md
180
221
  - bin/abbu
@@ -185,6 +226,9 @@ files:
185
226
  - bin/outdated
186
227
  - bin/test
187
228
  - docs/ABBU.md
229
+ - docs/CHANGELOG.md
230
+ - docs/CONTRIBUTING.md
231
+ - docs/TODO.md
188
232
  - examples/deduplicate_contacts.rb
189
233
  - examples/export_to_api.rb
190
234
  - examples/export_to_csv.rb
@@ -210,7 +254,7 @@ metadata:
210
254
  allowed_push_host: https://rubygems.org
211
255
  homepage_uri: https://github.com/scarver2/abbu
212
256
  source_code_uri: https://github.com/scarver2/abbu
213
- changelog_uri: https://github.com/scarver2/abbu/blob/master/CHANGELOG.md
257
+ changelog_uri: https://github.com/scarver2/abbu/blob/master/docs/CHANGELOG.md
214
258
  rubygems_mfa_required: 'true'
215
259
  post_install_message:
216
260
  rdoc_options: []