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.
@@ -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 { |k, _| k.nil? }
15
- .select { |_, v| v.size > 1 }
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
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Abbu
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
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.2.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-05-03 00:00:00.000000000 Z
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: Parse Apple Address Book Archive (.abbu) files and export contacts to
210
- CSV, JSON, or vCard. Supports modern SQLite-backed archives and legacy plist-based
211
- records.
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/master/docs/CHANGELOG.md
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.2'
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.4.19
278
+ rubygems_version: 3.5.22
275
279
  signing_key:
276
280
  specification_version: 4
277
- summary: Read and process Apple Contacts .abbu archives in Ruby.
281
+ summary: Read-only Apple Contacts toolkit for archives and live macOS stores.
278
282
  test_files: []
data/bin/cleanse DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env bash
2
- # bin/cleanse
3
- set -euo pipefail
4
-
5
- cd "$(dirname "$0")/.."
6
-
7
- exec bundle exec rubocop -A "$@"
data/bin/console DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # bin/console
3
- # frozen_string_literal: true
4
-
5
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
6
-
7
- require "irb"
8
- require "abbu"
9
-
10
- IRB.start(__FILE__)
data/bin/dev DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env bash
2
- # bin/dev
3
- set -euo pipefail
4
-
5
- cd "$(dirname "$0")/.."
6
-
7
- exec bundle exec guard
data/bin/lint DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env bash
2
- # bin/lint
3
- set -euo pipefail
4
-
5
- cd "$(dirname "$0")/.."
6
-
7
- exec bundle exec rubocop "$@"
data/bin/outdated DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env bash
2
- # bin/outdated
3
- set -euo pipefail
4
-
5
- cd "$(dirname "$0")/.."
6
-
7
- exec bundle outdated "$@"
data/bin/test DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env bash
2
- # bin/test
3
- set -euo pipefail
4
-
5
- cd "$(dirname "$0")/.."
6
-
7
- exec bundle exec rspec "$@"