abbu 0.2.0 → 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,10 +3,16 @@
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
18
  'ZFIRSTNAME' => :first_name, 'ZMIDDLENAME' => :middle_name,
@@ -21,11 +27,20 @@ module Abbu
21
27
  'ZPHONETICORGANIZATION' => :phonetic_company,
22
28
  'ZPRONOUNS' => :pronouns,
23
29
  'ZRINGTONE' => :ringtone, 'ZTEXTTONE' => :texttone,
24
- 'ZVERIFICATIONCODE' => :verification_code
30
+ 'ZVERIFICATIONCODE' => :verification_code,
31
+ 'ZIMAGEURI' => :image_uri
25
32
  }.freeze
26
33
 
27
- def initialize(db_paths)
34
+ attr_reader :diagnostics
35
+
36
+ def initialize(db_paths, root_path: nil, readonly: false, diagnostics: nil, strict: false)
28
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 = {}
29
44
  end
30
45
 
31
46
  def contacts
@@ -37,9 +52,10 @@ module Abbu
37
52
  private
38
53
 
39
54
  def parse_db(db_path)
40
- 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)
41
57
  db.results_as_hash = true
42
- records(db).map { |row| build_contact(db, row) }
58
+ records(db).filter_map { |row| build_contact(db, row, db_path) }
43
59
  ensure
44
60
  db&.close
45
61
  end
@@ -47,38 +63,46 @@ module Abbu
47
63
  def records(db)
48
64
  # Exclude groups (typically Z_ENT = 15 in this schema version)
49
65
  db.execute('SELECT * FROM ZABCDRECORD WHERE Z_ENT != 15')
66
+ rescue SQLite3::Exception
67
+ fail_required_schema(@active_db_path)
50
68
  end
51
69
 
52
70
  def emails_for(db, record_id)
53
- db.execute(
54
- 'SELECT ZADDRESSNORMALIZED, ZLABEL FROM ZABCDEMAILADDRESS WHERE ZOWNER = ?',
55
- record_id
56
- ).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
57
75
  end
58
76
 
59
77
  def phones_for(db, record_id)
60
- db.execute(
61
- 'SELECT ZFULLNUMBER, ZLABEL FROM ZABCDPHONENUMBER WHERE ZOWNER = ?',
62
- record_id
63
- ).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
64
82
  end
65
83
 
66
84
  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|
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|
71
88
  {
72
89
  street: row['ZSTREET'],
73
90
  city: row['ZCITY'],
74
91
  state: row['ZSTATE'],
75
92
  zip: row['ZZIPCODE'],
76
93
  country: row['ZCOUNTRYNAME'],
77
- label: row['ZLABEL']
94
+ **label_fields(row['ZLABEL'])
78
95
  }
79
96
  end
80
97
  end
81
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
+
82
106
  def groups_for(db, record_id)
83
107
  query = <<-SQL
84
108
  SELECT g.ZFIRSTNAME
@@ -86,76 +110,123 @@ module Abbu
86
110
  JOIN ZABCDRECORD g ON j.Z_GROUP = g.Z_PK
87
111
  WHERE j.Z_CONTACT = ?
88
112
  SQL
89
- db.execute(query, record_id).map { |row| row['ZFIRSTNAME'] }
90
- rescue SQLite3::SQLException
91
- []
113
+ optional_rows(db, record_id, 'Z_ABCDCONTACTGROUP', query).map { |row| row['ZFIRSTNAME'] }
92
114
  end
93
115
 
94
116
  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
- []
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
101
121
  end
102
122
 
103
123
  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
- []
124
+ query = 'SELECT ZTEXT FROM ZABCDNOTE WHERE ZCONTACT = ?'
125
+ optional_rows(db, record_id, 'ZABCDNOTE', query).filter_map { |row| row['ZTEXT'] }
110
126
  end
111
127
 
112
128
  def related_names_for(db, record_id)
113
- db.execute(
114
- 'SELECT ZNAME, ZLABEL FROM ZABCDRELATEDNAME WHERE ZOWNER = ?',
115
- record_id
116
- ).map { |row| { name: row['ZNAME'], label: row['ZLABEL'] } }
117
- rescue SQLite3::SQLException
118
- []
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
119
133
  end
120
134
 
121
135
  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
- []
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
128
140
  end
129
141
 
130
142
  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
- []
143
+ query = 'SELECT ZYEAR, ZMONTH, ZDAY, ZLABEL FROM ZABCDDATECOMPONENTS WHERE ZOWNER = ?'
144
+ optional_rows(db, record_id, 'ZABCDDATECOMPONENTS', query).map { |row| date_from(row) }
139
145
  end
140
146
 
141
147
  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'] }
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']) }
147
151
  end
148
- rescue SQLite3::SQLException
149
- []
150
152
  end
151
153
 
152
- def build_contact(db, row)
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)
153
191
  contact = Contact.new
154
192
  assign_flat_fields(contact, row)
155
193
  assign_relational_fields(contact, db, row['Z_PK'])
194
+ assign_metadata(contact, row, db_path)
156
195
  contact
157
196
  end
158
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
+
159
230
  def assign_flat_fields(contact, row)
160
231
  RECORD_FIELD_MAP.each do |column, attr|
161
232
  contact.public_send(:"#{attr}=", row[column])
@@ -175,9 +246,9 @@ module Abbu
175
246
 
176
247
  all_dates = dates_for(db, record_id)
177
248
  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>!$_' }
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' }
181
252
  end
182
253
  end
183
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