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.
- checksums.yaml +4 -4
- data/README.md +120 -8
- data/bin/abbu +185 -54
- data/docs/ABBU.md +226 -7
- data/docs/CHANGELOG.md +126 -0
- data/docs/CONTRIBUTING.md +13 -3
- data/docs/TODO.md +70 -62
- data/lib/abbu/archive.rb +66 -6
- data/lib/abbu/contact.rb +3 -1
- data/lib/abbu/diagnostic.rb +27 -0
- data/lib/abbu/exporters/csv_exporter.rb +2 -2
- data/lib/abbu/exporters/json_exporter.rb +7 -1
- data/lib/abbu/exporters/vcard_exporter.rb +19 -1
- 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 +41 -11
- data/lib/abbu/parsers/sqlite_parser.rb +134 -63
- 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 +19 -15
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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] == '
|
|
179
|
-
contact.anniversary = all_dates.find { |d| d[:label] == '
|
|
180
|
-
contact.lunar_birthday = all_dates.find { |d| d[:label] == '
|
|
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
|