abbu 0.1.2 → 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.1.2'
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.1.2
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-04-26 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
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: plist
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.7'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: guard
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +122,20 @@ dependencies:
108
122
  - - "~>"
109
123
  - !ruby/object:Gem::Version
110
124
  version: '3.13'
125
+ - !ruby/object:Gem::Dependency
126
+ name: ruby-lsp
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.1'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.1'
111
139
  - !ruby/object:Gem::Dependency
112
140
  name: rubocop
113
141
  requirement: !ruby/object:Gem::Requirement
@@ -178,9 +206,9 @@ dependencies:
178
206
  - - "~>"
179
207
  - !ruby/object:Gem::Version
180
208
  version: '0.22'
181
- description: Parse Apple Address Book Archive (.abbu) files and export contacts to
182
- CSV, JSON, or vCard. Supports modern SQLite-backed archives and legacy plist-based
183
- 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.
184
212
  email:
185
213
  - stan@a1webconsulting.com
186
214
  executables:
@@ -188,17 +216,13 @@ executables:
188
216
  extensions: []
189
217
  extra_rdoc_files: []
190
218
  files:
191
- - CHANGELOG.md
192
219
  - LICENSE
193
220
  - README.md
194
221
  - bin/abbu
195
- - bin/cleanse
196
- - bin/console
197
- - bin/dev
198
- - bin/lint
199
- - bin/outdated
200
- - bin/test
201
222
  - docs/ABBU.md
223
+ - docs/CHANGELOG.md
224
+ - docs/CONTRIBUTING.md
225
+ - docs/TODO.md
202
226
  - examples/deduplicate_contacts.rb
203
227
  - examples/export_to_api.rb
204
228
  - examples/export_to_csv.rb
@@ -209,12 +233,22 @@ files:
209
233
  - lib/abbu.rb
210
234
  - lib/abbu/archive.rb
211
235
  - lib/abbu/contact.rb
236
+ - lib/abbu/diagnostic.rb
212
237
  - lib/abbu/exporters/csv_exporter.rb
213
238
  - lib/abbu/exporters/json_exporter.rb
214
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
215
243
  - lib/abbu/parsers/plist_parser.rb
216
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
217
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
218
252
  - lib/abbu/version.rb
219
253
  - tasks/abbu.rake
220
254
  homepage: https://github.com/scarver2/abbu
@@ -224,7 +258,7 @@ metadata:
224
258
  allowed_push_host: https://rubygems.org
225
259
  homepage_uri: https://github.com/scarver2/abbu
226
260
  source_code_uri: https://github.com/scarver2/abbu
227
- changelog_uri: https://github.com/scarver2/abbu/blob/master/CHANGELOG.md
261
+ changelog_uri: https://github.com/scarver2/abbu/blob/main/docs/CHANGELOG.md
228
262
  rubygems_mfa_required: 'true'
229
263
  post_install_message:
230
264
  rdoc_options: []
@@ -234,15 +268,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
234
268
  requirements:
235
269
  - - ">="
236
270
  - !ruby/object:Gem::Version
237
- version: '3.2'
271
+ version: '3.3'
238
272
  required_rubygems_version: !ruby/object:Gem::Requirement
239
273
  requirements:
240
274
  - - ">="
241
275
  - !ruby/object:Gem::Version
242
276
  version: '0'
243
277
  requirements: []
244
- rubygems_version: 3.4.19
278
+ rubygems_version: 3.5.22
245
279
  signing_key:
246
280
  specification_version: 4
247
- summary: Read and process Apple Contacts .abbu archives in Ruby.
281
+ summary: Read-only Apple Contacts toolkit for archives and live macOS stores.
248
282
  test_files: []
data/CHANGELOG.md DELETED
@@ -1,67 +0,0 @@
1
- <!-- CHANGELOG.md -->
2
-
3
- # Changelog
4
-
5
- All notable changes to `abbu` are documented here.
6
-
7
- Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
8
- Versioning follows [Semantic Versioning](https://semver.org/).
9
-
10
- ---
11
-
12
- ## [Unreleased]
13
-
14
- ## [0.1.2] - 2026-04-26
15
-
16
- ### Added
17
-
18
- - Full Apple Contacts schema support: job title, department, maiden name, phonetic names, pronouns, ringtone, texttone
19
- - Relational table parsing: URLs, notes, related names (family/business), social profiles (Twitter, etc.)
20
- - Nickname, prefix, and suffix fields with smart `full_name` formatting
21
- - Hash-based email/phone data preserving custom labels (e.g. "Direct Line", "Work")
22
- - Address, group, URL, notes, related names, and social profiles in CSV export
23
- - Comprehensive JSON export with all contact fields
24
- - vCard 3.0 export with ADR, URL, NICKNAME, TITLE, NOTE, X-SOCIALPROFILE
25
- - `rubocop-rspec` plugin integration
26
- - 100% line coverage across all 44 specs
27
-
28
- ### Changed
29
-
30
- - Refactored `SqliteParser` to use `RECORD_FIELD_MAP` constant for maintainability
31
- - Refactored `CsvExporter` into `core_fields`/`extended_fields` for cleaner ABC metrics
32
- - All specs comply with rubocop-rspec conventions
33
-
34
- ## [0.1.1] - 2026-04-23
35
-
36
- ### Fixed
37
-
38
- - `require 'pathname'` missing in `archive.rb` causing `NameError` in isolation
39
- - Added regression guard spec for file require isolation
40
-
41
-
42
- ## [0.1.0] - 2026-04-12
43
-
44
- ### Added
45
-
46
- - `Abbu.open(path)` entry point returning an `Archive`
47
- - `Archive#contacts` — reads contacts from SQLite or falls back to plist stub
48
- - `Archive#sqlite?` — detects modern `.abcddb` bundles
49
- - `Contact` object with `first_name`, `last_name`, `emails`, `phones`, `company`, `full_name`
50
- - `Parsers::SqliteParser` — queries `ZABCDRECORD`, `ZABCDEMAILADDRESS`, `ZABCDPHONENUMBER`
51
- - `Parsers::PlistParser` — stub with warning (legacy `.abcdp` support in v0.2)
52
- - `Exporters::CsvExporter` — `to_file` and `to_stdout`
53
- - `Exporters::JsonExporter` — `to_file` and `to_stdout`
54
- - `Exporters::VcardExporter` — `to_file` and `to_stdout` (vCard 3.0)
55
- - `Utils::Deduplicator` — groups contacts by first email, returns duplicates hash
56
- - `bin/abbu` CLI with `--format`, `--output`, `--stats`, `--dedupe`, `--version`
57
- - Rake tasks: `abbu:export`, `abbu:dedupe`, `abbu:stats`
58
- - Example scripts: CSV, JSON, vCard, API, CRM sync, stats, dedupe
59
- - `docs/ABBU.md` — file format reference
60
- - RSpec test suite with 100% coverage target
61
- - Guard + RuboCop DX loop
62
- - GitHub Actions CI (Ruby 3.2 + 3.3)
63
-
64
- ---
65
- Stan Carver II
66
- Made in Texas 🤠
67
- https://stancarver.com
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 "$@"