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.
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 'parsers/sqlite_parser'
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 records_path
37
- @records_path ||= @path.join('Records')
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(records_path)
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, :last_name, :emails, :phones, :company, :addresses, :groups, :nickname, :prefix, :suffix,
7
- :job_title, :department, :maiden_name, :phonetic_first_name, :phonetic_last_name, :phonetic_company,
8
- :pronouns, :ringtone, :texttone, :urls, :notes, :related_names, :social_profiles
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.company,
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.notes.join("\n"),
52
- format_related_names(contact.related_names),
53
- format_social_profiles(contact.social_profiles)
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/AbcSize,Metrics/MethodLength
27
+ def vcard_for(contact) # rubocop:disable Metrics/MethodLength
26
28
  lines = ['BEGIN:VCARD', 'VERSION:3.0']
27
- lines << "FN:#{contact.full_name}"
28
- lines << "N:#{contact.last_name};#{contact.first_name};;;"
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
- contact.notes.each { |n| lines << "NOTE:#{n}" }
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