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 +4 -4
- data/README.md +10 -12
- data/docs/CONTRIBUTING.md +51 -0
- data/docs/TODO.md +172 -0
- data/lib/abbu/archive.rb +3 -3
- data/lib/abbu/contact.rb +11 -4
- data/lib/abbu/exporters/csv_exporter.rb +26 -12
- data/lib/abbu/exporters/json_exporter.rb +11 -1
- data/lib/abbu/exporters/vcard_exporter.rb +73 -7
- data/lib/abbu/parsers/plist_parser.rb +142 -4
- data/lib/abbu/parsers/sqlite_parser.rb +43 -11
- data/lib/abbu/version.rb +1 -1
- metadata +34 -4
- /data/{CHANGELOG.md → docs/CHANGELOG.md} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9926de7b6edc99155f0ddf18c39ebfab80677152caeba28bc7b2bce8b0e870bd
|
|
4
|
+
data.tar.gz: 8c7c8224f7799343d8ac994e4c21c2185b30f53aa8bd87783c5423f2ee09d1d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
12
|
-
-
|
|
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
|
-
|
|
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
|
|
37
|
-
@
|
|
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(
|
|
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, :
|
|
7
|
-
:
|
|
8
|
-
:
|
|
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.
|
|
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.
|
|
52
|
-
|
|
53
|
-
|
|
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/
|
|
25
|
+
def vcard_for(contact) # rubocop:disable Metrics/MethodLength
|
|
26
26
|
lines = ['BEGIN:VCARD', 'VERSION:3.0']
|
|
27
|
-
|
|
28
|
-
lines
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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, '
|
|
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
|
|
142
|
-
contact.phones
|
|
143
|
-
contact.addresses
|
|
144
|
-
contact.groups
|
|
145
|
-
contact.urls
|
|
146
|
-
contact.notes
|
|
147
|
-
contact.related_names
|
|
148
|
-
contact.social_profiles
|
|
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
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.
|
|
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-
|
|
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
|