abbu 0.2.0 → 0.8.1

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +281 -8
  3. data/bin/abbu +230 -53
  4. data/docs/ABBU.md +369 -7
  5. data/docs/API_STABILITY.md +189 -0
  6. data/docs/CHANGELOG.md +179 -0
  7. data/docs/CONTRIBUTING.md +25 -3
  8. data/docs/FORMAT_COMPATIBILITY.md +97 -0
  9. data/docs/RELEASING.md +75 -0
  10. data/docs/TODO.md +73 -62
  11. data/lib/abbu/archive.rb +80 -6
  12. data/lib/abbu/contact.rb +6 -3
  13. data/lib/abbu/diagnostic.rb +27 -0
  14. data/lib/abbu/exporters/csv_exporter.rb +2 -2
  15. data/lib/abbu/exporters/json_exporter.rb +7 -1
  16. data/lib/abbu/exporters/vcard_document.rb +54 -0
  17. data/lib/abbu/exporters/vcard_encoding.rb +54 -0
  18. data/lib/abbu/exporters/vcard_exporter.rb +44 -27
  19. data/lib/abbu/group.rb +28 -0
  20. data/lib/abbu/group_catalog.rb +32 -0
  21. data/lib/abbu/image_extractor.rb +134 -0
  22. data/lib/abbu/live_store.rb +117 -0
  23. data/lib/abbu/parse_error.rb +13 -0
  24. data/lib/abbu/parsers/plist_parser.rb +41 -11
  25. data/lib/abbu/parsers/sqlite_parser.rb +139 -65
  26. data/lib/abbu/query.rb +92 -0
  27. data/lib/abbu/schema_inspector.rb +133 -0
  28. data/lib/abbu/source.rb +53 -0
  29. data/lib/abbu/source_catalog.rb +32 -0
  30. data/lib/abbu/timestamp_range.rb +40 -0
  31. data/lib/abbu/utils/contact_identity.rb +97 -0
  32. data/lib/abbu/utils/deduplicator.rb +130 -2
  33. data/lib/abbu/utils/image_resolver.rb +59 -0
  34. data/lib/abbu/utils/label_normalizer.rb +15 -0
  35. data/lib/abbu/utils/source_descriptor.rb +35 -0
  36. data/lib/abbu/version.rb +1 -1
  37. data/lib/abbu.rb +15 -2
  38. data/sig/group.rbs +36 -0
  39. data/sig/query.rbs +21 -0
  40. data/sig/source.rbs +25 -0
  41. data/sig/vcard_exporter.rbs +10 -0
  42. data/tasks/abbu.rake +1 -1
  43. metadata +33 -15
  44. data/bin/cleanse +0 -7
  45. data/bin/console +0 -10
  46. data/bin/dev +0 -7
  47. data/bin/lint +0 -7
  48. data/bin/outdated +0 -7
  49. data/bin/test +0 -7
@@ -0,0 +1,13 @@
1
+ # lib/abbu/parse_error.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ class ParseError < StandardError
6
+ attr_reader :diagnostic
7
+
8
+ def initialize(diagnostic)
9
+ @diagnostic = diagnostic
10
+ super("#{diagnostic.message} (#{diagnostic.source})")
11
+ end
12
+ end
13
+ end
@@ -3,6 +3,10 @@
3
3
 
4
4
  require 'plist'
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
@@ -21,8 +25,13 @@ module Abbu
21
25
  }.freeze
22
26
 
23
27
  # Accepts either a directory path (scans for *.abcdp) or an array of file paths
24
- def initialize(paths)
28
+ attr_reader :diagnostics
29
+
30
+ def initialize(paths, root_path: nil, diagnostics: nil, strict: false)
25
31
  @paths = resolve_paths(paths)
32
+ @root_path = root_path
33
+ @diagnostics = diagnostics || []
34
+ @strict = strict
26
35
  end
27
36
 
28
37
  def contacts
@@ -45,15 +54,32 @@ module Abbu
45
54
 
46
55
  def parse_file(file)
47
56
  data = Plist.parse_xml(file.to_s)
48
- return nil unless data
57
+ return recover(file) unless data.is_a?(Hash)
58
+
59
+ build_contact(data, file)
60
+ rescue ParseError
61
+ raise
62
+ rescue ArgumentError, EOFError, Plist::UnimplementedElementError, SystemCallError, TypeError
63
+ recover(file)
64
+ end
49
65
 
50
- build_contact(data)
66
+ def recover(file)
67
+ diagnostic = Diagnostic.new(
68
+ category: :malformed_record,
69
+ message: 'Unable to parse plist contact record',
70
+ parser: :plist,
71
+ source: file,
72
+ context: {}
73
+ )
74
+ diagnostics << diagnostic
75
+ raise ParseError, diagnostic if @strict
51
76
  end
52
77
 
53
- def build_contact(data)
78
+ def build_contact(data, file)
54
79
  contact = Contact.new
55
80
  assign_flat_fields(contact, data)
56
81
  assign_multi_value_fields(contact, data)
82
+ contact.source = Utils::SourceDescriptor.new(file, root_path: @root_path).to_h
57
83
  contact
58
84
  end
59
85
 
@@ -76,14 +102,14 @@ module Abbu
76
102
  contact.birthday = extract_birthday(data)
77
103
  contact.lunar_birthday = extract_lunar_birthday(data)
78
104
  contact.dates = extract_dates(data)
79
- contact.anniversary = contact.dates.find { |d| d[:label] == '_$!<Anniversary>!$_' }
105
+ contact.anniversary = contact.dates.find { |d| d[:label] == 'Anniversary' }
80
106
  end
81
107
 
82
108
  def extract_labeled_values(data, key, value_key)
83
109
  return [] unless data[key]&.dig('values')
84
110
 
85
111
  data[key]['values'].map do |entry|
86
- { value_key => entry['value'], label: entry['label'] }
112
+ { value_key => entry['value'], **label_fields(entry['label']) }
87
113
  end
88
114
  end
89
115
 
@@ -98,7 +124,7 @@ module Abbu
98
124
  state: addr['State'],
99
125
  zip: addr['ZIP'],
100
126
  country: addr['Country'],
101
- label: entry['label']
127
+ **label_fields(entry['label'])
102
128
  }
103
129
  end
104
130
  end
@@ -122,7 +148,7 @@ module Abbu
122
148
 
123
149
  data['InstantMessage']['values'].map do |entry|
124
150
  msg = entry['value'] || {}
125
- { address: msg['address'], label: entry['label'], service: msg['serviceName'] }
151
+ { address: msg['address'], service: msg['serviceName'], **label_fields(entry['label']) }
126
152
  end
127
153
  end
128
154
 
@@ -130,14 +156,14 @@ module Abbu
130
156
  val = data['Birthday']
131
157
  return nil unless val.respond_to?(:year)
132
158
 
133
- { year: val.year, month: val.month, day: val.day, label: '_$!<Birthday>!$_' }
159
+ { year: val.year, month: val.month, day: val.day, label: 'Birthday', raw_label: nil }
134
160
  end
135
161
 
136
162
  def extract_lunar_birthday(data)
137
163
  val = data['LunarBirthday']
138
164
  return nil unless val.respond_to?(:year)
139
165
 
140
- { year: val.year, month: val.month, day: val.day, label: '_$!<LunarBirthday>!$_' }
166
+ { year: val.year, month: val.month, day: val.day, label: 'LunarBirthday', raw_label: nil }
141
167
  end
142
168
 
143
169
  def extract_dates(data)
@@ -147,9 +173,13 @@ module Abbu
147
173
  val = entry['value']
148
174
  next unless val.respond_to?(:year)
149
175
 
150
- { year: val.year, month: val.month, day: val.day, label: entry['label'] }
176
+ { year: val.year, month: val.month, day: val.day, **label_fields(entry['label']) }
151
177
  end
152
178
  end
179
+
180
+ def label_fields(raw_label)
181
+ { label: Utils::LabelNormalizer.normalize(raw_label), raw_label: raw_label }
182
+ end
153
183
  end
154
184
  end
155
185
  end
@@ -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,115 +63,172 @@ 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
- SELECT g.ZFIRSTNAME
108
+ SELECT g.Z_PK, g.ZFIRSTNAME
85
109
  FROM Z_ABCDCONTACTGROUP j
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 do |row|
114
+ { record_id: row['Z_PK'], name: row['ZFIRSTNAME'] }
115
+ end
92
116
  end
93
117
 
94
118
  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
- []
119
+ query = 'SELECT ZURL, ZLABEL FROM ZABCDURLADDRESS WHERE ZOWNER = ?'
120
+ optional_rows(db, record_id, 'ZABCDURLADDRESS', query).map do |row|
121
+ { url: row['ZURL'], **label_fields(row['ZLABEL']) }
122
+ end
101
123
  end
102
124
 
103
125
  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
- []
126
+ query = 'SELECT ZTEXT FROM ZABCDNOTE WHERE ZCONTACT = ?'
127
+ optional_rows(db, record_id, 'ZABCDNOTE', query).filter_map { |row| row['ZTEXT'] }
110
128
  end
111
129
 
112
130
  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
- []
131
+ query = 'SELECT ZNAME, ZLABEL FROM ZABCDRELATEDNAME WHERE ZOWNER = ?'
132
+ optional_rows(db, record_id, 'ZABCDRELATEDNAME', query).map do |row|
133
+ { name: row['ZNAME'], **label_fields(row['ZLABEL']) }
134
+ end
119
135
  end
120
136
 
121
137
  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
- []
138
+ query = 'SELECT ZSERVICENAME, ZUSERNAME FROM ZABCDSOCIALPROFILE WHERE ZOWNER = ?'
139
+ optional_rows(db, record_id, 'ZABCDSOCIALPROFILE', query).map do |row|
140
+ { service: row['ZSERVICENAME'], username: row['ZUSERNAME'] }
141
+ end
128
142
  end
129
143
 
130
144
  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
- []
145
+ query = 'SELECT ZYEAR, ZMONTH, ZDAY, ZLABEL FROM ZABCDDATECOMPONENTS WHERE ZOWNER = ?'
146
+ optional_rows(db, record_id, 'ZABCDDATECOMPONENTS', query).map { |row| date_from(row) }
139
147
  end
140
148
 
141
149
  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'] }
150
+ query = 'SELECT ZADDRESS, ZLABEL, ZSERVICENAME FROM ZABCDMESSAGINGADDRESS WHERE ZOWNER = ?'
151
+ optional_rows(db, record_id, 'ZABCDMESSAGINGADDRESS', query).map do |row|
152
+ { address: row['ZADDRESS'], service: row['ZSERVICENAME'], **label_fields(row['ZLABEL']) }
147
153
  end
148
- rescue SQLite3::SQLException
149
- []
150
154
  end
151
155
 
152
- def build_contact(db, row)
156
+ def optional_rows(db, record_id, table, query)
157
+ return missing_optional_table(table) unless table_exists?(db, table)
158
+
159
+ db.execute(query, record_id)
160
+ end
161
+
162
+ def missing_optional_table(table)
163
+ key = [@active_db_path.to_s, table]
164
+ return [] if @missing_optional_tables[key]
165
+
166
+ @missing_optional_tables[key] = true
167
+ recover(optional_diagnostic(table), fallback: [])
168
+ end
169
+
170
+ def optional_diagnostic(table)
171
+ Diagnostic.new(
172
+ category: :missing_optional_data,
173
+ message: 'Optional SQLite table is unavailable',
174
+ parser: :sqlite,
175
+ source: @active_db_path,
176
+ context: { table: table }.freeze
177
+ )
178
+ end
179
+
180
+ def fail_required_schema(db_path)
181
+ diagnostic = Diagnostic.new(
182
+ category: :required_schema,
183
+ message: 'Required SQLite contact schema is unavailable',
184
+ parser: :sqlite,
185
+ source: db_path,
186
+ context: { table: 'ZABCDRECORD' }.freeze
187
+ )
188
+ diagnostics << diagnostic
189
+ raise ParseError, diagnostic
190
+ end
191
+
192
+ def build_contact(db, row, db_path)
153
193
  contact = Contact.new
154
194
  assign_flat_fields(contact, row)
155
195
  assign_relational_fields(contact, db, row['Z_PK'])
196
+ assign_metadata(contact, row, db_path)
156
197
  contact
157
198
  end
158
199
 
200
+ def recover(diagnostic, fallback:)
201
+ diagnostics << diagnostic
202
+ raise ParseError, diagnostic if @strict
203
+
204
+ fallback
205
+ end
206
+
207
+ def assign_metadata(contact, row, db_path)
208
+ contact.created_at = apple_time(row['ZCREATIONDATE'])
209
+ contact.modified_at = apple_time(row['ZMODIFICATIONDATE'])
210
+ contact.source = Utils::SourceDescriptor.new(db_path, root_path: @root_path).to_h
211
+ end
212
+
213
+ def apple_time(value)
214
+ return if value.nil?
215
+
216
+ Time.at(Float(value) + APPLE_EPOCH_OFFSET).utc
217
+ rescue ArgumentError, RangeError, TypeError
218
+ nil
219
+ end
220
+
221
+ def label_fields(raw_label)
222
+ { label: Utils::LabelNormalizer.normalize(raw_label), raw_label: raw_label }
223
+ end
224
+
225
+ def date_from(row)
226
+ {
227
+ year: row['ZYEAR'], month: row['ZMONTH'], day: row['ZDAY'],
228
+ **label_fields(row['ZLABEL'])
229
+ }
230
+ end
231
+
159
232
  def assign_flat_fields(contact, row)
160
233
  RECORD_FIELD_MAP.each do |column, attr|
161
234
  contact.public_send(:"#{attr}=", row[column])
@@ -166,7 +239,8 @@ module Abbu
166
239
  contact.emails = emails_for(db, record_id)
167
240
  contact.phones = phones_for(db, record_id)
168
241
  contact.addresses = addresses_for(db, record_id)
169
- contact.groups = groups_for(db, record_id)
242
+ contact.group_memberships = groups_for(db, record_id)
243
+ contact.groups = contact.group_memberships.map { |membership| membership[:name] }
170
244
  contact.urls = urls_for(db, record_id)
171
245
  contact.notes = notes_for(db, record_id)
172
246
  contact.related_names = related_names_for(db, record_id)
@@ -175,9 +249,9 @@ module Abbu
175
249
 
176
250
  all_dates = dates_for(db, record_id)
177
251
  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>!$_' }
252
+ contact.birthday = all_dates.find { |d| d[:label] == 'Birthday' }
253
+ contact.anniversary = all_dates.find { |d| d[:label] == 'Anniversary' }
254
+ contact.lunar_birthday = all_dates.find { |d| d[:label] == 'LunarBirthday' }
181
255
  end
182
256
  end
183
257
  end
data/lib/abbu/query.rb ADDED
@@ -0,0 +1,92 @@
1
+ # lib/abbu/query.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'timestamp_range'
5
+
6
+ module Abbu
7
+ class Query
8
+ include Enumerable
9
+
10
+ def initialize(contacts)
11
+ @contacts = contacts.to_a.freeze
12
+ end
13
+
14
+ def each(&)
15
+ @contacts.each(&)
16
+ end
17
+
18
+ def where(criteria)
19
+ self.class.new(select { |contact| matches_criteria?(contact, criteria) })
20
+ end
21
+
22
+ def search(term)
23
+ normalized_term = normalize_email(term)
24
+ return self.class.new([]) if normalized_term.empty?
25
+
26
+ self.class.new(select do |contact|
27
+ normalize_text(contact.full_name).include?(normalized_term) ||
28
+ contact.emails.any? { |email| normalize_email(email[:address]).include?(normalized_term) }
29
+ end)
30
+ end
31
+
32
+ def find_by_email(email)
33
+ normalized_email = normalize_email(email)
34
+ return self.class.new([]) if normalized_email.empty?
35
+
36
+ self.class.new(select do |contact|
37
+ contact.emails.any? { |entry| normalize_email(entry[:address]) == normalized_email }
38
+ end)
39
+ end
40
+
41
+ def find_by_phone(phone)
42
+ normalized_phone = normalize_phone(phone)
43
+ return self.class.new([]) if normalized_phone.empty?
44
+
45
+ self.class.new(select do |contact|
46
+ contact.phones.any? { |entry| normalize_phone(entry[:number]) == normalized_phone }
47
+ end)
48
+ end
49
+
50
+ def to_a
51
+ @contacts.dup
52
+ end
53
+
54
+ def in_group(group)
55
+ self.class.new(select { |contact| group.include?(contact) })
56
+ end
57
+
58
+ def modified_since(value)
59
+ date_range(:modified_at, since: value)
60
+ end
61
+
62
+ def created_since(value)
63
+ date_range(:created_at, since: value)
64
+ end
65
+
66
+ def date_range(field, since: nil, before: nil)
67
+ range = TimestampRange.new(field, since: since, before: before)
68
+ self.class.new(select { |contact| range.include?(contact.public_send(field)) })
69
+ end
70
+
71
+ private
72
+
73
+ def matches_criteria?(contact, criteria)
74
+ criteria.all? do |field, expected|
75
+ actual = contact.public_send(field)
76
+ actual.is_a?(Array) ? actual.include?(expected) : actual == expected
77
+ end
78
+ end
79
+
80
+ def normalize_email(value)
81
+ normalize_text(value).strip
82
+ end
83
+
84
+ def normalize_phone(value)
85
+ value.to_s.gsub(/[^0-9]/, '')
86
+ end
87
+
88
+ def normalize_text(value)
89
+ value.to_s.unicode_normalize(:nfc).downcase
90
+ end
91
+ end
92
+ 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