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.
@@ -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
@@ -1,16 +1,184 @@
1
1
  # lib/abbu/parsers/plist_parser.rb
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'plist'
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'
10
+
4
11
  module Abbu
5
12
  module Parsers
6
13
  class PlistParser
7
- def initialize(path)
8
- @path = path
14
+ # Maps plist keys → Contact attr_accessor names for simple string fields
15
+ FIELD_MAP = {
16
+ 'First' => :first_name, 'Middle' => :middle_name,
17
+ 'Last' => :last_name,
18
+ 'Nickname' => :nickname, 'Title' => :prefix,
19
+ 'Suffix' => :suffix, 'Organization' => :company,
20
+ 'JobTitle' => :job_title, 'Department' => :department,
21
+ 'MaidenName' => :maiden_name, 'VerificationCode' => :verification_code,
22
+ 'PhoneticFirst' => :phonetic_first_name,
23
+ 'PhoneticMiddle' => :phonetic_middle_name,
24
+ 'PhoneticLast' => :phonetic_last_name
25
+ }.freeze
26
+
27
+ # Accepts either a directory path (scans for *.abcdp) or an array of file paths
28
+ attr_reader :diagnostics
29
+
30
+ def initialize(paths, root_path: nil, diagnostics: nil, strict: false)
31
+ @paths = resolve_paths(paths)
32
+ @root_path = root_path
33
+ @diagnostics = diagnostics || []
34
+ @strict = strict
9
35
  end
10
36
 
11
37
  def contacts
12
- warn 'Plist parsing not yet implemented — no .abcddb found in this archive.'
13
- []
38
+ @paths.filter_map { |file| parse_file(file) }
39
+ end
40
+
41
+ private
42
+
43
+ def resolve_paths(paths)
44
+ case paths
45
+ when Array
46
+ paths.map { |p| Pathname.new(p) }
47
+ else
48
+ path = Pathname.new(paths)
49
+ return [] unless path.exist?
50
+
51
+ path.directory? ? path.glob('*.abcdp').sort : [path]
52
+ end
53
+ end
54
+
55
+ def parse_file(file)
56
+ data = Plist.parse_xml(file.to_s)
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
65
+
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
76
+ end
77
+
78
+ def build_contact(data, file)
79
+ contact = Contact.new
80
+ assign_flat_fields(contact, data)
81
+ assign_multi_value_fields(contact, data)
82
+ contact.source = Utils::SourceDescriptor.new(file, root_path: @root_path).to_h
83
+ contact
84
+ end
85
+
86
+ def assign_flat_fields(contact, data)
87
+ FIELD_MAP.each do |plist_key, attr|
88
+ contact.public_send(:"#{attr}=", data[plist_key])
89
+ end
90
+ end
91
+
92
+ def assign_multi_value_fields(contact, data) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
93
+ contact.emails = extract_labeled_values(data, 'Email', :address)
94
+ contact.phones = extract_labeled_values(data, 'Phone', :number)
95
+ contact.addresses = extract_addresses(data)
96
+ contact.urls = extract_labeled_values(data, 'URLs', :url)
97
+ contact.notes = extract_notes(data)
98
+ contact.related_names = extract_labeled_values(data, 'RelatedNames', :name)
99
+ contact.social_profiles = extract_social_profiles(data)
100
+ contact.instant_messages = extract_instant_messages(data)
101
+
102
+ contact.birthday = extract_birthday(data)
103
+ contact.lunar_birthday = extract_lunar_birthday(data)
104
+ contact.dates = extract_dates(data)
105
+ contact.anniversary = contact.dates.find { |d| d[:label] == 'Anniversary' }
106
+ end
107
+
108
+ def extract_labeled_values(data, key, value_key)
109
+ return [] unless data[key]&.dig('values')
110
+
111
+ data[key]['values'].map do |entry|
112
+ { value_key => entry['value'], **label_fields(entry['label']) }
113
+ end
114
+ end
115
+
116
+ def extract_addresses(data) # rubocop:disable Metrics/MethodLength
117
+ return [] unless data['Address']&.dig('values')
118
+
119
+ data['Address']['values'].map do |entry|
120
+ addr = entry['value'] || {}
121
+ {
122
+ street: addr['Street'],
123
+ city: addr['City'],
124
+ state: addr['State'],
125
+ zip: addr['ZIP'],
126
+ country: addr['Country'],
127
+ **label_fields(entry['label'])
128
+ }
129
+ end
130
+ end
131
+
132
+ def extract_notes(data)
133
+ note = data['Note']
134
+ note ? [note] : []
135
+ end
136
+
137
+ def extract_social_profiles(data)
138
+ return [] unless data['SocialProfile']&.dig('values')
139
+
140
+ data['SocialProfile']['values'].map do |entry|
141
+ profile = entry['value'] || {}
142
+ { service: profile['serviceName'], username: profile['username'] }
143
+ end
144
+ end
145
+
146
+ def extract_instant_messages(data)
147
+ return [] unless data['InstantMessage']&.dig('values')
148
+
149
+ data['InstantMessage']['values'].map do |entry|
150
+ msg = entry['value'] || {}
151
+ { address: msg['address'], service: msg['serviceName'], **label_fields(entry['label']) }
152
+ end
153
+ end
154
+
155
+ def extract_birthday(data)
156
+ val = data['Birthday']
157
+ return nil unless val.respond_to?(:year)
158
+
159
+ { year: val.year, month: val.month, day: val.day, label: 'Birthday', raw_label: nil }
160
+ end
161
+
162
+ def extract_lunar_birthday(data)
163
+ val = data['LunarBirthday']
164
+ return nil unless val.respond_to?(:year)
165
+
166
+ { year: val.year, month: val.month, day: val.day, label: 'LunarBirthday', raw_label: nil }
167
+ end
168
+
169
+ def extract_dates(data)
170
+ return [] unless data['Dates']&.dig('values')
171
+
172
+ data['Dates']['values'].filter_map do |entry|
173
+ val = entry['value']
174
+ next unless val.respond_to?(:year)
175
+
176
+ { year: val.year, month: val.month, day: val.day, **label_fields(entry['label']) }
177
+ end
178
+ end
179
+
180
+ def label_fields(raw_label)
181
+ { label: Utils::LabelNormalizer.normalize(raw_label), raw_label: raw_label }
14
182
  end
15
183
  end
16
184
  end