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
data/lib/abbu/contact.rb
CHANGED
|
@@ -4,19 +4,22 @@
|
|
|
4
4
|
module Abbu
|
|
5
5
|
class Contact
|
|
6
6
|
attr_accessor :first_name, :middle_name, :last_name, :emails,
|
|
7
|
-
:phones, :company, :addresses, :groups, :nickname,
|
|
7
|
+
:phones, :company, :addresses, :groups, :group_memberships, :nickname,
|
|
8
8
|
:prefix, :suffix, :job_title, :department, :maiden_name,
|
|
9
9
|
:phonetic_first_name, :phonetic_middle_name, :phonetic_last_name,
|
|
10
10
|
:phonetic_company, :pronouns, :ringtone, :texttone,
|
|
11
11
|
:urls, :notes, :related_names, :social_profiles,
|
|
12
12
|
:birthday, :anniversary, :dates, :instant_messages,
|
|
13
|
-
:verification_code, :lunar_birthday
|
|
13
|
+
:verification_code, :lunar_birthday,
|
|
14
|
+
:image_uri, :image_path,
|
|
15
|
+
:created_at, :modified_at, :source
|
|
14
16
|
|
|
15
|
-
def initialize
|
|
17
|
+
def initialize # rubocop:disable Metrics/MethodLength
|
|
16
18
|
@emails = []
|
|
17
19
|
@phones = []
|
|
18
20
|
@addresses = []
|
|
19
21
|
@groups = []
|
|
22
|
+
@group_memberships = []
|
|
20
23
|
@urls = []
|
|
21
24
|
@notes = []
|
|
22
25
|
@related_names = []
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# lib/abbu/diagnostic.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Abbu
|
|
5
|
+
class Diagnostic
|
|
6
|
+
attr_reader :category, :context, :message, :parser, :source
|
|
7
|
+
|
|
8
|
+
def initialize(category:, message:, parser:, source:, context: {})
|
|
9
|
+
@category = category.to_sym
|
|
10
|
+
@context = context.freeze
|
|
11
|
+
@message = message.to_s.freeze
|
|
12
|
+
@parser = parser.to_sym
|
|
13
|
+
@source = source.to_s.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
category: category,
|
|
20
|
+
message: message,
|
|
21
|
+
parser: parser,
|
|
22
|
+
source: source,
|
|
23
|
+
context: context
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -28,7 +28,7 @@ module Abbu
|
|
|
28
28
|
|
|
29
29
|
def headers
|
|
30
30
|
%w[Name First Middle Last Email Phone Company Address Groups URLs Notes RelatedNames SocialProfiles Birthday
|
|
31
|
-
Anniversary InstantMessages VerificationCode LunarBirthday]
|
|
31
|
+
Anniversary InstantMessages VerificationCode LunarBirthday ImagePath]
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def row(contact)
|
|
@@ -50,7 +50,7 @@ module Abbu
|
|
|
50
50
|
format_related_names(contact.related_names), format_social_profiles(contact.social_profiles),
|
|
51
51
|
format_date(contact.birthday), format_date(contact.anniversary),
|
|
52
52
|
format_instant_messages(contact.instant_messages), contact.verification_code,
|
|
53
|
-
format_date(contact.lunar_birthday)
|
|
53
|
+
format_date(contact.lunar_birthday), contact.image_path.to_s
|
|
54
54
|
]
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'time'
|
|
5
6
|
|
|
6
7
|
module Abbu
|
|
7
8
|
module Exporters
|
|
@@ -52,7 +53,12 @@ module Abbu
|
|
|
52
53
|
dates: contact.dates,
|
|
53
54
|
instant_messages: contact.instant_messages,
|
|
54
55
|
verification_code: contact.verification_code,
|
|
55
|
-
lunar_birthday: contact.lunar_birthday
|
|
56
|
+
lunar_birthday: contact.lunar_birthday,
|
|
57
|
+
image_uri: contact.image_uri,
|
|
58
|
+
image_path: contact.image_path&.to_s,
|
|
59
|
+
created_at: contact.created_at&.iso8601,
|
|
60
|
+
modified_at: contact.modified_at&.iso8601,
|
|
61
|
+
source: contact.source
|
|
56
62
|
}.compact
|
|
57
63
|
end
|
|
58
64
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# lib/abbu/exporters/vcard_document.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'vcard_encoding'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
module Exporters
|
|
8
|
+
# Per-card grouping avoids ambiguous associations between repeated labels.
|
|
9
|
+
class VcardDocument
|
|
10
|
+
TYPES = {
|
|
11
|
+
'EMAIL' => %w[INTERNET X400 PREF],
|
|
12
|
+
'TEL' => %w[HOME WORK PREF VOICE FAX MSG CELL PAGER BBS MODEM CAR ISDN VIDEO PCS],
|
|
13
|
+
'ADR' => %w[DOM INTL POSTAL PARCEL HOME WORK PREF],
|
|
14
|
+
'IMPP' => %w[PERSONAL BUSINESS HOME WORK MOBILE PREF]
|
|
15
|
+
}.transform_values(&:freeze).freeze
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@lines = ['BEGIN:VCARD', 'VERSION:3.0']
|
|
19
|
+
@group_number = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def <<(line)
|
|
23
|
+
@lines << line
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def labeled(property, value, entry, default_type: nil)
|
|
27
|
+
type = standard_type(property, entry[:label]) || default_type
|
|
28
|
+
header = type ? "#{property};TYPE=#{type}" : property
|
|
29
|
+
label = entry[:raw_label] || entry[:label]
|
|
30
|
+
return self << "#{header}:#{value}" if label.nil?
|
|
31
|
+
|
|
32
|
+
@group_number += 1
|
|
33
|
+
self << "item#{@group_number}.#{header}:#{value}"
|
|
34
|
+
self << "item#{@group_number}.X-ABLABEL:#{VcardEncoding.text(label)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_s
|
|
38
|
+
lines = (@lines + ['END:VCARD']).map { |line| VcardEncoding.fold(line) }
|
|
39
|
+
"#{lines.join("\r\n")}\r\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def standard_type(property, label)
|
|
45
|
+
return unless label.to_s.match?(/\A[A-Za-z0-9-]+\z/)
|
|
46
|
+
|
|
47
|
+
type = label.upcase
|
|
48
|
+
return 'INTERNET,PREF' if property == 'EMAIL' && type == 'PREF'
|
|
49
|
+
|
|
50
|
+
type if TYPES.fetch(property, []).include?(type)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# lib/abbu/exporters/vcard_encoding.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
module Exporters
|
|
8
|
+
# Internal wire encoding; URI values and TEXT values have different grammars.
|
|
9
|
+
module VcardEncoding
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def text(value)
|
|
13
|
+
utf8(value).gsub(/\\|;|,|\r\n|\r|\n/) do |character|
|
|
14
|
+
character.match?(/[\r\n]/) ? '\\n' : "\\#{character}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def structured(values)
|
|
19
|
+
values.map { |value| text(value) }.join(';')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def uri(value)
|
|
23
|
+
URI::DEFAULT_PARSER.escape(utf8(value), %r{[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def token(value)
|
|
27
|
+
string = utf8(value)
|
|
28
|
+
raise ArgumentError, 'vCard parameter must be an ASCII token' unless string.match?(/\A[A-Za-z0-9-]+\z/)
|
|
29
|
+
|
|
30
|
+
string
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fold(line)
|
|
34
|
+
parts = [+'']
|
|
35
|
+
utf8(line).each_char do |character|
|
|
36
|
+
parts << +' ' if parts.last.bytesize + character.bytesize > 75
|
|
37
|
+
parts.last << character
|
|
38
|
+
end
|
|
39
|
+
parts.join("\r\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def utf8(value)
|
|
43
|
+
string = value.to_s.encode(Encoding::UTF_8)
|
|
44
|
+
raise ArgumentError, 'vCard text has invalid encoding' unless string.valid_encoding?
|
|
45
|
+
if string.match?(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/)
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
'vCard text contains an unsupported control character'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
string
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# lib/abbu/exporters/vcard_exporter.rb
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
require_relative 'vcard_document'
|
|
7
|
+
|
|
4
8
|
module Abbu
|
|
5
9
|
module Exporters
|
|
6
10
|
class VcardExporter
|
|
@@ -9,21 +13,21 @@ module Abbu
|
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def to_file(path)
|
|
12
|
-
File.
|
|
16
|
+
File.binwrite(path, generate)
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def to_stdout
|
|
16
|
-
|
|
20
|
+
print generate
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
private
|
|
20
24
|
|
|
21
25
|
def generate
|
|
22
|
-
@contacts.map { |c| vcard_for(c) }.join
|
|
26
|
+
@contacts.map { |c| vcard_for(c) }.join
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def vcard_for(contact) # rubocop:disable Metrics/MethodLength
|
|
26
|
-
lines =
|
|
30
|
+
lines = VcardDocument.new
|
|
27
31
|
|
|
28
32
|
append_name_fields(lines, contact)
|
|
29
33
|
append_emails(lines, contact)
|
|
@@ -35,14 +39,14 @@ module Abbu
|
|
|
35
39
|
append_instant_messages(lines, contact)
|
|
36
40
|
append_verification_code(lines, contact)
|
|
37
41
|
append_notes(lines, contact)
|
|
42
|
+
append_photo(lines, contact)
|
|
38
43
|
|
|
39
|
-
lines
|
|
40
|
-
lines.join("\n")
|
|
44
|
+
lines.to_s
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
def append_name_fields(lines, contact)
|
|
44
|
-
lines << "FN:#{contact.full_name}"
|
|
45
|
-
lines << "N:#{name_components(contact)
|
|
48
|
+
lines << "FN:#{VcardEncoding.text(contact.full_name)}"
|
|
49
|
+
lines << "N:#{VcardEncoding.structured(name_components(contact))}"
|
|
46
50
|
append_nickname(lines, contact)
|
|
47
51
|
append_company(lines, contact)
|
|
48
52
|
append_title(lines, contact)
|
|
@@ -54,68 +58,81 @@ module Abbu
|
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def append_nickname(lines, contact)
|
|
57
|
-
lines << "NICKNAME:#{contact.nickname}" if contact.nickname
|
|
61
|
+
lines << "NICKNAME:#{VcardEncoding.text(contact.nickname)}" if contact.nickname
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def append_company(lines, contact)
|
|
61
|
-
lines << "ORG:#{contact.company}" if contact.company
|
|
65
|
+
lines << "ORG:#{VcardEncoding.text(contact.company)}" if contact.company
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
def append_title(lines, contact)
|
|
65
|
-
lines << "TITLE:#{contact.job_title}" if contact.job_title
|
|
69
|
+
lines << "TITLE:#{VcardEncoding.text(contact.job_title)}" if contact.job_title
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
def append_phonetic_names(lines, contact)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
%i[first middle last].each do |part|
|
|
74
|
+
value = contact.public_send(:"phonetic_#{part}_name")
|
|
75
|
+
lines << "X-PHONETIC-#{part.upcase}-NAME:#{VcardEncoding.text(value)}" if value
|
|
76
|
+
end
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
def append_verification_code(lines, contact)
|
|
75
|
-
lines << "X-VERIFICATION-CODE:#{contact.verification_code}" if contact.verification_code
|
|
80
|
+
lines << "X-VERIFICATION-CODE:#{VcardEncoding.text(contact.verification_code)}" if contact.verification_code
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def append_photo(lines, contact)
|
|
84
|
+
return unless contact.image_path
|
|
85
|
+
|
|
86
|
+
lines << "PHOTO;VALUE=URI:#{file_uri(contact.image_path)}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def file_uri(path)
|
|
90
|
+
escaped_path = URI::DEFAULT_PARSER.escape(path.to_s)
|
|
91
|
+
URI::Generic.build(scheme: 'file', path: escaped_path)
|
|
76
92
|
end
|
|
77
93
|
|
|
78
94
|
def append_notes(lines, contact)
|
|
79
|
-
contact.notes.each { |n| lines << "NOTE:#{n}" }
|
|
95
|
+
contact.notes.each { |n| lines << "NOTE:#{VcardEncoding.text(n)}" }
|
|
80
96
|
end
|
|
81
97
|
|
|
82
98
|
def append_emails(lines, contact)
|
|
83
99
|
contact.emails.each do |e|
|
|
84
|
-
|
|
85
|
-
lines << "EMAIL;TYPE=#{label}:#{e[:address]}"
|
|
100
|
+
lines.labeled('EMAIL', VcardEncoding.text(e[:address]), e, default_type: 'INTERNET')
|
|
86
101
|
end
|
|
87
102
|
end
|
|
88
103
|
|
|
89
104
|
def append_phones(lines, contact)
|
|
90
105
|
contact.phones.each do |p|
|
|
91
|
-
|
|
92
|
-
lines << "TEL;TYPE=#{label}:#{p[:number]}"
|
|
106
|
+
lines.labeled('TEL', VcardEncoding.text(p[:number]), p, default_type: 'VOICE')
|
|
93
107
|
end
|
|
94
108
|
end
|
|
95
109
|
|
|
96
110
|
def append_addresses(lines, contact)
|
|
97
111
|
contact.addresses.each do |a|
|
|
98
|
-
|
|
99
|
-
lines
|
|
112
|
+
value = VcardEncoding.structured([nil, nil, a[:street], a[:city], a[:state], a[:zip], a[:country]])
|
|
113
|
+
lines.labeled('ADR', value, a)
|
|
100
114
|
end
|
|
101
115
|
end
|
|
102
116
|
|
|
103
117
|
def append_urls(lines, contact)
|
|
104
118
|
contact.urls.each do |u|
|
|
105
|
-
lines
|
|
119
|
+
lines.labeled('URL', VcardEncoding.uri(u[:url]), u)
|
|
106
120
|
end
|
|
107
121
|
end
|
|
108
122
|
|
|
109
123
|
def append_social_profiles(lines, contact)
|
|
110
124
|
contact.social_profiles.each do |sp|
|
|
111
|
-
|
|
125
|
+
type = sp[:service] ? ";TYPE=#{VcardEncoding.token(sp[:service])}" : ''
|
|
126
|
+
lines << "X-SOCIALPROFILE#{type}:#{VcardEncoding.text(sp[:username])}"
|
|
112
127
|
end
|
|
113
128
|
end
|
|
114
129
|
|
|
115
130
|
def append_instant_messages(lines, contact)
|
|
116
131
|
contact.instant_messages.each do |im|
|
|
117
132
|
service = im[:service]&.downcase || 'unknown'
|
|
118
|
-
|
|
133
|
+
raise ArgumentError, 'IM service must be a URI scheme' unless service.match?(/\A[a-z][a-z0-9+.-]*\z/)
|
|
134
|
+
|
|
135
|
+
lines.labeled('IMPP', "#{service}:#{VcardEncoding.uri(im[:address])}", im)
|
|
119
136
|
end
|
|
120
137
|
end
|
|
121
138
|
|
|
@@ -124,8 +141,8 @@ module Abbu
|
|
|
124
141
|
lines << "X-LUNAR-BDAY:#{format_vcard_date(contact.lunar_birthday)}" if contact.lunar_birthday
|
|
125
142
|
return unless contact.anniversary
|
|
126
143
|
|
|
127
|
-
|
|
128
|
-
lines
|
|
144
|
+
entry = { label: 'Anniversary' }.merge(contact.anniversary.compact)
|
|
145
|
+
lines.labeled('X-ABDATE', format_vcard_date(contact.anniversary), entry)
|
|
129
146
|
end
|
|
130
147
|
|
|
131
148
|
def format_vcard_date(date)
|
data/lib/abbu/group.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# lib/abbu/group.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'query'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
# A snapshot of an observed SQLite group membership, local to one input file.
|
|
8
|
+
# Names and record keys are evidence, not globally unique Apple identifiers.
|
|
9
|
+
class Group
|
|
10
|
+
attr_reader :record_id, :name, :source, :contacts
|
|
11
|
+
|
|
12
|
+
def initialize(record_id:, name:, source:, contacts:)
|
|
13
|
+
@record_id = record_id
|
|
14
|
+
@name = name&.dup&.freeze
|
|
15
|
+
@source = source.transform_values { |value| value&.dup&.freeze }.freeze
|
|
16
|
+
@contacts = Query.new(contacts.to_a.uniq).freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def include?(contact)
|
|
21
|
+
contacts.any? { |member| member.equal?(contact) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{ record_id: record_id, name: name, source: source, contact_count: contacts.count }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# lib/abbu/group_catalog.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'group'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
class GroupCatalog
|
|
8
|
+
def initialize(contacts)
|
|
9
|
+
@contacts = contacts
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def groups
|
|
13
|
+
entries_by_key = memberships.group_by { |contact, membership| [contact.source[:path], membership[:record_id]] }
|
|
14
|
+
groups = entries_by_key.values.map { |entries| build_group(entries) }
|
|
15
|
+
groups.sort_by { |group| [group.source[:relative_path], group.record_id] }.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def memberships
|
|
21
|
+
@contacts.flat_map do |contact|
|
|
22
|
+
contact.group_memberships.map { |membership| [contact, membership] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_group(entries)
|
|
27
|
+
contact, membership = entries.first
|
|
28
|
+
Group.new(record_id: membership[:record_id], name: membership[:name], source: contact.source,
|
|
29
|
+
contacts: entries.map(&:first))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# lib/abbu/image_extractor.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'pathname'
|
|
7
|
+
|
|
8
|
+
module Abbu
|
|
9
|
+
class ImageExtractor
|
|
10
|
+
Result = Struct.new(:files, :diagnostics, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
SIGNATURES = {
|
|
13
|
+
jpeg: { extension: 'jpg', media_type: 'image/jpeg' },
|
|
14
|
+
png: { extension: 'png', media_type: 'image/png' },
|
|
15
|
+
gif: { extension: 'gif', media_type: 'image/gif' },
|
|
16
|
+
heic: { extension: 'heic', media_type: 'image/heic' }
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(contacts)
|
|
20
|
+
@contacts = contacts
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def extract(output_dir)
|
|
24
|
+
destination = Pathname.new(output_dir).expand_path
|
|
25
|
+
FileUtils.mkdir_p(destination)
|
|
26
|
+
destination = destination.realpath
|
|
27
|
+
files = []
|
|
28
|
+
diagnostics = []
|
|
29
|
+
used_names = Hash.new(0)
|
|
30
|
+
|
|
31
|
+
@contacts.each do |contact|
|
|
32
|
+
extract_contact(contact, destination, files, diagnostics, used_names)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Result.new(files: files.freeze, diagnostics: diagnostics.freeze).freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def extract_contact(contact, destination, files, diagnostics, used_names)
|
|
41
|
+
return unless contact.image_uri
|
|
42
|
+
|
|
43
|
+
source = contact.image_path
|
|
44
|
+
return add_diagnostic(diagnostics, :missing_image, contact, source) unless source&.file?
|
|
45
|
+
|
|
46
|
+
type = detect_type(source)
|
|
47
|
+
return add_diagnostic(diagnostics, :unsupported_image, contact, source) unless type
|
|
48
|
+
|
|
49
|
+
target = destination.join(unique_filename(contact, source, type[:extension], used_names))
|
|
50
|
+
return add_diagnostic(diagnostics, :destination_exists, contact, source) unless copy_image(source, target)
|
|
51
|
+
|
|
52
|
+
files << file_record(contact, source, target, type)
|
|
53
|
+
rescue IOError, SystemCallError => e
|
|
54
|
+
add_diagnostic(diagnostics, :unreadable_image, contact, source, e.message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def copy_image(source, target)
|
|
58
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |output|
|
|
59
|
+
source.open('rb') { |input| IO.copy_stream(input, output) }
|
|
60
|
+
end
|
|
61
|
+
true
|
|
62
|
+
rescue Errno::EEXIST
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def detect_type(path)
|
|
67
|
+
header = path.open('rb') { |file| file.read(32) }.to_s.b
|
|
68
|
+
SIGNATURES[kind_for(header)]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def kind_for(header)
|
|
72
|
+
return :jpeg if header.start_with?("\xFF\xD8\xFF".b)
|
|
73
|
+
return :png if header.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
74
|
+
return :gif if header.start_with?('GIF87a', 'GIF89a')
|
|
75
|
+
|
|
76
|
+
:heic if heic?(header)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def heic?(header)
|
|
80
|
+
header.byteslice(4, 4) == 'ftyp' && %w[heic heix hevc hevx mif1 msf1].include?(header.byteslice(8, 4))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def unique_filename(contact, source, extension, used_names)
|
|
84
|
+
readable = sanitize(contact.full_name)
|
|
85
|
+
readable = 'contact' if readable.empty?
|
|
86
|
+
identifier = sanitize(contact.image_uri.to_s)
|
|
87
|
+
identifier = 'image' if identifier.empty?
|
|
88
|
+
fingerprint = Digest::SHA256.hexdigest(provenance_key(contact, source))[0, 10]
|
|
89
|
+
base = [readable, identifier, fingerprint].join('--')
|
|
90
|
+
used_names[base] += 1
|
|
91
|
+
suffix = used_names[base] == 1 ? '' : "-#{used_names[base]}"
|
|
92
|
+
"#{base}#{suffix}.#{extension}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sanitize(value)
|
|
96
|
+
value.unicode_normalize(:nfc)
|
|
97
|
+
.gsub(%r{[\\/:*?"<>|\p{Cntrl}]+}u, '-')
|
|
98
|
+
.gsub(/[^\p{Alnum}\p{Mark}_ -]+/u, '-')
|
|
99
|
+
.strip
|
|
100
|
+
.gsub(/[ .-]+\z/, '')
|
|
101
|
+
.gsub(/\s+/, '-')
|
|
102
|
+
.squeeze('-')
|
|
103
|
+
.then { |name| name[0, 80] }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def provenance_key(contact, source)
|
|
107
|
+
[contact.source&.fetch(:relative_path, nil), source.expand_path, contact.full_name, contact.image_uri].join("\0")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def file_record(contact, source, target, type)
|
|
111
|
+
{
|
|
112
|
+
contact: contact,
|
|
113
|
+
image_uri: contact.image_uri,
|
|
114
|
+
source_path: source.expand_path,
|
|
115
|
+
path: target,
|
|
116
|
+
media_type: type[:media_type]
|
|
117
|
+
}.freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def diagnostic(code, contact, source, detail = nil)
|
|
121
|
+
{
|
|
122
|
+
code: code,
|
|
123
|
+
contact_name: contact.full_name,
|
|
124
|
+
image_uri: contact.image_uri,
|
|
125
|
+
source_path: source&.expand_path,
|
|
126
|
+
detail: detail
|
|
127
|
+
}.compact.freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def add_diagnostic(diagnostics, code, contact, source, detail = nil)
|
|
131
|
+
diagnostics << diagnostic(code, contact, source, detail)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# lib/abbu/live_store.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'sqlite3'
|
|
6
|
+
require_relative 'parsers/sqlite_parser'
|
|
7
|
+
require_relative 'source_catalog'
|
|
8
|
+
|
|
9
|
+
module Abbu
|
|
10
|
+
class LiveStore
|
|
11
|
+
DATABASE_GLOB = 'AddressBook-v*.abcddb'
|
|
12
|
+
DEFAULT_RELATIVE_PATH = 'Library/Application Support/AddressBook'
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class NotFoundError < Error; end
|
|
16
|
+
class PermissionError < Error; end
|
|
17
|
+
class UnsupportedPlatformError < Error; end
|
|
18
|
+
|
|
19
|
+
attr_reader :diagnostics, :path
|
|
20
|
+
|
|
21
|
+
def self.default_path(home: Dir.home, platform: RUBY_PLATFORM)
|
|
22
|
+
unless platform.include?('darwin')
|
|
23
|
+
raise UnsupportedPlatformError,
|
|
24
|
+
'Automatic live Contacts discovery is available only on macOS; supply an AddressBook directory path.'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Pathname.new(home).join(DEFAULT_RELATIVE_PATH)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(path = nil, strict: false)
|
|
31
|
+
@strict = strict
|
|
32
|
+
@diagnostics = []
|
|
33
|
+
@path = Pathname.new(path || self.class.default_path).expand_path
|
|
34
|
+
validate!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def contacts
|
|
38
|
+
@contacts ||= Parsers::SqliteParser.new(
|
|
39
|
+
database_paths, root_path: @path, readonly: true, diagnostics: diagnostics, strict: @strict
|
|
40
|
+
).contacts
|
|
41
|
+
rescue SQLite3::CantOpenException, Errno::EACCES => e
|
|
42
|
+
raise permission_error(e.message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def database_paths
|
|
46
|
+
@database_paths ||= discover_database_paths.tap do |paths|
|
|
47
|
+
raise NotFoundError, missing_database_message if paths.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sources
|
|
52
|
+
@sources ||= SourceCatalog.new(database_paths, root_path: @path, contacts: contacts).sources
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def groups
|
|
56
|
+
@groups ||= sources.flat_map(&:groups).freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def groups_for(contact)
|
|
60
|
+
groups.select { |group| group.include?(contact) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def validate!
|
|
66
|
+
stat = @path.stat
|
|
67
|
+
raise NotFoundError, "Live Contacts store is not a directory: #{@path}" unless stat.directory?
|
|
68
|
+
|
|
69
|
+
ensure_readable_directory!(@path)
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
raise NotFoundError, "Live Contacts store not found: #{@path}"
|
|
72
|
+
rescue Errno::EACCES => e
|
|
73
|
+
raise permission_error(e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def discover_database_paths
|
|
77
|
+
paths = @path.glob(DATABASE_GLOB)
|
|
78
|
+
sources_path = @path.join('Sources')
|
|
79
|
+
paths.concat(source_database_paths(sources_path)) if sources_path.exist?
|
|
80
|
+
paths.sort.each { |db_path| ensure_readable_file!(db_path) }
|
|
81
|
+
rescue Errno::EACCES => e
|
|
82
|
+
raise permission_error(e.message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def source_database_paths(sources_path)
|
|
86
|
+
ensure_readable_directory!(sources_path)
|
|
87
|
+
sources_path.children.sort.select(&:directory?).flat_map do |source_path|
|
|
88
|
+
ensure_readable_directory!(source_path)
|
|
89
|
+
source_path.glob(DATABASE_GLOB)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def ensure_readable_directory!(path)
|
|
94
|
+
return if path.readable? && path.executable?
|
|
95
|
+
|
|
96
|
+
raise permission_error("directory is not readable: #{path}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ensure_readable_file!(path)
|
|
100
|
+
return if path.file? && path.readable?
|
|
101
|
+
|
|
102
|
+
raise permission_error("database is not readable: #{path}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def missing_database_message
|
|
106
|
+
"No #{DATABASE_GLOB} databases found in live Contacts store: #{@path}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def permission_error(detail)
|
|
110
|
+
PermissionError.new(
|
|
111
|
+
"Cannot read live Contacts store at #{@path} (#{detail}). " \
|
|
112
|
+
'Grant Full Disk Access to the terminal or application running abbu in ' \
|
|
113
|
+
'System Settings > Privacy & Security > Full Disk Access, then retry.'
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|