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
data/lib/abbu/archive.rb
CHANGED
|
@@ -2,26 +2,62 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'pathname'
|
|
5
|
-
require_relative '
|
|
5
|
+
require_relative 'diagnostic'
|
|
6
|
+
require_relative 'image_extractor'
|
|
7
|
+
require_relative 'parse_error'
|
|
6
8
|
require_relative 'parsers/plist_parser'
|
|
9
|
+
require_relative 'parsers/sqlite_parser'
|
|
10
|
+
require_relative 'query'
|
|
11
|
+
require_relative 'schema_inspector'
|
|
12
|
+
require_relative 'utils/image_resolver'
|
|
7
13
|
|
|
8
14
|
module Abbu
|
|
9
15
|
class Archive
|
|
10
|
-
attr_reader :path
|
|
16
|
+
attr_reader :diagnostics, :path
|
|
11
17
|
|
|
12
|
-
def initialize(path)
|
|
18
|
+
def initialize(path, strict: false)
|
|
13
19
|
@path = Pathname.new(path)
|
|
20
|
+
@strict = strict
|
|
21
|
+
@diagnostics = []
|
|
14
22
|
validate!
|
|
15
23
|
end
|
|
16
24
|
|
|
17
25
|
def contacts
|
|
18
|
-
parser.contacts
|
|
26
|
+
@contacts ||= parser.contacts.tap { |cs| attach_images(cs) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def query
|
|
30
|
+
Query.new(contacts)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def where(criteria)
|
|
34
|
+
query.where(criteria)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search(term)
|
|
38
|
+
query.search(term)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_by_email(email)
|
|
42
|
+
query.find_by_email(email)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_by_phone(phone)
|
|
46
|
+
query.find_by_phone(phone)
|
|
19
47
|
end
|
|
20
48
|
|
|
21
49
|
def sqlite?
|
|
22
50
|
db_paths.any?
|
|
23
51
|
end
|
|
24
52
|
|
|
53
|
+
def extract_images(output_dir)
|
|
54
|
+
ImageExtractor.new(contacts).extract(output_dir)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def schema_report
|
|
58
|
+
SchemaInspector.new(db_paths, root_path: @path).report
|
|
59
|
+
end
|
|
60
|
+
|
|
25
61
|
private
|
|
26
62
|
|
|
27
63
|
def validate!
|
|
@@ -39,10 +75,34 @@ module Abbu
|
|
|
39
75
|
|
|
40
76
|
def parser
|
|
41
77
|
if sqlite?
|
|
42
|
-
Parsers::SqliteParser.new(db_paths)
|
|
78
|
+
Parsers::SqliteParser.new(db_paths, root_path: @path, diagnostics: diagnostics, strict: @strict)
|
|
43
79
|
else
|
|
44
|
-
Parsers::PlistParser.new(plist_paths)
|
|
80
|
+
Parsers::PlistParser.new(plist_paths, root_path: @path, diagnostics: diagnostics, strict: @strict)
|
|
45
81
|
end
|
|
46
82
|
end
|
|
83
|
+
|
|
84
|
+
def attach_images(contacts)
|
|
85
|
+
return if contacts.empty?
|
|
86
|
+
|
|
87
|
+
resolver = Utils::ImageResolver.new(@path)
|
|
88
|
+
contacts.each do |contact|
|
|
89
|
+
next unless contact.image_uri
|
|
90
|
+
|
|
91
|
+
contact.image_path = resolver.resolve(contact.image_uri, source: contact.source)
|
|
92
|
+
record_missing_image(contact) unless contact.image_path
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_missing_image(contact)
|
|
97
|
+
diagnostic = Diagnostic.new(
|
|
98
|
+
category: :missing_image,
|
|
99
|
+
message: 'Referenced contact image was not found',
|
|
100
|
+
parser: :archive,
|
|
101
|
+
source: contact.source&.fetch(:path, @path.to_s) || @path.to_s,
|
|
102
|
+
context: {}
|
|
103
|
+
)
|
|
104
|
+
diagnostics << diagnostic
|
|
105
|
+
raise ParseError, diagnostic if @strict
|
|
106
|
+
end
|
|
47
107
|
end
|
|
48
108
|
end
|
data/lib/abbu/contact.rb
CHANGED
|
@@ -10,7 +10,9 @@ module Abbu
|
|
|
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
17
|
def initialize
|
|
16
18
|
@emails = []
|
|
@@ -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
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# lib/abbu/exporters/vcard_exporter.rb
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
4
6
|
module Abbu
|
|
5
7
|
module Exporters
|
|
6
8
|
class VcardExporter
|
|
@@ -35,6 +37,7 @@ module Abbu
|
|
|
35
37
|
append_instant_messages(lines, contact)
|
|
36
38
|
append_verification_code(lines, contact)
|
|
37
39
|
append_notes(lines, contact)
|
|
40
|
+
append_photo(lines, contact)
|
|
38
41
|
|
|
39
42
|
lines << 'END:VCARD'
|
|
40
43
|
lines.join("\n")
|
|
@@ -75,6 +78,17 @@ module Abbu
|
|
|
75
78
|
lines << "X-VERIFICATION-CODE:#{contact.verification_code}" if contact.verification_code
|
|
76
79
|
end
|
|
77
80
|
|
|
81
|
+
def append_photo(lines, contact)
|
|
82
|
+
return unless contact.image_path
|
|
83
|
+
|
|
84
|
+
lines << "PHOTO;VALUE=URI:#{file_uri(contact.image_path)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def file_uri(path)
|
|
88
|
+
escaped_path = URI::DEFAULT_PARSER.escape(path.to_s)
|
|
89
|
+
URI::Generic.build(scheme: 'file', path: escaped_path)
|
|
90
|
+
end
|
|
91
|
+
|
|
78
92
|
def append_notes(lines, contact)
|
|
79
93
|
contact.notes.each { |n| lines << "NOTE:#{n}" }
|
|
80
94
|
end
|
|
@@ -125,7 +139,11 @@ module Abbu
|
|
|
125
139
|
return unless contact.anniversary
|
|
126
140
|
|
|
127
141
|
lines << "X-ABDATE;type=pref:#{format_vcard_date(contact.anniversary)}"
|
|
128
|
-
lines <<
|
|
142
|
+
lines << "X-ABLABEL:#{anniversary_label(contact.anniversary)}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def anniversary_label(anniversary)
|
|
146
|
+
anniversary[:raw_label] || anniversary[:label] || 'Anniversary'
|
|
129
147
|
end
|
|
130
148
|
|
|
131
149
|
def format_vcard_date(date)
|
|
@@ -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,104 @@
|
|
|
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
|
+
|
|
8
|
+
module Abbu
|
|
9
|
+
class LiveStore
|
|
10
|
+
DATABASE_GLOB = 'AddressBook-v*.abcddb'
|
|
11
|
+
DEFAULT_RELATIVE_PATH = 'Library/Application Support/AddressBook'
|
|
12
|
+
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class NotFoundError < Error; end
|
|
15
|
+
class PermissionError < Error; end
|
|
16
|
+
class UnsupportedPlatformError < Error; end
|
|
17
|
+
|
|
18
|
+
attr_reader :diagnostics, :path
|
|
19
|
+
|
|
20
|
+
def self.default_path(home: Dir.home, platform: RUBY_PLATFORM)
|
|
21
|
+
unless platform.include?('darwin')
|
|
22
|
+
raise UnsupportedPlatformError,
|
|
23
|
+
'Automatic live Contacts discovery is available only on macOS; supply an AddressBook directory path.'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Pathname.new(home).join(DEFAULT_RELATIVE_PATH)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(path = nil, strict: false)
|
|
30
|
+
@strict = strict
|
|
31
|
+
@diagnostics = []
|
|
32
|
+
@path = Pathname.new(path || self.class.default_path).expand_path
|
|
33
|
+
validate!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def contacts
|
|
37
|
+
@contacts ||= Parsers::SqliteParser.new(
|
|
38
|
+
database_paths, root_path: @path, readonly: true, diagnostics: diagnostics, strict: @strict
|
|
39
|
+
).contacts
|
|
40
|
+
rescue SQLite3::CantOpenException, Errno::EACCES => e
|
|
41
|
+
raise permission_error(e.message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def database_paths
|
|
45
|
+
@database_paths ||= discover_database_paths.tap do |paths|
|
|
46
|
+
raise NotFoundError, missing_database_message if paths.empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def validate!
|
|
53
|
+
stat = @path.stat
|
|
54
|
+
raise NotFoundError, "Live Contacts store is not a directory: #{@path}" unless stat.directory?
|
|
55
|
+
|
|
56
|
+
ensure_readable_directory!(@path)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
raise NotFoundError, "Live Contacts store not found: #{@path}"
|
|
59
|
+
rescue Errno::EACCES => e
|
|
60
|
+
raise permission_error(e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def discover_database_paths
|
|
64
|
+
paths = @path.glob(DATABASE_GLOB)
|
|
65
|
+
sources_path = @path.join('Sources')
|
|
66
|
+
paths.concat(source_database_paths(sources_path)) if sources_path.exist?
|
|
67
|
+
paths.sort.each { |db_path| ensure_readable_file!(db_path) }
|
|
68
|
+
rescue Errno::EACCES => e
|
|
69
|
+
raise permission_error(e.message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def source_database_paths(sources_path)
|
|
73
|
+
ensure_readable_directory!(sources_path)
|
|
74
|
+
sources_path.children.sort.select(&:directory?).flat_map do |source_path|
|
|
75
|
+
ensure_readable_directory!(source_path)
|
|
76
|
+
source_path.glob(DATABASE_GLOB)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ensure_readable_directory!(path)
|
|
81
|
+
return if path.readable? && path.executable?
|
|
82
|
+
|
|
83
|
+
raise permission_error("directory is not readable: #{path}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ensure_readable_file!(path)
|
|
87
|
+
return if path.file? && path.readable?
|
|
88
|
+
|
|
89
|
+
raise permission_error("database is not readable: #{path}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def missing_database_message
|
|
93
|
+
"No #{DATABASE_GLOB} databases found in live Contacts store: #{@path}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def permission_error(detail)
|
|
97
|
+
PermissionError.new(
|
|
98
|
+
"Cannot read live Contacts store at #{@path} (#{detail}). " \
|
|
99
|
+
'Grant Full Disk Access to the terminal or application running abbu in ' \
|
|
100
|
+
'System Settings > Privacy & Security > Full Disk Access, then retry.'
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -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
|