abbu 0.1.2 → 0.4.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 +127 -17
- data/bin/abbu +185 -54
- data/docs/ABBU.md +226 -7
- data/docs/CHANGELOG.md +193 -0
- data/docs/CONTRIBUTING.md +61 -0
- data/docs/TODO.md +180 -0
- data/lib/abbu/archive.rb +68 -8
- data/lib/abbu/contact.rb +13 -4
- data/lib/abbu/diagnostic.rb +27 -0
- data/lib/abbu/exporters/csv_exporter.rb +26 -12
- data/lib/abbu/exporters/json_exporter.rb +17 -1
- data/lib/abbu/exporters/vcard_exporter.rb +91 -7
- data/lib/abbu/image_extractor.rb +134 -0
- data/lib/abbu/live_store.rb +104 -0
- data/lib/abbu/parse_error.rb +13 -0
- data/lib/abbu/parsers/plist_parser.rb +172 -4
- data/lib/abbu/parsers/sqlite_parser.rb +158 -55
- data/lib/abbu/query.rb +73 -0
- data/lib/abbu/schema_inspector.rb +133 -0
- data/lib/abbu/utils/contact_identity.rb +97 -0
- data/lib/abbu/utils/deduplicator.rb +130 -2
- data/lib/abbu/utils/image_resolver.rb +59 -0
- data/lib/abbu/utils/label_normalizer.rb +15 -0
- data/lib/abbu/utils/source_descriptor.rb +35 -0
- data/lib/abbu/version.rb +1 -1
- data/lib/abbu.rb +14 -2
- data/tasks/abbu.rake +1 -1
- metadata +50 -16
- data/CHANGELOG.md +0 -67
- data/bin/cleanse +0 -7
- data/bin/console +0 -10
- data/bin/dev +0 -7
- data/bin/lint +0 -7
- data/bin/outdated +0 -7
- data/bin/test +0 -7
data/docs/TODO.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
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 (Released)
|
|
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 Resolution (Released)
|
|
106
|
+
|
|
107
|
+
- [x] Resolve contact photos through observed `ZIMAGEURI` relationships
|
|
108
|
+
- [x] `Contact#image_uri` and `Contact#image_path` accessors
|
|
109
|
+
- [x] Root and nested source image discovery
|
|
110
|
+
- [x] CSV / JSON image paths and vCard PHOTO file URI references
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## v0.4.0 — Read-only Contacts Toolkit
|
|
115
|
+
|
|
116
|
+
Implemented in the accepted #19–#25 tranche and earlier prerequisites; publication
|
|
117
|
+
is gated by [release issue #27](https://github.com/scarver2/abbu/issues/27),
|
|
118
|
+
Deputy exact-head review, and Sheriff release authority.
|
|
119
|
+
|
|
120
|
+
- [x] Evidence-safe schema introspection and `--schema`
|
|
121
|
+
- [x] Lossless Apple label normalization with `raw_label` and anniversary vCard fidelity
|
|
122
|
+
- [x] Creation/modification timestamps and source provenance
|
|
123
|
+
- [x] Tolerant structured diagnostics, per-database/table deduplication, and strict mode
|
|
124
|
+
- [x] Provenance-aware identity suggestions, phone comparison, and explicit merge-policy boundary
|
|
125
|
+
- [x] Source-aware duplicate image-stem resolution
|
|
126
|
+
- [x] First-class image extraction with content detection, safe filenames, and no destination overwrites
|
|
127
|
+
- [x] Chainable query API, exact email/phone lookup, and partial name/email search
|
|
128
|
+
- [x] TSV and stable JSON CLI search output
|
|
129
|
+
- [x] Explicit read-only live macOS Contacts input through `--live` / `--live-path PATH`
|
|
130
|
+
- [x] Ruby 3.3 minimum and lint/tooling gate; Ruby 3.4 and 4.0 CI
|
|
131
|
+
- [x] Pull-request CI plus canonical-main pushes, without duplicate feature-branch runs
|
|
132
|
+
- [x] `AGENTS.md`, local skills, and canonical spec/lint/package workflows
|
|
133
|
+
|
|
134
|
+
## Pre-1.0 Backlog — Not Included in 0.4.0
|
|
135
|
+
|
|
136
|
+
These remain future work; no implementation is authorized by release preparation.
|
|
137
|
+
Version assignments below 1.0 are intentionally deferred until scopes are accepted.
|
|
138
|
+
|
|
139
|
+
- [ ] [#28](https://github.com/scarver2/abbu/issues/28): synthetic WAL, sidecar, and concurrent-writer validation
|
|
140
|
+
- [ ] [#29](https://github.com/scarver2/abbu/issues/29): stronger Apple-compatible vCard fidelity and round trips
|
|
141
|
+
- [ ] [#30](https://github.com/scarver2/abbu/issues/30): embedded vCard photos (currently file URI references)
|
|
142
|
+
- [ ] [#31](https://github.com/scarver2/abbu/issues/31): first-class read-only source objects
|
|
143
|
+
- [ ] [#32](https://github.com/scarver2/abbu/issues/32): first-class groups and membership queries
|
|
144
|
+
- [ ] [#33](https://github.com/scarver2/abbu/issues/33): evidence-backed My Card identification
|
|
145
|
+
- [ ] [#34](https://github.com/scarver2/abbu/issues/34): modified-since and date-range queries
|
|
146
|
+
- [ ] [#35](https://github.com/scarver2/abbu/issues/35): evidence-backed save/edit history
|
|
147
|
+
- [ ] [#36](https://github.com/scarver2/abbu/issues/36): alternate-calendar and lunar metadata research
|
|
148
|
+
- [ ] [#37](https://github.com/scarver2/abbu/issues/37): explainable fuzzy names and configurable thresholds
|
|
149
|
+
- [ ] [#38](https://github.com/scarver2/abbu/issues/38): merge plans, side-by-side evidence, and safe built-in policies
|
|
150
|
+
- [ ] [#39](https://github.com/scarver2/abbu/issues/39): broader machine-readable JSON CLI contract
|
|
151
|
+
- [ ] [#40](https://github.com/scarver2/abbu/issues/40): streaming contact iteration/export and large-store performance
|
|
152
|
+
- [ ] [#41](https://github.com/scarver2/abbu/issues/41): portable SQLite export
|
|
153
|
+
- [ ] [#42](https://github.com/scarver2/abbu/issues/42): birthday/anniversary iCalendar export
|
|
154
|
+
- [ ] [#43](https://github.com/scarver2/abbu/issues/43): identity-aware snapshot diffs
|
|
155
|
+
- [ ] [#44](https://github.com/scarver2/abbu/issues/44): history across snapshot directories
|
|
156
|
+
- [ ] [#45](https://github.com/scarver2/abbu/issues/45): optional MCP/agent adapter over read-only interfaces
|
|
157
|
+
- [ ] [#46](https://github.com/scarver2/abbu/issues/46): evidence-backed new-ABBU writer research
|
|
158
|
+
- [ ] [#47](https://github.com/scarver2/abbu/issues/47): public API stability checklist
|
|
159
|
+
- [ ] Thumbnail/full-size image selection after reproducible format evidence
|
|
160
|
+
- [ ] Region filtering and an explicit CLI filter grammar
|
|
161
|
+
|
|
162
|
+
Previous in-place `Archive#deduplicate!` / source-archive mutation proposals are
|
|
163
|
+
superseded. Merge plans must preserve originals; any future writer targets a new
|
|
164
|
+
output bundle and requires evidence-backed round-trip validation. Writing live
|
|
165
|
+
Contacts databases is not part of the roadmap.
|
|
166
|
+
|
|
167
|
+
## v1.0.0 — Future Stable API
|
|
168
|
+
|
|
169
|
+
- [ ] Complete the API-stability checklist and acceptance evidence before promising stability
|
|
170
|
+
- [ ] Comprehensive public API documentation
|
|
171
|
+
- [ ] Benchmarks for large archives (10k+ contacts)
|
|
172
|
+
- [ ] Optional external CRM adapters (Printavo, HubSpot, generic webhook/API), separately scoped
|
|
173
|
+
|
|
174
|
+
See [README](../README.md) and [CHANGELOG](CHANGELOG.md) for current behavior
|
|
175
|
+
and historical releases.
|
|
176
|
+
|
|
177
|
+
—
|
|
178
|
+
Stan Carver II
|
|
179
|
+
Made in Texas 🤠
|
|
180
|
+
https://stancarver.com
|
data/lib/abbu/archive.rb
CHANGED
|
@@ -2,26 +2,62 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'pathname'
|
|
5
|
-
require_relative '
|
|
5
|
+
require_relative 'diagnostic'
|
|
6
|
+
require_relative 'image_extractor'
|
|
7
|
+
require_relative 'parse_error'
|
|
6
8
|
require_relative 'parsers/plist_parser'
|
|
9
|
+
require_relative 'parsers/sqlite_parser'
|
|
10
|
+
require_relative 'query'
|
|
11
|
+
require_relative 'schema_inspector'
|
|
12
|
+
require_relative 'utils/image_resolver'
|
|
7
13
|
|
|
8
14
|
module Abbu
|
|
9
15
|
class Archive
|
|
10
|
-
attr_reader :path
|
|
16
|
+
attr_reader :diagnostics, :path
|
|
11
17
|
|
|
12
|
-
def initialize(path)
|
|
18
|
+
def initialize(path, strict: false)
|
|
13
19
|
@path = Pathname.new(path)
|
|
20
|
+
@strict = strict
|
|
21
|
+
@diagnostics = []
|
|
14
22
|
validate!
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
def contacts
|
|
18
|
-
parser.contacts
|
|
26
|
+
@contacts ||= parser.contacts.tap { |cs| attach_images(cs) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def query
|
|
30
|
+
Query.new(contacts)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def where(criteria)
|
|
34
|
+
query.where(criteria)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search(term)
|
|
38
|
+
query.search(term)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_by_email(email)
|
|
42
|
+
query.find_by_email(email)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_by_phone(phone)
|
|
46
|
+
query.find_by_phone(phone)
|
|
19
47
|
end
|
|
20
48
|
|
|
21
49
|
def sqlite?
|
|
22
50
|
db_paths.any?
|
|
23
51
|
end
|
|
24
52
|
|
|
53
|
+
def extract_images(output_dir)
|
|
54
|
+
ImageExtractor.new(contacts).extract(output_dir)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def schema_report
|
|
58
|
+
SchemaInspector.new(db_paths, root_path: @path).report
|
|
59
|
+
end
|
|
60
|
+
|
|
25
61
|
private
|
|
26
62
|
|
|
27
63
|
def validate!
|
|
@@ -33,16 +69,40 @@ module Abbu
|
|
|
33
69
|
@db_paths ||= @path.glob('**/*.abcddb')
|
|
34
70
|
end
|
|
35
71
|
|
|
36
|
-
def
|
|
37
|
-
@
|
|
72
|
+
def plist_paths
|
|
73
|
+
@plist_paths ||= @path.glob('**/*.abcdp').sort
|
|
38
74
|
end
|
|
39
75
|
|
|
40
76
|
def parser
|
|
41
77
|
if sqlite?
|
|
42
|
-
Parsers::SqliteParser.new(db_paths)
|
|
78
|
+
Parsers::SqliteParser.new(db_paths, root_path: @path, diagnostics: diagnostics, strict: @strict)
|
|
43
79
|
else
|
|
44
|
-
Parsers::PlistParser.new(
|
|
80
|
+
Parsers::PlistParser.new(plist_paths, root_path: @path, diagnostics: diagnostics, strict: @strict)
|
|
45
81
|
end
|
|
46
82
|
end
|
|
83
|
+
|
|
84
|
+
def attach_images(contacts)
|
|
85
|
+
return if contacts.empty?
|
|
86
|
+
|
|
87
|
+
resolver = Utils::ImageResolver.new(@path)
|
|
88
|
+
contacts.each do |contact|
|
|
89
|
+
next unless contact.image_uri
|
|
90
|
+
|
|
91
|
+
contact.image_path = resolver.resolve(contact.image_uri, source: contact.source)
|
|
92
|
+
record_missing_image(contact) unless contact.image_path
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_missing_image(contact)
|
|
97
|
+
diagnostic = Diagnostic.new(
|
|
98
|
+
category: :missing_image,
|
|
99
|
+
message: 'Referenced contact image was not found',
|
|
100
|
+
parser: :archive,
|
|
101
|
+
source: contact.source&.fetch(:path, @path.to_s) || @path.to_s,
|
|
102
|
+
context: {}
|
|
103
|
+
)
|
|
104
|
+
diagnostics << diagnostic
|
|
105
|
+
raise ParseError, diagnostic if @strict
|
|
106
|
+
end
|
|
47
107
|
end
|
|
48
108
|
end
|
data/lib/abbu/contact.rb
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
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,
|
|
14
|
+
:image_uri, :image_path,
|
|
15
|
+
:created_at, :modified_at, :source
|
|
9
16
|
|
|
10
17
|
def initialize
|
|
11
18
|
@emails = []
|
|
@@ -16,11 +23,13 @@ module Abbu
|
|
|
16
23
|
@notes = []
|
|
17
24
|
@related_names = []
|
|
18
25
|
@social_profiles = []
|
|
26
|
+
@dates = []
|
|
27
|
+
@instant_messages = []
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
def full_name
|
|
22
31
|
quoted_nickname = nickname ? "\"#{nickname}\"" : nil
|
|
23
|
-
[prefix, first_name, quoted_nickname, last_name, suffix].compact.join(' ')
|
|
32
|
+
[prefix, first_name, middle_name, quoted_nickname, last_name, suffix].compact.join(' ')
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def to_s
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# lib/abbu/diagnostic.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Abbu
|
|
5
|
+
class Diagnostic
|
|
6
|
+
attr_reader :category, :context, :message, :parser, :source
|
|
7
|
+
|
|
8
|
+
def initialize(category:, message:, parser:, source:, context: {})
|
|
9
|
+
@category = category.to_sym
|
|
10
|
+
@context = context.freeze
|
|
11
|
+
@message = message.to_s.freeze
|
|
12
|
+
@parser = parser.to_sym
|
|
13
|
+
@source = source.to_s.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
category: category,
|
|
20
|
+
message: message,
|
|
21
|
+
parser: parser,
|
|
22
|
+
source: source,
|
|
23
|
+
context: context
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -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 ImagePath]
|
|
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), contact.image_path.to_s
|
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'time'
|
|
5
6
|
|
|
6
7
|
module Abbu
|
|
7
8
|
module Exporters
|
|
@@ -28,7 +29,11 @@ module Abbu
|
|
|
28
29
|
{
|
|
29
30
|
name: contact.full_name,
|
|
30
31
|
first_name: contact.first_name,
|
|
32
|
+
middle_name: contact.middle_name,
|
|
31
33
|
last_name: contact.last_name,
|
|
34
|
+
phonetic_first_name: contact.phonetic_first_name,
|
|
35
|
+
phonetic_middle_name: contact.phonetic_middle_name,
|
|
36
|
+
phonetic_last_name: contact.phonetic_last_name,
|
|
32
37
|
nickname: contact.nickname,
|
|
33
38
|
prefix: contact.prefix,
|
|
34
39
|
suffix: contact.suffix,
|
|
@@ -42,7 +47,18 @@ module Abbu
|
|
|
42
47
|
urls: contact.urls,
|
|
43
48
|
notes: contact.notes,
|
|
44
49
|
related_names: contact.related_names,
|
|
45
|
-
social_profiles: contact.social_profiles
|
|
50
|
+
social_profiles: contact.social_profiles,
|
|
51
|
+
birthday: contact.birthday,
|
|
52
|
+
anniversary: contact.anniversary,
|
|
53
|
+
dates: contact.dates,
|
|
54
|
+
instant_messages: contact.instant_messages,
|
|
55
|
+
verification_code: contact.verification_code,
|
|
56
|
+
lunar_birthday: contact.lunar_birthday,
|
|
57
|
+
image_uri: contact.image_uri,
|
|
58
|
+
image_path: contact.image_path&.to_s,
|
|
59
|
+
created_at: contact.created_at&.iso8601,
|
|
60
|
+
modified_at: contact.modified_at&.iso8601,
|
|
61
|
+
source: contact.source
|
|
46
62
|
}.compact
|
|
47
63
|
end
|
|
48
64
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# lib/abbu/exporters/vcard_exporter.rb
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
4
6
|
module Abbu
|
|
5
7
|
module Exporters
|
|
6
8
|
class VcardExporter
|
|
@@ -22,23 +24,75 @@ module Abbu
|
|
|
22
24
|
@contacts.map { |c| vcard_for(c) }.join("\n")
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def vcard_for(contact) # rubocop:disable Metrics/
|
|
27
|
+
def vcard_for(contact) # rubocop:disable Metrics/MethodLength
|
|
26
28
|
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
|
|
29
|
+
|
|
30
|
+
append_name_fields(lines, contact)
|
|
32
31
|
append_emails(lines, contact)
|
|
33
32
|
append_phones(lines, contact)
|
|
34
33
|
append_addresses(lines, contact)
|
|
35
34
|
append_urls(lines, contact)
|
|
36
35
|
append_social_profiles(lines, contact)
|
|
37
|
-
|
|
36
|
+
append_dates(lines, contact)
|
|
37
|
+
append_instant_messages(lines, contact)
|
|
38
|
+
append_verification_code(lines, contact)
|
|
39
|
+
append_notes(lines, contact)
|
|
40
|
+
append_photo(lines, contact)
|
|
41
|
+
|
|
38
42
|
lines << 'END:VCARD'
|
|
39
43
|
lines.join("\n")
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
def append_name_fields(lines, contact)
|
|
47
|
+
lines << "FN:#{contact.full_name}"
|
|
48
|
+
lines << "N:#{name_components(contact).join(';')}"
|
|
49
|
+
append_nickname(lines, contact)
|
|
50
|
+
append_company(lines, contact)
|
|
51
|
+
append_title(lines, contact)
|
|
52
|
+
append_phonetic_names(lines, contact)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def name_components(contact)
|
|
56
|
+
[contact.last_name, contact.first_name, contact.middle_name, contact.prefix, contact.suffix]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def append_nickname(lines, contact)
|
|
60
|
+
lines << "NICKNAME:#{contact.nickname}" if contact.nickname
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def append_company(lines, contact)
|
|
64
|
+
lines << "ORG:#{contact.company}" if contact.company
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def append_title(lines, contact)
|
|
68
|
+
lines << "TITLE:#{contact.job_title}" if contact.job_title
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_phonetic_names(lines, contact)
|
|
72
|
+
lines << "X-PHONETIC-FIRST-NAME:#{contact.phonetic_first_name}" if contact.phonetic_first_name
|
|
73
|
+
lines << "X-PHONETIC-MIDDLE-NAME:#{contact.phonetic_middle_name}" if contact.phonetic_middle_name
|
|
74
|
+
lines << "X-PHONETIC-LAST-NAME:#{contact.phonetic_last_name}" if contact.phonetic_last_name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def append_verification_code(lines, contact)
|
|
78
|
+
lines << "X-VERIFICATION-CODE:#{contact.verification_code}" if contact.verification_code
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def append_photo(lines, contact)
|
|
82
|
+
return unless contact.image_path
|
|
83
|
+
|
|
84
|
+
lines << "PHOTO;VALUE=URI:#{file_uri(contact.image_path)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def file_uri(path)
|
|
88
|
+
escaped_path = URI::DEFAULT_PARSER.escape(path.to_s)
|
|
89
|
+
URI::Generic.build(scheme: 'file', path: escaped_path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def append_notes(lines, contact)
|
|
93
|
+
contact.notes.each { |n| lines << "NOTE:#{n}" }
|
|
94
|
+
end
|
|
95
|
+
|
|
42
96
|
def append_emails(lines, contact)
|
|
43
97
|
contact.emails.each do |e|
|
|
44
98
|
label = e[:label] || 'INTERNET'
|
|
@@ -71,6 +125,36 @@ module Abbu
|
|
|
71
125
|
lines << "X-SOCIALPROFILE;TYPE=#{sp[:service]}:#{sp[:username]}"
|
|
72
126
|
end
|
|
73
127
|
end
|
|
128
|
+
|
|
129
|
+
def append_instant_messages(lines, contact)
|
|
130
|
+
contact.instant_messages.each do |im|
|
|
131
|
+
service = im[:service]&.downcase || 'unknown'
|
|
132
|
+
lines << "IMPP;TYPE=#{im[:label]}:#{service}:#{im[:address]}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def append_dates(lines, contact)
|
|
137
|
+
lines << "BDAY:#{format_vcard_date(contact.birthday)}" if contact.birthday
|
|
138
|
+
lines << "X-LUNAR-BDAY:#{format_vcard_date(contact.lunar_birthday)}" if contact.lunar_birthday
|
|
139
|
+
return unless contact.anniversary
|
|
140
|
+
|
|
141
|
+
lines << "X-ABDATE;type=pref:#{format_vcard_date(contact.anniversary)}"
|
|
142
|
+
lines << "X-ABLABEL:#{anniversary_label(contact.anniversary)}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def anniversary_label(anniversary)
|
|
146
|
+
anniversary[:raw_label] || anniversary[:label] || 'Anniversary'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def format_vcard_date(date)
|
|
150
|
+
return nil unless date
|
|
151
|
+
|
|
152
|
+
if date[:year]&.positive?
|
|
153
|
+
format('%<year>04d-%<month>02d-%<day>02d', year: date[:year], month: date[:month], day: date[:day])
|
|
154
|
+
else
|
|
155
|
+
format('--%<month>02d-%<day>02d', month: date[:month], day: date[:day])
|
|
156
|
+
end
|
|
157
|
+
end
|
|
74
158
|
end
|
|
75
159
|
end
|
|
76
160
|
end
|