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.
data/lib/abbu/archive.rb CHANGED
@@ -2,26 +2,62 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'pathname'
5
- require_relative 'parsers/sqlite_parser'
5
+ require_relative 'diagnostic'
6
+ require_relative 'image_extractor'
7
+ require_relative 'parse_error'
6
8
  require_relative 'parsers/plist_parser'
9
+ require_relative 'parsers/sqlite_parser'
10
+ require_relative 'query'
11
+ require_relative 'schema_inspector'
12
+ require_relative 'utils/image_resolver'
7
13
 
8
14
  module Abbu
9
15
  class Archive
10
- attr_reader :path
16
+ attr_reader :diagnostics, :path
11
17
 
12
- def initialize(path)
18
+ def initialize(path, strict: false)
13
19
  @path = Pathname.new(path)
20
+ @strict = strict
21
+ @diagnostics = []
14
22
  validate!
15
23
  end
16
24
 
17
25
  def contacts
18
- parser.contacts
26
+ @contacts ||= parser.contacts.tap { |cs| attach_images(cs) }
27
+ end
28
+
29
+ def query
30
+ Query.new(contacts)
31
+ end
32
+
33
+ def where(criteria)
34
+ query.where(criteria)
35
+ end
36
+
37
+ def search(term)
38
+ query.search(term)
39
+ end
40
+
41
+ def find_by_email(email)
42
+ query.find_by_email(email)
43
+ end
44
+
45
+ def find_by_phone(phone)
46
+ query.find_by_phone(phone)
19
47
  end
20
48
 
21
49
  def sqlite?
22
50
  db_paths.any?
23
51
  end
24
52
 
53
+ def extract_images(output_dir)
54
+ ImageExtractor.new(contacts).extract(output_dir)
55
+ end
56
+
57
+ def schema_report
58
+ SchemaInspector.new(db_paths, root_path: @path).report
59
+ end
60
+
25
61
  private
26
62
 
27
63
  def validate!
@@ -39,10 +75,34 @@ module Abbu
39
75
 
40
76
  def parser
41
77
  if sqlite?
42
- Parsers::SqliteParser.new(db_paths)
78
+ Parsers::SqliteParser.new(db_paths, root_path: @path, diagnostics: diagnostics, strict: @strict)
43
79
  else
44
- Parsers::PlistParser.new(plist_paths)
80
+ Parsers::PlistParser.new(plist_paths, root_path: @path, diagnostics: diagnostics, strict: @strict)
45
81
  end
46
82
  end
83
+
84
+ def attach_images(contacts)
85
+ return if contacts.empty?
86
+
87
+ resolver = Utils::ImageResolver.new(@path)
88
+ contacts.each do |contact|
89
+ next unless contact.image_uri
90
+
91
+ contact.image_path = resolver.resolve(contact.image_uri, source: contact.source)
92
+ record_missing_image(contact) unless contact.image_path
93
+ end
94
+ end
95
+
96
+ def record_missing_image(contact)
97
+ diagnostic = Diagnostic.new(
98
+ category: :missing_image,
99
+ message: 'Referenced contact image was not found',
100
+ parser: :archive,
101
+ source: contact.source&.fetch(:path, @path.to_s) || @path.to_s,
102
+ context: {}
103
+ )
104
+ diagnostics << diagnostic
105
+ raise ParseError, diagnostic if @strict
106
+ end
47
107
  end
48
108
  end
data/lib/abbu/contact.rb CHANGED
@@ -10,7 +10,9 @@ module Abbu
10
10
  :phonetic_company, :pronouns, :ringtone, :texttone,
11
11
  :urls, :notes, :related_names, :social_profiles,
12
12
  :birthday, :anniversary, :dates, :instant_messages,
13
- :verification_code, :lunar_birthday
13
+ :verification_code, :lunar_birthday,
14
+ :image_uri, :image_path,
15
+ :created_at, :modified_at, :source
14
16
 
15
17
  def initialize
16
18
  @emails = []
@@ -0,0 +1,27 @@
1
+ # lib/abbu/diagnostic.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ class Diagnostic
6
+ attr_reader :category, :context, :message, :parser, :source
7
+
8
+ def initialize(category:, message:, parser:, source:, context: {})
9
+ @category = category.to_sym
10
+ @context = context.freeze
11
+ @message = message.to_s.freeze
12
+ @parser = parser.to_sym
13
+ @source = source.to_s.freeze
14
+ freeze
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ category: category,
20
+ message: message,
21
+ parser: parser,
22
+ source: source,
23
+ context: context
24
+ }
25
+ end
26
+ end
27
+ end
@@ -28,7 +28,7 @@ module Abbu
28
28
 
29
29
  def headers
30
30
  %w[Name First Middle Last Email Phone Company Address Groups URLs Notes RelatedNames SocialProfiles Birthday
31
- Anniversary InstantMessages VerificationCode LunarBirthday]
31
+ Anniversary InstantMessages VerificationCode LunarBirthday ImagePath]
32
32
  end
33
33
 
34
34
  def row(contact)
@@ -50,7 +50,7 @@ module Abbu
50
50
  format_related_names(contact.related_names), format_social_profiles(contact.social_profiles),
51
51
  format_date(contact.birthday), format_date(contact.anniversary),
52
52
  format_instant_messages(contact.instant_messages), contact.verification_code,
53
- format_date(contact.lunar_birthday)
53
+ format_date(contact.lunar_birthday), contact.image_path.to_s
54
54
  ]
55
55
  end
56
56
 
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'json'
5
+ require 'time'
5
6
 
6
7
  module Abbu
7
8
  module Exporters
@@ -52,7 +53,12 @@ module Abbu
52
53
  dates: contact.dates,
53
54
  instant_messages: contact.instant_messages,
54
55
  verification_code: contact.verification_code,
55
- lunar_birthday: contact.lunar_birthday
56
+ lunar_birthday: contact.lunar_birthday,
57
+ image_uri: contact.image_uri,
58
+ image_path: contact.image_path&.to_s,
59
+ created_at: contact.created_at&.iso8601,
60
+ modified_at: contact.modified_at&.iso8601,
61
+ source: contact.source
56
62
  }.compact
57
63
  end
58
64
  end
@@ -1,6 +1,8 @@
1
1
  # lib/abbu/exporters/vcard_exporter.rb
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'uri'
5
+
4
6
  module Abbu
5
7
  module Exporters
6
8
  class VcardExporter
@@ -35,6 +37,7 @@ module Abbu
35
37
  append_instant_messages(lines, contact)
36
38
  append_verification_code(lines, contact)
37
39
  append_notes(lines, contact)
40
+ append_photo(lines, contact)
38
41
 
39
42
  lines << 'END:VCARD'
40
43
  lines.join("\n")
@@ -75,6 +78,17 @@ module Abbu
75
78
  lines << "X-VERIFICATION-CODE:#{contact.verification_code}" if contact.verification_code
76
79
  end
77
80
 
81
+ def append_photo(lines, contact)
82
+ return unless contact.image_path
83
+
84
+ lines << "PHOTO;VALUE=URI:#{file_uri(contact.image_path)}"
85
+ end
86
+
87
+ def file_uri(path)
88
+ escaped_path = URI::DEFAULT_PARSER.escape(path.to_s)
89
+ URI::Generic.build(scheme: 'file', path: escaped_path)
90
+ end
91
+
78
92
  def append_notes(lines, contact)
79
93
  contact.notes.each { |n| lines << "NOTE:#{n}" }
80
94
  end
@@ -125,7 +139,11 @@ module Abbu
125
139
  return unless contact.anniversary
126
140
 
127
141
  lines << "X-ABDATE;type=pref:#{format_vcard_date(contact.anniversary)}"
128
- lines << 'X-ABLABEL:_$!<Anniversary>!$_'
142
+ lines << "X-ABLABEL:#{anniversary_label(contact.anniversary)}"
143
+ end
144
+
145
+ def anniversary_label(anniversary)
146
+ anniversary[:raw_label] || anniversary[:label] || 'Anniversary'
129
147
  end
130
148
 
131
149
  def format_vcard_date(date)
@@ -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
@@ -3,6 +3,10 @@
3
3
 
4
4
  require 'plist'
5
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'
6
10
 
7
11
  module Abbu
8
12
  module Parsers
@@ -21,8 +25,13 @@ module Abbu
21
25
  }.freeze
22
26
 
23
27
  # Accepts either a directory path (scans for *.abcdp) or an array of file paths
24
- def initialize(paths)
28
+ attr_reader :diagnostics
29
+
30
+ def initialize(paths, root_path: nil, diagnostics: nil, strict: false)
25
31
  @paths = resolve_paths(paths)
32
+ @root_path = root_path
33
+ @diagnostics = diagnostics || []
34
+ @strict = strict
26
35
  end
27
36
 
28
37
  def contacts
@@ -45,15 +54,32 @@ module Abbu
45
54
 
46
55
  def parse_file(file)
47
56
  data = Plist.parse_xml(file.to_s)
48
- return nil unless data
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
49
65
 
50
- build_contact(data)
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
51
76
  end
52
77
 
53
- def build_contact(data)
78
+ def build_contact(data, file)
54
79
  contact = Contact.new
55
80
  assign_flat_fields(contact, data)
56
81
  assign_multi_value_fields(contact, data)
82
+ contact.source = Utils::SourceDescriptor.new(file, root_path: @root_path).to_h
57
83
  contact
58
84
  end
59
85
 
@@ -76,14 +102,14 @@ module Abbu
76
102
  contact.birthday = extract_birthday(data)
77
103
  contact.lunar_birthday = extract_lunar_birthday(data)
78
104
  contact.dates = extract_dates(data)
79
- contact.anniversary = contact.dates.find { |d| d[:label] == '_$!<Anniversary>!$_' }
105
+ contact.anniversary = contact.dates.find { |d| d[:label] == 'Anniversary' }
80
106
  end
81
107
 
82
108
  def extract_labeled_values(data, key, value_key)
83
109
  return [] unless data[key]&.dig('values')
84
110
 
85
111
  data[key]['values'].map do |entry|
86
- { value_key => entry['value'], label: entry['label'] }
112
+ { value_key => entry['value'], **label_fields(entry['label']) }
87
113
  end
88
114
  end
89
115
 
@@ -98,7 +124,7 @@ module Abbu
98
124
  state: addr['State'],
99
125
  zip: addr['ZIP'],
100
126
  country: addr['Country'],
101
- label: entry['label']
127
+ **label_fields(entry['label'])
102
128
  }
103
129
  end
104
130
  end
@@ -122,7 +148,7 @@ module Abbu
122
148
 
123
149
  data['InstantMessage']['values'].map do |entry|
124
150
  msg = entry['value'] || {}
125
- { address: msg['address'], label: entry['label'], service: msg['serviceName'] }
151
+ { address: msg['address'], service: msg['serviceName'], **label_fields(entry['label']) }
126
152
  end
127
153
  end
128
154
 
@@ -130,14 +156,14 @@ module Abbu
130
156
  val = data['Birthday']
131
157
  return nil unless val.respond_to?(:year)
132
158
 
133
- { year: val.year, month: val.month, day: val.day, label: '_$!<Birthday>!$_' }
159
+ { year: val.year, month: val.month, day: val.day, label: 'Birthday', raw_label: nil }
134
160
  end
135
161
 
136
162
  def extract_lunar_birthday(data)
137
163
  val = data['LunarBirthday']
138
164
  return nil unless val.respond_to?(:year)
139
165
 
140
- { year: val.year, month: val.month, day: val.day, label: '_$!<LunarBirthday>!$_' }
166
+ { year: val.year, month: val.month, day: val.day, label: 'LunarBirthday', raw_label: nil }
141
167
  end
142
168
 
143
169
  def extract_dates(data)
@@ -147,9 +173,13 @@ module Abbu
147
173
  val = entry['value']
148
174
  next unless val.respond_to?(:year)
149
175
 
150
- { year: val.year, month: val.month, day: val.day, label: entry['label'] }
176
+ { year: val.year, month: val.month, day: val.day, **label_fields(entry['label']) }
151
177
  end
152
178
  end
179
+
180
+ def label_fields(raw_label)
181
+ { label: Utils::LabelNormalizer.normalize(raw_label), raw_label: raw_label }
182
+ end
153
183
  end
154
184
  end
155
185
  end