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/source.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# lib/abbu/source.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'group_catalog'
|
|
5
|
+
require_relative 'query'
|
|
6
|
+
|
|
7
|
+
module Abbu
|
|
8
|
+
# An observed input container, not an inferred Apple account or provider.
|
|
9
|
+
# Metadata and membership are frozen; contacts retain their existing mutability.
|
|
10
|
+
class Source
|
|
11
|
+
attr_reader :path, :relative_path, :kind, :identifier, :files, :contacts, :group_names, :groups
|
|
12
|
+
|
|
13
|
+
def initialize(path:, relative_path:, identifier:, files:, contacts:)
|
|
14
|
+
@path = copy_string(path)
|
|
15
|
+
@relative_path = copy_string(relative_path)
|
|
16
|
+
@identifier = copy_string(identifier)
|
|
17
|
+
@kind = identifier.nil? ? 'root' : 'source'
|
|
18
|
+
@files = snapshot_files(files)
|
|
19
|
+
@contacts = Query.new(contacts.to_a.dup).freeze
|
|
20
|
+
@group_names = snapshot_group_names
|
|
21
|
+
@groups = GroupCatalog.new(@contacts).groups
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# No currently supported evidence identifies an account provider.
|
|
26
|
+
def provider
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def groups_for(contact)
|
|
31
|
+
groups.select { |group| group.include?(contact) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
{ path: path, relative_path: relative_path, kind: kind, identifier: identifier,
|
|
36
|
+
provider: provider, files: files, contact_count: contacts.count, group_names: group_names }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def copy_string(value)
|
|
42
|
+
value&.dup&.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def snapshot_files(files)
|
|
46
|
+
files.map { |file| file.transform_values { |value| copy_string(value) }.freeze }.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def snapshot_group_names
|
|
50
|
+
@contacts.flat_map(&:groups).compact.uniq.sort.map { |name| copy_string(name) }.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# lib/abbu/source_catalog.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'source'
|
|
6
|
+
require_relative 'utils/source_descriptor'
|
|
7
|
+
|
|
8
|
+
module Abbu
|
|
9
|
+
# Internal adapter over the same file descriptors used by the parsers.
|
|
10
|
+
class SourceCatalog
|
|
11
|
+
def initialize(paths, root_path:, contacts:)
|
|
12
|
+
@root_path = Pathname.new(root_path).expand_path
|
|
13
|
+
@files = paths.map { |path| Utils::SourceDescriptor.new(path, root_path: root_path).to_h }
|
|
14
|
+
.sort_by { |file| file.fetch(:relative_path) }
|
|
15
|
+
@contacts_by_path = contacts.group_by { |contact| contact.source&.fetch(:path, nil) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sources
|
|
19
|
+
@files.group_by { |file| file.fetch(:identifier) }.map do |identifier, files|
|
|
20
|
+
relative_path = identifier.nil? ? '.' : "Sources/#{identifier}"
|
|
21
|
+
Source.new(path: @root_path.join(relative_path).to_s, relative_path: relative_path,
|
|
22
|
+
identifier: identifier, files: files, contacts: contacts_for(files))
|
|
23
|
+
end.sort_by(&:relative_path).freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def contacts_for(files)
|
|
29
|
+
files.flat_map { |file| @contacts_by_path.fetch(file.fetch(:path), []) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# lib/abbu/timestamp_range.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
# Internal filter over observed timestamps, never a claim about human edits.
|
|
8
|
+
class TimestampRange
|
|
9
|
+
FORMAT = /\A\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/
|
|
10
|
+
|
|
11
|
+
def initialize(field, since: nil, before: nil)
|
|
12
|
+
raise ArgumentError, 'field must be :created_at or :modified_at' unless %i[created_at modified_at].include?(field)
|
|
13
|
+
raise ArgumentError, 'at least one timestamp bound is required' if since.nil? && before.nil?
|
|
14
|
+
|
|
15
|
+
@since = parse(since) unless since.nil?
|
|
16
|
+
@before = parse(before) unless before.nil?
|
|
17
|
+
validate_order
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def include?(value)
|
|
21
|
+
!value.nil? && (@since.nil? || value >= @since) && (@before.nil? || value < @before)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_order
|
|
27
|
+
raise ArgumentError, 'since must be earlier than before' if @since && @before && @since >= @before
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse(value)
|
|
31
|
+
return value if value.is_a?(Time)
|
|
32
|
+
|
|
33
|
+
unless value.is_a?(String) && FORMAT.match?(value)
|
|
34
|
+
raise ArgumentError, 'timestamp must be a Time or ISO 8601 datetime with explicit timezone'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
DateTime.iso8601(value, Date::GREGORIAN).to_time
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
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
|
|
@@ -1,9 +1,61 @@
|
|
|
1
1
|
# lib/abbu/utils/deduplicator.rb
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative 'contact_identity'
|
|
5
|
+
|
|
4
6
|
module Abbu
|
|
5
7
|
module Utils
|
|
6
8
|
class Deduplicator
|
|
9
|
+
class MergePolicyRequired < ArgumentError; end
|
|
10
|
+
|
|
11
|
+
class Match
|
|
12
|
+
attr_reader :left, :right, :evidence, :score, :confidence, :status
|
|
13
|
+
|
|
14
|
+
def initialize(left:, right:, evidence:, score:, status:)
|
|
15
|
+
@left = left
|
|
16
|
+
@right = right
|
|
17
|
+
@evidence = evidence.freeze
|
|
18
|
+
@score = score
|
|
19
|
+
@confidence = confidence_for(score)
|
|
20
|
+
@status = status
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def sources
|
|
25
|
+
[left.source, right.source].freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ambiguous?
|
|
29
|
+
status == :ambiguous
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merge(policy: nil)
|
|
33
|
+
raise MergePolicyRequired, 'an explicit callable merge policy is required' unless policy.respond_to?(:call)
|
|
34
|
+
|
|
35
|
+
policy.call(left, right, evidence: evidence)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def confidence_for(value)
|
|
41
|
+
return :high if value >= 0.8
|
|
42
|
+
return :medium if value >= 0.6
|
|
43
|
+
|
|
44
|
+
:low
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
WEIGHTS = {
|
|
49
|
+
email: 0.8,
|
|
50
|
+
international_phone: 0.65,
|
|
51
|
+
source_local_phone: 0.45,
|
|
52
|
+
name: 0.25,
|
|
53
|
+
organization: 0.15
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
MINIMUM_SCORE = 0.4
|
|
57
|
+
PROBABLE_SCORE = 0.6
|
|
58
|
+
|
|
7
59
|
def initialize(contacts)
|
|
8
60
|
@contacts = contacts
|
|
9
61
|
end
|
|
@@ -11,8 +63,84 @@ module Abbu
|
|
|
11
63
|
def duplicates
|
|
12
64
|
@contacts
|
|
13
65
|
.group_by { |c| c.emails.first }
|
|
14
|
-
.reject
|
|
15
|
-
.select
|
|
66
|
+
.reject { |k, _| k.nil? }
|
|
67
|
+
.select { |_, v| v.size > 1 }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def matches
|
|
71
|
+
identities = @contacts.map { |contact| ContactIdentity.new(contact) }
|
|
72
|
+
candidates = identities.combination(2).filter_map { |left, right| candidate(left, right) }
|
|
73
|
+
ambiguous_contacts = multiply_matched_contacts(candidates)
|
|
74
|
+
|
|
75
|
+
candidates.map do |candidate|
|
|
76
|
+
build_match(candidate, ambiguous_contacts)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
alias identity_matches matches
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def candidate(left, right)
|
|
85
|
+
evidence = evidence_for(left, right)
|
|
86
|
+
score = evidence.map { |item| item[:type] }.uniq.sum { |type| WEIGHTS.fetch(type) }.clamp(0.0, 1.0).round(2)
|
|
87
|
+
return if score < MINIMUM_SCORE
|
|
88
|
+
|
|
89
|
+
{ left: left.contact, right: right.contact, evidence: evidence, score: score }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def evidence_for(left, right)
|
|
93
|
+
signal_pairs(left, right).flat_map do |left_signals, right_signals, same_source|
|
|
94
|
+
shared_evidence(left_signals, right_signals, same_source: same_source)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def signal_pairs(left, right)
|
|
99
|
+
[
|
|
100
|
+
[left.emails, right.emails, false],
|
|
101
|
+
[left.phones, right.phones, left.same_source?(right)],
|
|
102
|
+
[[left.name].compact, [right.name].compact, false],
|
|
103
|
+
[[left.organization].compact, [right.organization].compact, false]
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def shared_evidence(left_signals, right_signals, same_source: false)
|
|
108
|
+
left_signals.filter_map do |left|
|
|
109
|
+
right = right_signals.find { |candidate| comparable?(left, candidate, same_source) }
|
|
110
|
+
evidence_record(left, right) if right
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def comparable?(left, right, same_source)
|
|
115
|
+
return false unless left.type == right.type && left.normalized == right.normalized
|
|
116
|
+
return same_source if left.type == :source_local_phone
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def evidence_record(left, right)
|
|
122
|
+
{
|
|
123
|
+
type: left.type,
|
|
124
|
+
normalized: left.normalized,
|
|
125
|
+
left_raw: left.raw,
|
|
126
|
+
right_raw: right.raw
|
|
127
|
+
}.freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def multiply_matched_contacts(candidates)
|
|
131
|
+
counts = Hash.new(0).compare_by_identity
|
|
132
|
+
candidates.each do |candidate|
|
|
133
|
+
counts[candidate[:left]] += 1
|
|
134
|
+
counts[candidate[:right]] += 1
|
|
135
|
+
end
|
|
136
|
+
counts.select { |_, count| count > 1 }.keys
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_match(candidate, ambiguous_contacts)
|
|
140
|
+
ambiguous = candidate[:score] < PROBABLE_SCORE ||
|
|
141
|
+
ambiguous_contacts.include?(candidate[:left]) ||
|
|
142
|
+
ambiguous_contacts.include?(candidate[:right])
|
|
143
|
+
Match.new(**candidate, status: ambiguous ? :ambiguous : :probable)
|
|
16
144
|
end
|
|
17
145
|
end
|
|
18
146
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# lib/abbu/utils/image_resolver.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
module Utils
|
|
8
|
+
class ImageResolver
|
|
9
|
+
EXTENSIONS = %w[jpg jpeg png heic].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(bundle_path)
|
|
12
|
+
@bundle_path = Pathname.new(bundle_path).expand_path
|
|
13
|
+
@index = build_index
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve(image_uri, source: nil)
|
|
17
|
+
return nil if image_uri.nil? || image_uri.to_s.empty?
|
|
18
|
+
|
|
19
|
+
candidates = @index.fetch(image_uri.to_s, [])
|
|
20
|
+
return candidates.first if candidates.one?
|
|
21
|
+
return if candidates.empty?
|
|
22
|
+
|
|
23
|
+
source_local_candidate(candidates, source)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def each_image(&)
|
|
27
|
+
return enum_for(:each_image) unless block_given?
|
|
28
|
+
|
|
29
|
+
@index.each_value { |files| files.each(&) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_index
|
|
35
|
+
image_files.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |file, hash|
|
|
36
|
+
stem = file.basename('.*').to_s
|
|
37
|
+
hash[stem] << file
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def image_files
|
|
42
|
+
@bundle_path.glob('**/Images/*').select { |f| f.file? && image_extension?(f) }.sort
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def image_extension?(file)
|
|
46
|
+
ext = file.extname.downcase.delete_prefix('.')
|
|
47
|
+
EXTENSIONS.include?(ext)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def source_local_candidate(candidates, source)
|
|
51
|
+
return unless source
|
|
52
|
+
|
|
53
|
+
database = @bundle_path.join(source.fetch(:relative_path, ''))
|
|
54
|
+
local_images = database.dirname.join('Images').expand_path
|
|
55
|
+
candidates.find { |candidate| candidate.dirname.expand_path == local_images }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# lib/abbu/utils/label_normalizer.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Abbu
|
|
5
|
+
module Utils
|
|
6
|
+
class LabelNormalizer
|
|
7
|
+
APPLE_STANDARD_LABEL = /\A_\$!<(?<label>.+)>!\$_\z/
|
|
8
|
+
|
|
9
|
+
def self.normalize(label)
|
|
10
|
+
match = label&.match(APPLE_STANDARD_LABEL)
|
|
11
|
+
match ? match[:label] : label
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# lib/abbu/utils/source_descriptor.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Abbu
|
|
7
|
+
module Utils
|
|
8
|
+
class SourceDescriptor
|
|
9
|
+
def initialize(path, root_path: nil)
|
|
10
|
+
@path = Pathname.new(path).expand_path
|
|
11
|
+
@root_path = Pathname.new(root_path).expand_path if root_path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
relative_path = relative_path_for(@path)
|
|
16
|
+
parts = Pathname.new(relative_path).each_filename.to_a
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
path: @path.to_s,
|
|
20
|
+
relative_path: relative_path,
|
|
21
|
+
kind: parts.first == 'Sources' ? 'source' : 'root',
|
|
22
|
+
identifier: parts.first == 'Sources' ? parts[1] : nil
|
|
23
|
+
}.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def relative_path_for(path)
|
|
29
|
+
return path.basename.to_s unless @root_path
|
|
30
|
+
|
|
31
|
+
path.relative_path_from(@root_path).to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/abbu/version.rb
CHANGED
data/lib/abbu.rb
CHANGED
|
@@ -3,16 +3,29 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative 'abbu/version'
|
|
5
5
|
require_relative 'abbu/contact'
|
|
6
|
+
require_relative 'abbu/diagnostic'
|
|
7
|
+
require_relative 'abbu/parse_error'
|
|
6
8
|
require_relative 'abbu/archive'
|
|
9
|
+
require_relative 'abbu/live_store'
|
|
10
|
+
require_relative 'abbu/query'
|
|
11
|
+
require_relative 'abbu/schema_inspector'
|
|
12
|
+
require_relative 'abbu/source'
|
|
7
13
|
require_relative 'abbu/parsers/sqlite_parser'
|
|
8
14
|
require_relative 'abbu/parsers/plist_parser'
|
|
9
15
|
require_relative 'abbu/exporters/csv_exporter'
|
|
10
16
|
require_relative 'abbu/exporters/json_exporter'
|
|
11
17
|
require_relative 'abbu/exporters/vcard_exporter'
|
|
18
|
+
require_relative 'abbu/image_extractor'
|
|
19
|
+
require_relative 'abbu/utils/contact_identity'
|
|
12
20
|
require_relative 'abbu/utils/deduplicator'
|
|
21
|
+
require_relative 'abbu/utils/label_normalizer'
|
|
13
22
|
|
|
14
23
|
module Abbu
|
|
15
|
-
def self.open(path)
|
|
16
|
-
Archive.new(path)
|
|
24
|
+
def self.open(path, strict: false)
|
|
25
|
+
Archive.new(path, strict: strict)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.open_live(path = nil, strict: false)
|
|
29
|
+
LiveStore.new(path, strict: strict)
|
|
17
30
|
end
|
|
18
31
|
end
|
data/sig/group.rbs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# sig/group.rbs
|
|
2
|
+
module Abbu
|
|
3
|
+
class Contact
|
|
4
|
+
attr_accessor group_memberships: Array[{ record_id: Integer, name: String? }]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Group
|
|
8
|
+
attr_reader record_id: Integer
|
|
9
|
+
attr_reader name: String?
|
|
10
|
+
attr_reader source: Hash[Symbol, String?]
|
|
11
|
+
attr_reader contacts: Query
|
|
12
|
+
def initialize: (record_id: Integer, name: String?, source: Hash[Symbol, String?],
|
|
13
|
+
contacts: Enumerable[Contact]) -> void
|
|
14
|
+
def include?: (Contact contact) -> bool
|
|
15
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Source
|
|
19
|
+
attr_reader groups: Array[Group]
|
|
20
|
+
def groups_for: (Contact contact) -> Array[Group]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Archive
|
|
24
|
+
def groups: () -> Array[Group]
|
|
25
|
+
def groups_for: (Contact contact) -> Array[Group]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class LiveStore
|
|
29
|
+
def groups: () -> Array[Group]
|
|
30
|
+
def groups_for: (Contact contact) -> Array[Group]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Query
|
|
34
|
+
def in_group: (Group group) -> Query
|
|
35
|
+
end
|
|
36
|
+
end
|
data/sig/query.rbs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# sig/query.rbs
|
|
2
|
+
module Abbu
|
|
3
|
+
class Contact
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
class Query
|
|
7
|
+
include Enumerable[Contact]
|
|
8
|
+
|
|
9
|
+
def initialize: (Enumerable[Contact] contacts) -> void
|
|
10
|
+
def each: () { (Contact) -> void } -> Array[Contact]
|
|
11
|
+
| () -> Enumerator[Contact, Array[Contact]]
|
|
12
|
+
def where: (Hash[Symbol, untyped] criteria) -> Query
|
|
13
|
+
def search: (String term) -> Query
|
|
14
|
+
def find_by_email: (String email) -> Query
|
|
15
|
+
def find_by_phone: (String phone) -> Query
|
|
16
|
+
def to_a: () -> Array[Contact]
|
|
17
|
+
def created_since: (Time | String value) -> Query
|
|
18
|
+
def modified_since: (Time | String value) -> Query
|
|
19
|
+
def date_range: (:created_at | :modified_at field, ?since: Time | String | nil, ?before: Time | String | nil) -> Query
|
|
20
|
+
end
|
|
21
|
+
end
|
data/sig/source.rbs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# sig/source.rbs
|
|
2
|
+
module Abbu
|
|
3
|
+
class Source
|
|
4
|
+
attr_reader path: String
|
|
5
|
+
attr_reader relative_path: String
|
|
6
|
+
attr_reader kind: String
|
|
7
|
+
attr_reader identifier: String?
|
|
8
|
+
attr_reader files: Array[Hash[Symbol, String?]]
|
|
9
|
+
attr_reader contacts: Query
|
|
10
|
+
attr_reader group_names: Array[String]
|
|
11
|
+
|
|
12
|
+
def initialize: (path: String, relative_path: String, identifier: String?,
|
|
13
|
+
files: Array[Hash[Symbol, String?]], contacts: Enumerable[Contact]) -> void
|
|
14
|
+
def provider: () -> nil
|
|
15
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Archive
|
|
19
|
+
def sources: () -> Array[Source]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class LiveStore
|
|
23
|
+
def sources: () -> Array[Source]
|
|
24
|
+
end
|
|
25
|
+
end
|
data/tasks/abbu.rake
CHANGED
|
@@ -12,7 +12,7 @@ namespace :abbu do
|
|
|
12
12
|
CSV.open('contacts.csv', 'w') do |csv|
|
|
13
13
|
csv << %w[Name Email Phone Company]
|
|
14
14
|
archive.contacts.each do |c|
|
|
15
|
-
csv << [c.full_name, c.emails.first, c.phones.first, c.company]
|
|
15
|
+
csv << [c.full_name, c.emails.first&.dig(:address), c.phones.first&.dig(:number), c.company]
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|