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
|
@@ -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,28 @@
|
|
|
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'
|
|
7
12
|
require_relative 'abbu/parsers/sqlite_parser'
|
|
8
13
|
require_relative 'abbu/parsers/plist_parser'
|
|
9
14
|
require_relative 'abbu/exporters/csv_exporter'
|
|
10
15
|
require_relative 'abbu/exporters/json_exporter'
|
|
11
16
|
require_relative 'abbu/exporters/vcard_exporter'
|
|
17
|
+
require_relative 'abbu/image_extractor'
|
|
18
|
+
require_relative 'abbu/utils/contact_identity'
|
|
12
19
|
require_relative 'abbu/utils/deduplicator'
|
|
20
|
+
require_relative 'abbu/utils/label_normalizer'
|
|
13
21
|
|
|
14
22
|
module Abbu
|
|
15
|
-
def self.open(path)
|
|
16
|
-
Archive.new(path)
|
|
23
|
+
def self.open(path, strict: false)
|
|
24
|
+
Archive.new(path, strict: strict)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.open_live(path = nil, strict: false)
|
|
28
|
+
LiveStore.new(path, strict: strict)
|
|
17
29
|
end
|
|
18
30
|
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
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: abbu
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stan Carver II
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: sqlite3
|
|
@@ -206,9 +206,9 @@ dependencies:
|
|
|
206
206
|
- - "~>"
|
|
207
207
|
- !ruby/object:Gem::Version
|
|
208
208
|
version: '0.22'
|
|
209
|
-
description:
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
description: Read SQLite and legacy plist .abbu archives or opt into read-only live
|
|
210
|
+
macOS Contacts access. Query contacts, inspect schemas and diagnostics, compare
|
|
211
|
+
identity evidence, extract images, and export CSV, JSON, or vCard.
|
|
212
212
|
email:
|
|
213
213
|
- stan@a1webconsulting.com
|
|
214
214
|
executables:
|
|
@@ -219,12 +219,6 @@ files:
|
|
|
219
219
|
- LICENSE
|
|
220
220
|
- README.md
|
|
221
221
|
- bin/abbu
|
|
222
|
-
- bin/cleanse
|
|
223
|
-
- bin/console
|
|
224
|
-
- bin/dev
|
|
225
|
-
- bin/lint
|
|
226
|
-
- bin/outdated
|
|
227
|
-
- bin/test
|
|
228
222
|
- docs/ABBU.md
|
|
229
223
|
- docs/CHANGELOG.md
|
|
230
224
|
- docs/CONTRIBUTING.md
|
|
@@ -239,12 +233,22 @@ files:
|
|
|
239
233
|
- lib/abbu.rb
|
|
240
234
|
- lib/abbu/archive.rb
|
|
241
235
|
- lib/abbu/contact.rb
|
|
236
|
+
- lib/abbu/diagnostic.rb
|
|
242
237
|
- lib/abbu/exporters/csv_exporter.rb
|
|
243
238
|
- lib/abbu/exporters/json_exporter.rb
|
|
244
239
|
- lib/abbu/exporters/vcard_exporter.rb
|
|
240
|
+
- lib/abbu/image_extractor.rb
|
|
241
|
+
- lib/abbu/live_store.rb
|
|
242
|
+
- lib/abbu/parse_error.rb
|
|
245
243
|
- lib/abbu/parsers/plist_parser.rb
|
|
246
244
|
- lib/abbu/parsers/sqlite_parser.rb
|
|
245
|
+
- lib/abbu/query.rb
|
|
246
|
+
- lib/abbu/schema_inspector.rb
|
|
247
|
+
- lib/abbu/utils/contact_identity.rb
|
|
247
248
|
- lib/abbu/utils/deduplicator.rb
|
|
249
|
+
- lib/abbu/utils/image_resolver.rb
|
|
250
|
+
- lib/abbu/utils/label_normalizer.rb
|
|
251
|
+
- lib/abbu/utils/source_descriptor.rb
|
|
248
252
|
- lib/abbu/version.rb
|
|
249
253
|
- tasks/abbu.rake
|
|
250
254
|
homepage: https://github.com/scarver2/abbu
|
|
@@ -254,7 +258,7 @@ metadata:
|
|
|
254
258
|
allowed_push_host: https://rubygems.org
|
|
255
259
|
homepage_uri: https://github.com/scarver2/abbu
|
|
256
260
|
source_code_uri: https://github.com/scarver2/abbu
|
|
257
|
-
changelog_uri: https://github.com/scarver2/abbu/blob/
|
|
261
|
+
changelog_uri: https://github.com/scarver2/abbu/blob/main/docs/CHANGELOG.md
|
|
258
262
|
rubygems_mfa_required: 'true'
|
|
259
263
|
post_install_message:
|
|
260
264
|
rdoc_options: []
|
|
@@ -264,15 +268,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
264
268
|
requirements:
|
|
265
269
|
- - ">="
|
|
266
270
|
- !ruby/object:Gem::Version
|
|
267
|
-
version: '3.
|
|
271
|
+
version: '3.3'
|
|
268
272
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
269
273
|
requirements:
|
|
270
274
|
- - ">="
|
|
271
275
|
- !ruby/object:Gem::Version
|
|
272
276
|
version: '0'
|
|
273
277
|
requirements: []
|
|
274
|
-
rubygems_version: 3.
|
|
278
|
+
rubygems_version: 3.5.22
|
|
275
279
|
signing_key:
|
|
276
280
|
specification_version: 4
|
|
277
|
-
summary: Read
|
|
281
|
+
summary: Read-only Apple Contacts toolkit for archives and live macOS stores.
|
|
278
282
|
test_files: []
|
data/bin/cleanse
DELETED
data/bin/console
DELETED
data/bin/dev
DELETED
data/bin/lint
DELETED
data/bin/outdated
DELETED