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 +4 -4
- data/README.md +10 -12
- data/docs/ABBU.md +30 -11
- data/{CHANGELOG.md → docs/CHANGELOG.md} +28 -0
- data/docs/CONTRIBUTING.md +51 -0
- data/docs/TODO.md +172 -0
- data/lib/abbu/archive.rb +3 -3
- data/lib/abbu/contact.rb +18 -2
- data/lib/abbu/exporters/csv_exporter.rb +50 -2
- data/lib/abbu/exporters/json_exporter.rb +33 -8
- data/lib/abbu/exporters/vcard_exporter.rb +111 -6
- data/lib/abbu/parsers/plist_parser.rb +142 -4
- data/lib/abbu/parsers/sqlite_parser.rb +136 -11
- data/lib/abbu/version.rb +1 -1
- metadata +48 -4
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
|
|
data/docs/ABBU.md
CHANGED
|
@@ -44,20 +44,39 @@ AddressBook-v22.abcddb
|
|
|
44
44
|
|
|
45
45
|
Key tables:
|
|
46
46
|
|
|
47
|
-
| Table
|
|
48
|
-
|
|
49
|
-
| `ZABCDRECORD`
|
|
50
|
-
| `ZABCDEMAILADDRESS`
|
|
51
|
-
| `ZABCDPHONENUMBER`
|
|
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
|
|
56
|
-
|
|
57
|
-
| `Z_PK`
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
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
|
|
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,15 +3,31 @@
|
|
|
3
3
|
|
|
4
4
|
module Abbu
|
|
5
5
|
class Contact
|
|
6
|
-
attr_accessor :first_name, :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
28
|
-
lines
|
|
29
|
-
lines
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
).
|
|
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
|
-
).
|
|
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
|
|
51
|
-
contact
|
|
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
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
|
|
@@ -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: []
|