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.
@@ -3,26 +3,44 @@
3
3
 
4
4
  require 'sqlite3'
5
5
  require_relative '../contact'
6
+ require_relative '../diagnostic'
7
+ require_relative '../parse_error'
8
+ require_relative '../utils/label_normalizer'
9
+ require_relative '../utils/source_descriptor'
6
10
 
7
11
  module Abbu
8
12
  module Parsers
9
13
  class SqliteParser # rubocop:disable Metrics/ClassLength
14
+ APPLE_EPOCH_OFFSET = 978_307_200
15
+
10
16
  # Column-name → attr_accessor mapping for flat fields on ZABCDRECORD
11
17
  RECORD_FIELD_MAP = {
12
- 'ZFIRSTNAME' => :first_name, 'ZLASTNAME' => :last_name,
18
+ 'ZFIRSTNAME' => :first_name, 'ZMIDDLENAME' => :middle_name,
19
+ 'ZLASTNAME' => :last_name,
13
20
  'ZNICKNAME' => :nickname, 'ZTITLE' => :prefix,
14
21
  'ZSUFFIX' => :suffix, 'ZORGANIZATION' => :company,
15
22
  'ZJOBTITLE' => :job_title, 'ZDEPARTMENT' => :department,
16
23
  'ZMAIDENNAME' => :maiden_name,
17
24
  'ZPHONETICFIRSTNAME' => :phonetic_first_name,
25
+ 'ZPHONETICMIDDLENAME' => :phonetic_middle_name,
18
26
  'ZPHONETICLASTNAME' => :phonetic_last_name,
19
27
  'ZPHONETICORGANIZATION' => :phonetic_company,
20
28
  'ZPRONOUNS' => :pronouns,
21
- 'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone
29
+ 'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone,
30
+ 'ZVERIFICATIONCODE' => :verification_code,
31
+ 'ZIMAGEURI' => :image_uri
22
32
  }.freeze
23
33
 
24
- def initialize(db_paths)
34
+ attr_reader :diagnostics
35
+
36
+ def initialize(db_paths, root_path: nil, readonly: false, diagnostics: nil, strict: false)
25
37
  @db_paths = Array(db_paths)
38
+ @readonly = readonly
39
+ @root_path = root_path
40
+ @diagnostics = diagnostics || []
41
+ @missing_optional_tables = {}
42
+ @strict = strict
43
+ @table_presence = {}
26
44
  end
27
45
 
28
46
  def contacts
@@ -34,9 +52,10 @@ module Abbu
34
52
  private
35
53
 
36
54
  def parse_db(db_path)
37
- db = SQLite3::Database.new(db_path.to_s)
55
+ @active_db_path = db_path
56
+ db = SQLite3::Database.new(db_path.to_s, readonly: @readonly)
38
57
  db.results_as_hash = true
39
- records(db).map { |row| build_contact(db, row) }
58
+ records(db).filter_map { |row| build_contact(db, row, db_path) }
40
59
  ensure
41
60
  db&.close
42
61
  end
@@ -44,38 +63,46 @@ module Abbu
44
63
  def records(db)
45
64
  # Exclude groups (typically Z_ENT = 15 in this schema version)
46
65
  db.execute('SELECT * FROM ZABCDRECORD WHERE Z_ENT != 15')
66
+ rescue SQLite3::Exception
67
+ fail_required_schema(@active_db_path)
47
68
  end
48
69
 
49
70
  def emails_for(db, record_id)
50
- db.execute(
51
- 'SELECT ZADDRESSNORMALIZED, ZLABEL FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
52
- record_id
53
- ).map { |row| { address: row['ZADDRESSNORMALIZED'], label: row['ZLABEL'] } }
71
+ query = 'SELECT ZADDRESSNORMALIZED, ZLABEL FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?'
72
+ optional_rows(db, record_id, 'ZABCDEMAILADDRESS', query).map do |row|
73
+ { address: row['ZADDRESSNORMALIZED'], **label_fields(row['ZLABEL']) }
74
+ end
54
75
  end
55
76
 
56
77
  def phones_for(db, record_id)
57
- db.execute(
58
- 'SELECT ZFULLNUMBER, ZLABEL FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
59
- record_id
60
- ).map { |row| { number: row['ZFULLNUMBER'], label: row['ZLABEL'] } }
78
+ query = 'SELECT ZFULLNUMBER, ZLABEL FROM ZABCDPHONENUMBER WHERE ZOWNER = ?'
79
+ optional_rows(db, record_id, 'ZABCDPHONENUMBER', query).map do |row|
80
+ { number: row['ZFULLNUMBER'], **label_fields(row['ZLABEL']) }
81
+ end
61
82
  end
62
83
 
63
84
  def addresses_for(db, record_id) # rubocop:disable Metrics/MethodLength
64
- db.execute(
65
- 'SELECT ZSTREET, ZCITY, ZSTATE, ZZIPCODE, ZCOUNTRYNAME, ZLABEL FROM ZABCDPOSTALADDRESS WHERE ZOWNER = ?',
66
- record_id
67
- ).map do |row|
85
+ query = 'SELECT ZSTREET, ZCITY, ZSTATE, ZZIPCODE, ZCOUNTRYNAME, ZLABEL ' \
86
+ 'FROM ZABCDPOSTALADDRESS WHERE ZOWNER = ?'
87
+ optional_rows(db, record_id, 'ZABCDPOSTALADDRESS', query).map do |row|
68
88
  {
69
89
  street: row['ZSTREET'],
70
90
  city: row['ZCITY'],
71
91
  state: row['ZSTATE'],
72
92
  zip: row['ZZIPCODE'],
73
93
  country: row['ZCOUNTRYNAME'],
74
- label: row['ZLABEL']
94
+ **label_fields(row['ZLABEL'])
75
95
  }
76
96
  end
77
97
  end
78
98
 
99
+ def table_exists?(db, table_name)
100
+ key = [@active_db_path.to_s, table_name]
101
+ @table_presence.fetch(key) do
102
+ @table_presence[key] = db.table_info(table_name).any?
103
+ end
104
+ end
105
+
79
106
  def groups_for(db, record_id)
80
107
  query = <<-SQL
81
108
  SELECT g.ZFIRSTNAME
@@ -83,69 +110,145 @@ module Abbu
83
110
  JOIN ZABCDRECORD g ON j.Z_GROUP = g.Z_PK
84
111
  WHERE j.Z_CONTACT = ?
85
112
  SQL
86
- db.execute(query, record_id).map { |row| row['ZFIRSTNAME'] }
87
- rescue SQLite3::SQLException
88
- []
113
+ optional_rows(db, record_id, 'Z_ABCDCONTACTGROUP', query).map { |row| row['ZFIRSTNAME'] }
89
114
  end
90
115
 
91
116
  def urls_for(db, record_id)
92
- db.execute(
93
- 'SELECT ZURL, ZLABEL FROM ZABCDURLADDRESS WHERE ZOWNER = ?',
94
- record_id
95
- ).map { |row| { url: row['ZURL'], label: row['ZLABEL'] } }
96
- rescue SQLite3::SQLException
97
- []
117
+ query = 'SELECT ZURL, ZLABEL FROM ZABCDURLADDRESS WHERE ZOWNER = ?'
118
+ optional_rows(db, record_id, 'ZABCDURLADDRESS', query).map do |row|
119
+ { url: row['ZURL'], **label_fields(row['ZLABEL']) }
120
+ end
98
121
  end
99
122
 
100
123
  def notes_for(db, record_id)
101
- db.execute(
102
- 'SELECT ZTEXT FROM ZABCDNOTE WHERE ZCONTACT = ?',
103
- record_id
104
- ).filter_map { |row| row['ZTEXT'] }
105
- rescue SQLite3::SQLException
106
- []
124
+ query = 'SELECT ZTEXT FROM ZABCDNOTE WHERE ZCONTACT = ?'
125
+ optional_rows(db, record_id, 'ZABCDNOTE', query).filter_map { |row| row['ZTEXT'] }
107
126
  end
108
127
 
109
128
  def related_names_for(db, record_id)
110
- db.execute(
111
- 'SELECT ZNAME, ZLABEL FROM ZABCDRELATEDNAME WHERE ZOWNER = ?',
112
- record_id
113
- ).map { |row| { name: row['ZNAME'], label: row['ZLABEL'] } }
114
- rescue SQLite3::SQLException
115
- []
129
+ query = 'SELECT ZNAME, ZLABEL FROM ZABCDRELATEDNAME WHERE ZOWNER = ?'
130
+ optional_rows(db, record_id, 'ZABCDRELATEDNAME', query).map do |row|
131
+ { name: row['ZNAME'], **label_fields(row['ZLABEL']) }
132
+ end
116
133
  end
117
134
 
118
135
  def social_profiles_for(db, record_id)
119
- db.execute(
120
- 'SELECT ZSERVICENAME, ZUSERNAME FROM ZABCDSOCIALPROFILE WHERE ZOWNER = ?',
121
- record_id
122
- ).map { |row| { service: row['ZSERVICENAME'], username: row['ZUSERNAME'] } }
123
- rescue SQLite3::SQLException
124
- []
136
+ query = 'SELECT ZSERVICENAME, ZUSERNAME FROM ZABCDSOCIALPROFILE WHERE ZOWNER = ?'
137
+ optional_rows(db, record_id, 'ZABCDSOCIALPROFILE', query).map do |row|
138
+ { service: row['ZSERVICENAME'], username: row['ZUSERNAME'] }
139
+ end
125
140
  end
126
141
 
127
- def build_contact(db, row)
142
+ def dates_for(db, record_id)
143
+ query = 'SELECT ZYEAR, ZMONTH, ZDAY, ZLABEL FROM ZABCDDATECOMPONENTS WHERE ZOWNER = ?'
144
+ optional_rows(db, record_id, 'ZABCDDATECOMPONENTS', query).map { |row| date_from(row) }
145
+ end
146
+
147
+ def instant_messages_for(db, record_id)
148
+ query = 'SELECT ZADDRESS, ZLABEL, ZSERVICENAME FROM ZABCDMESSAGINGADDRESS WHERE ZOWNER = ?'
149
+ optional_rows(db, record_id, 'ZABCDMESSAGINGADDRESS', query).map do |row|
150
+ { address: row['ZADDRESS'], service: row['ZSERVICENAME'], **label_fields(row['ZLABEL']) }
151
+ end
152
+ end
153
+
154
+ def optional_rows(db, record_id, table, query)
155
+ return missing_optional_table(table) unless table_exists?(db, table)
156
+
157
+ db.execute(query, record_id)
158
+ end
159
+
160
+ def missing_optional_table(table)
161
+ key = [@active_db_path.to_s, table]
162
+ return [] if @missing_optional_tables[key]
163
+
164
+ @missing_optional_tables[key] = true
165
+ recover(optional_diagnostic(table), fallback: [])
166
+ end
167
+
168
+ def optional_diagnostic(table)
169
+ Diagnostic.new(
170
+ category: :missing_optional_data,
171
+ message: 'Optional SQLite table is unavailable',
172
+ parser: :sqlite,
173
+ source: @active_db_path,
174
+ context: { table: table }.freeze
175
+ )
176
+ end
177
+
178
+ def fail_required_schema(db_path)
179
+ diagnostic = Diagnostic.new(
180
+ category: :required_schema,
181
+ message: 'Required SQLite contact schema is unavailable',
182
+ parser: :sqlite,
183
+ source: db_path,
184
+ context: { table: 'ZABCDRECORD' }.freeze
185
+ )
186
+ diagnostics << diagnostic
187
+ raise ParseError, diagnostic
188
+ end
189
+
190
+ def build_contact(db, row, db_path)
128
191
  contact = Contact.new
129
192
  assign_flat_fields(contact, row)
130
193
  assign_relational_fields(contact, db, row['Z_PK'])
194
+ assign_metadata(contact, row, db_path)
131
195
  contact
132
196
  end
133
197
 
198
+ def recover(diagnostic, fallback:)
199
+ diagnostics << diagnostic
200
+ raise ParseError, diagnostic if @strict
201
+
202
+ fallback
203
+ end
204
+
205
+ def assign_metadata(contact, row, db_path)
206
+ contact.created_at = apple_time(row['ZCREATIONDATE'])
207
+ contact.modified_at = apple_time(row['ZMODIFICATIONDATE'])
208
+ contact.source = Utils::SourceDescriptor.new(db_path, root_path: @root_path).to_h
209
+ end
210
+
211
+ def apple_time(value)
212
+ return if value.nil?
213
+
214
+ Time.at(Float(value) + APPLE_EPOCH_OFFSET).utc
215
+ rescue ArgumentError, RangeError, TypeError
216
+ nil
217
+ end
218
+
219
+ def label_fields(raw_label)
220
+ { label: Utils::LabelNormalizer.normalize(raw_label), raw_label: raw_label }
221
+ end
222
+
223
+ def date_from(row)
224
+ {
225
+ year: row['ZYEAR'], month: row['ZMONTH'], day: row['ZDAY'],
226
+ **label_fields(row['ZLABEL'])
227
+ }
228
+ end
229
+
134
230
  def assign_flat_fields(contact, row)
135
231
  RECORD_FIELD_MAP.each do |column, attr|
136
232
  contact.public_send(:"#{attr}=", row[column])
137
233
  end
138
234
  end
139
235
 
140
- def assign_relational_fields(contact, db, record_id) # rubocop:disable Metrics/AbcSize
141
- contact.emails = emails_for(db, record_id)
142
- contact.phones = phones_for(db, record_id)
143
- contact.addresses = addresses_for(db, record_id)
144
- contact.groups = groups_for(db, record_id)
145
- contact.urls = urls_for(db, record_id)
146
- contact.notes = notes_for(db, record_id)
147
- contact.related_names = related_names_for(db, record_id)
148
- contact.social_profiles = social_profiles_for(db, record_id)
236
+ def assign_relational_fields(contact, db, record_id) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
237
+ contact.emails = emails_for(db, record_id)
238
+ contact.phones = phones_for(db, record_id)
239
+ contact.addresses = addresses_for(db, record_id)
240
+ contact.groups = groups_for(db, record_id)
241
+ contact.urls = urls_for(db, record_id)
242
+ contact.notes = notes_for(db, record_id)
243
+ contact.related_names = related_names_for(db, record_id)
244
+ contact.social_profiles = social_profiles_for(db, record_id)
245
+ contact.instant_messages = instant_messages_for(db, record_id)
246
+
247
+ all_dates = dates_for(db, record_id)
248
+ contact.dates = all_dates
249
+ contact.birthday = all_dates.find { |d| d[:label] == 'Birthday' }
250
+ contact.anniversary = all_dates.find { |d| d[:label] == 'Anniversary' }
251
+ contact.lunar_birthday = all_dates.find { |d| d[:label] == 'LunarBirthday' }
149
252
  end
150
253
  end
151
254
  end
data/lib/abbu/query.rb ADDED
@@ -0,0 +1,73 @@
1
+ # lib/abbu/query.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ class Query
6
+ include Enumerable
7
+
8
+ def initialize(contacts)
9
+ @contacts = contacts.to_a.freeze
10
+ end
11
+
12
+ def each(&)
13
+ @contacts.each(&)
14
+ end
15
+
16
+ def where(criteria)
17
+ self.class.new(select { |contact| matches_criteria?(contact, criteria) })
18
+ end
19
+
20
+ def search(term)
21
+ normalized_term = normalize_email(term)
22
+ return self.class.new([]) if normalized_term.empty?
23
+
24
+ self.class.new(select do |contact|
25
+ normalize_text(contact.full_name).include?(normalized_term) ||
26
+ contact.emails.any? { |email| normalize_email(email[:address]).include?(normalized_term) }
27
+ end)
28
+ end
29
+
30
+ def find_by_email(email)
31
+ normalized_email = normalize_email(email)
32
+ return self.class.new([]) if normalized_email.empty?
33
+
34
+ self.class.new(select do |contact|
35
+ contact.emails.any? { |entry| normalize_email(entry[:address]) == normalized_email }
36
+ end)
37
+ end
38
+
39
+ def find_by_phone(phone)
40
+ normalized_phone = normalize_phone(phone)
41
+ return self.class.new([]) if normalized_phone.empty?
42
+
43
+ self.class.new(select do |contact|
44
+ contact.phones.any? { |entry| normalize_phone(entry[:number]) == normalized_phone }
45
+ end)
46
+ end
47
+
48
+ def to_a
49
+ @contacts.dup
50
+ end
51
+
52
+ private
53
+
54
+ def matches_criteria?(contact, criteria)
55
+ criteria.all? do |field, expected|
56
+ actual = contact.public_send(field)
57
+ actual.is_a?(Array) ? actual.include?(expected) : actual == expected
58
+ end
59
+ end
60
+
61
+ def normalize_email(value)
62
+ normalize_text(value).strip
63
+ end
64
+
65
+ def normalize_phone(value)
66
+ value.to_s.gsub(/[^0-9]/, '')
67
+ end
68
+
69
+ def normalize_text(value)
70
+ value.to_s.unicode_normalize(:nfc).downcase
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,133 @@
1
+ # lib/abbu/schema_inspector.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'pathname'
5
+ # Explicit ownership keeps SchemaInspector independent of Ruby's Set autoload behavior.
6
+ require 'set' # rubocop:disable Lint/RedundantRequireStatement
7
+ require 'sqlite3'
8
+ require_relative 'utils/source_descriptor'
9
+
10
+ module Abbu
11
+ class SchemaInspector
12
+ RECORD_COLUMNS = %w[
13
+ Z_PK Z_ENT ZFIRSTNAME ZMIDDLENAME ZLASTNAME ZNICKNAME ZTITLE ZSUFFIX
14
+ ZORGANIZATION ZJOBTITLE ZDEPARTMENT ZMAIDENNAME ZPHONETICFIRSTNAME
15
+ ZPHONETICMIDDLENAME ZPHONETICLASTNAME ZPHONETICORGANIZATION ZPRONOUNS
16
+ ZRINGTONE ZTEXTTONE ZVERIFICATIONCODE ZIMAGEURI ZCREATIONDATE
17
+ ZMODIFICATIONDATE
18
+ ].freeze
19
+
20
+ KNOWN_SCHEMA = {
21
+ 'Z_ABCDCONTACTGROUP' => %w[Z_CONTACT Z_GROUP],
22
+ 'ZABCDDATECOMPONENTS' => %w[Z_PK ZOWNER ZYEAR ZMONTH ZDAY ZLABEL],
23
+ 'ZABCDEMAILADDRESS' => %w[Z_PK ZOWNER ZADDRESSNORMALIZED ZLABEL],
24
+ 'ZABCDMESSAGINGADDRESS' => %w[Z_PK ZOWNER ZADDRESS ZLABEL ZSERVICENAME],
25
+ 'ZABCDNOTE' => %w[Z_PK ZCONTACT ZTEXT],
26
+ 'ZABCDPHONENUMBER' => %w[Z_PK ZOWNER ZFULLNUMBER ZLABEL],
27
+ 'ZABCDPOSTALADDRESS' => %w[Z_PK ZOWNER ZSTREET ZCITY ZSTATE ZZIPCODE ZCOUNTRYNAME ZLABEL],
28
+ 'ZABCDRECORD' => RECORD_COLUMNS,
29
+ 'ZABCDRELATEDNAME' => %w[Z_PK ZOWNER ZNAME ZLABEL],
30
+ 'ZABCDSOCIALPROFILE' => %w[Z_PK ZOWNER ZSERVICENAME ZUSERNAME],
31
+ 'ZABCDURLADDRESS' => %w[Z_PK ZOWNER ZURL ZLABEL]
32
+ }.transform_values { |columns| columns.to_set.freeze }.freeze
33
+ REQUIRED_TABLES = %w[ZABCDRECORD].freeze
34
+
35
+ def initialize(db_paths, root_path: nil)
36
+ @db_paths = Array(db_paths).map { |path| Pathname.new(path) }.sort
37
+ @root_path = root_path && Pathname.new(root_path)
38
+ end
39
+
40
+ def report
41
+ {
42
+ archive_path: @root_path&.expand_path&.to_s,
43
+ databases: @db_paths.map { |db_path| inspect_database(db_path) }
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def inspect_database(db_path)
50
+ db = SQLite3::Database.new(db_path.to_s)
51
+ table_names = user_table_names(db)
52
+ tables = table_names.map { |table_name| inspect_table(db, table_name) }
53
+ source = Utils::SourceDescriptor.new(db_path, root_path: @root_path).to_h
54
+ database_report(source, tables, KNOWN_SCHEMA.keys - table_names)
55
+ ensure
56
+ db&.close
57
+ end
58
+
59
+ def database_report(source, tables, missing_tables)
60
+ {
61
+ path: source[:path],
62
+ relative_path: source[:relative_path],
63
+ source: source.slice(:kind, :identifier),
64
+ tables: tables,
65
+ unknown_tables: tables.reject { |table| table[:known] }.map { |table| table[:name] },
66
+ missing_known_tables: missing_tables,
67
+ missing_required_tables: REQUIRED_TABLES & missing_tables,
68
+ schema_drift: schema_drift?(tables, missing_tables)
69
+ }
70
+ end
71
+
72
+ def user_table_names(db)
73
+ db.execute(<<~SQL).flatten.sort
74
+ SELECT name
75
+ FROM sqlite_master
76
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
77
+ ORDER BY name
78
+ SQL
79
+ end
80
+
81
+ def inspect_table(db, table_name)
82
+ known_columns = KNOWN_SCHEMA[table_name]
83
+ columns = table_columns(db, table_name, known_columns)
84
+ {
85
+ name: table_name,
86
+ known: !known_columns.nil?,
87
+ columns: columns,
88
+ missing_known_columns: missing_known_columns(known_columns, columns),
89
+ unknown_columns: columns.reject { |column| column[:known] }.map { |column| column[:name] },
90
+ contact_link_candidates: contact_link_candidates(columns)
91
+ }
92
+ end
93
+
94
+ def missing_known_columns(known_columns, columns)
95
+ return [] unless known_columns
96
+
97
+ known_columns.to_a - columns.map { |column| column[:name] }
98
+ end
99
+
100
+ def contact_link_candidates(columns)
101
+ columns.filter_map { |column| column[:name] if column[:contact_link_candidate] }
102
+ end
103
+
104
+ def table_columns(db, table_name, known_columns)
105
+ db.table_info(table_name).sort_by { |column| column['name'] }.map do |column|
106
+ column_report(column, known_columns)
107
+ end
108
+ end
109
+
110
+ def column_report(column, known_columns)
111
+ name = column['name']
112
+ {
113
+ name: name,
114
+ declared_type: column['type'],
115
+ nullable: column['notnull'].zero?,
116
+ primary_key: column['pk'].positive?,
117
+ known: known_columns&.include?(name) || false,
118
+ contact_link_candidate: contact_link_candidate?(name)
119
+ }
120
+ end
121
+
122
+ def contact_link_candidate?(column_name)
123
+ %w[OWNER CONTACT].include?(column_name.delete_prefix('Z').delete('_').upcase)
124
+ end
125
+
126
+ def schema_drift?(tables, missing_tables)
127
+ table_drift = tables.any? do |table|
128
+ !table[:known] || table[:missing_known_columns].any? || table[:unknown_columns].any?
129
+ end
130
+ missing_tables.any? || table_drift
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,97 @@
1
+ # lib/abbu/utils/contact_identity.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Utils
6
+ class ContactIdentity
7
+ Signal = Struct.new(:type, :normalized, :raw, keyword_init: true)
8
+
9
+ attr_reader :contact
10
+
11
+ def initialize(contact)
12
+ @contact = contact
13
+ end
14
+
15
+ def emails
16
+ contact.emails.filter_map do |entry|
17
+ raw = entry.is_a?(Hash) ? entry[:address] : entry
18
+ normalized = normalize_email(raw)
19
+ Signal.new(type: :email, normalized: normalized, raw: raw).freeze if valid_email?(normalized)
20
+ end
21
+ end
22
+
23
+ def phones
24
+ contact.phones.filter_map do |entry|
25
+ raw = entry.is_a?(Hash) ? entry[:number] : entry
26
+ normalized = normalize_phone(raw)
27
+ Signal.new(type: phone_type(normalized), normalized: normalized, raw: raw).freeze if normalized
28
+ end
29
+ end
30
+
31
+ def name
32
+ signal(:name, contact.full_name)
33
+ end
34
+
35
+ def organization
36
+ signal(:organization, contact.company)
37
+ end
38
+
39
+ def source
40
+ contact.source
41
+ end
42
+
43
+ def same_source?(other)
44
+ source_key = source&.fetch(:relative_path, nil)
45
+ source_key && source_key == other.source&.fetch(:relative_path, nil)
46
+ end
47
+
48
+ private
49
+
50
+ def signal(type, raw)
51
+ normalized = normalize_text(raw)
52
+ Signal.new(type: type, normalized: normalized, raw: raw).freeze unless normalized.empty?
53
+ end
54
+
55
+ def normalize_text(value)
56
+ value.to_s.unicode_normalize(:nfkc).downcase
57
+ .gsub(/[^\p{Alnum}\p{Mark}]+/u, ' ')
58
+ .strip
59
+ .gsub(/\s+/, ' ')
60
+ end
61
+
62
+ def normalize_email(value)
63
+ value.to_s.unicode_normalize(:nfkc).strip.downcase
64
+ end
65
+
66
+ def valid_email?(value)
67
+ value.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
68
+ end
69
+
70
+ def normalize_phone(value)
71
+ raw = value.to_s.strip
72
+ extension = raw[/\b(?:ext\.?|x)\s*(\d+)\z/i, 1]
73
+ core = raw.sub(/\b(?:ext\.?|x)\s*\d+\z/i, '')
74
+ digits = core.gsub(/\D/, '')
75
+ international = international?(core)
76
+ digits = digits.delete_prefix('00') if core.start_with?('00')
77
+ return unless valid_phone_length?(digits, international)
78
+
79
+ prefix = international ? '+' : 'national:'
80
+ normalized = "#{prefix}#{digits}"
81
+ extension ? "#{normalized};ext=#{extension}" : normalized
82
+ end
83
+
84
+ def valid_phone_length?(digits, international)
85
+ international ? digits.length.between?(8, 15) : digits.length >= 7
86
+ end
87
+
88
+ def international?(raw)
89
+ raw.start_with?('+', '00')
90
+ end
91
+
92
+ def phone_type(normalized)
93
+ normalized.start_with?('+') ? :international_phone : :source_local_phone
94
+ end
95
+ end
96
+ end
97
+ end