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.
- checksums.yaml +4 -4
- data/README.md +281 -8
- data/bin/abbu +230 -53
- data/docs/ABBU.md +369 -7
- data/docs/API_STABILITY.md +189 -0
- data/docs/CHANGELOG.md +179 -0
- data/docs/CONTRIBUTING.md +25 -3
- data/docs/FORMAT_COMPATIBILITY.md +97 -0
- data/docs/RELEASING.md +75 -0
- data/docs/TODO.md +73 -62
- data/lib/abbu/archive.rb +80 -6
- data/lib/abbu/contact.rb +6 -3
- 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_document.rb +54 -0
- data/lib/abbu/exporters/vcard_encoding.rb +54 -0
- data/lib/abbu/exporters/vcard_exporter.rb +44 -27
- data/lib/abbu/group.rb +28 -0
- data/lib/abbu/group_catalog.rb +32 -0
- data/lib/abbu/image_extractor.rb +134 -0
- data/lib/abbu/live_store.rb +117 -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 +139 -65
- data/lib/abbu/query.rb +92 -0
- data/lib/abbu/schema_inspector.rb +133 -0
- data/lib/abbu/source.rb +53 -0
- data/lib/abbu/source_catalog.rb +32 -0
- data/lib/abbu/timestamp_range.rb +40 -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 +15 -2
- data/sig/group.rbs +36 -0
- data/sig/query.rbs +21 -0
- data/sig/source.rbs +25 -0
- data/sig/vcard_exporter.rbs +10 -0
- data/tasks/abbu.rake +1 -1
- metadata +33 -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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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] == '
|
|
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'],
|
|
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
|
-
|
|
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'],
|
|
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: '
|
|
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: '
|
|
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,
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
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
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
[]
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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.
|
|
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] == '
|
|
179
|
-
contact.anniversary = all_dates.find { |d| d[:label] == '
|
|
180
|
-
contact.lunar_birthday = all_dates.find { |d| d[:label] == '
|
|
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
|