abbu 0.2.0 → 0.8.1

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +281 -8
  3. data/bin/abbu +230 -53
  4. data/docs/ABBU.md +369 -7
  5. data/docs/API_STABILITY.md +189 -0
  6. data/docs/CHANGELOG.md +179 -0
  7. data/docs/CONTRIBUTING.md +25 -3
  8. data/docs/FORMAT_COMPATIBILITY.md +97 -0
  9. data/docs/RELEASING.md +75 -0
  10. data/docs/TODO.md +73 -62
  11. data/lib/abbu/archive.rb +80 -6
  12. data/lib/abbu/contact.rb +6 -3
  13. data/lib/abbu/diagnostic.rb +27 -0
  14. data/lib/abbu/exporters/csv_exporter.rb +2 -2
  15. data/lib/abbu/exporters/json_exporter.rb +7 -1
  16. data/lib/abbu/exporters/vcard_document.rb +54 -0
  17. data/lib/abbu/exporters/vcard_encoding.rb +54 -0
  18. data/lib/abbu/exporters/vcard_exporter.rb +44 -27
  19. data/lib/abbu/group.rb +28 -0
  20. data/lib/abbu/group_catalog.rb +32 -0
  21. data/lib/abbu/image_extractor.rb +134 -0
  22. data/lib/abbu/live_store.rb +117 -0
  23. data/lib/abbu/parse_error.rb +13 -0
  24. data/lib/abbu/parsers/plist_parser.rb +41 -11
  25. data/lib/abbu/parsers/sqlite_parser.rb +139 -65
  26. data/lib/abbu/query.rb +92 -0
  27. data/lib/abbu/schema_inspector.rb +133 -0
  28. data/lib/abbu/source.rb +53 -0
  29. data/lib/abbu/source_catalog.rb +32 -0
  30. data/lib/abbu/timestamp_range.rb +40 -0
  31. data/lib/abbu/utils/contact_identity.rb +97 -0
  32. data/lib/abbu/utils/deduplicator.rb +130 -2
  33. data/lib/abbu/utils/image_resolver.rb +59 -0
  34. data/lib/abbu/utils/label_normalizer.rb +15 -0
  35. data/lib/abbu/utils/source_descriptor.rb +35 -0
  36. data/lib/abbu/version.rb +1 -1
  37. data/lib/abbu.rb +15 -2
  38. data/sig/group.rbs +36 -0
  39. data/sig/query.rbs +21 -0
  40. data/sig/source.rbs +25 -0
  41. data/sig/vcard_exporter.rbs +10 -0
  42. data/tasks/abbu.rake +1 -1
  43. metadata +33 -15
  44. data/bin/cleanse +0 -7
  45. data/bin/console +0 -10
  46. data/bin/dev +0 -7
  47. data/bin/lint +0 -7
  48. data/bin/outdated +0 -7
  49. data/bin/test +0 -7
data/lib/abbu/contact.rb CHANGED
@@ -4,19 +4,22 @@
4
4
  module Abbu
5
5
  class Contact
6
6
  attr_accessor :first_name, :middle_name, :last_name, :emails,
7
- :phones, :company, :addresses, :groups, :nickname,
7
+ :phones, :company, :addresses, :groups, :group_memberships, :nickname,
8
8
  :prefix, :suffix, :job_title, :department, :maiden_name,
9
9
  :phonetic_first_name, :phonetic_middle_name, :phonetic_last_name,
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
- def initialize
17
+ def initialize # rubocop:disable Metrics/MethodLength
16
18
  @emails = []
17
19
  @phones = []
18
20
  @addresses = []
19
21
  @groups = []
22
+ @group_memberships = []
20
23
  @urls = []
21
24
  @notes = []
22
25
  @related_names = []
@@ -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
@@ -0,0 +1,54 @@
1
+ # lib/abbu/exporters/vcard_document.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'vcard_encoding'
5
+
6
+ module Abbu
7
+ module Exporters
8
+ # Per-card grouping avoids ambiguous associations between repeated labels.
9
+ class VcardDocument
10
+ TYPES = {
11
+ 'EMAIL' => %w[INTERNET X400 PREF],
12
+ 'TEL' => %w[HOME WORK PREF VOICE FAX MSG CELL PAGER BBS MODEM CAR ISDN VIDEO PCS],
13
+ 'ADR' => %w[DOM INTL POSTAL PARCEL HOME WORK PREF],
14
+ 'IMPP' => %w[PERSONAL BUSINESS HOME WORK MOBILE PREF]
15
+ }.transform_values(&:freeze).freeze
16
+
17
+ def initialize
18
+ @lines = ['BEGIN:VCARD', 'VERSION:3.0']
19
+ @group_number = 0
20
+ end
21
+
22
+ def <<(line)
23
+ @lines << line
24
+ end
25
+
26
+ def labeled(property, value, entry, default_type: nil)
27
+ type = standard_type(property, entry[:label]) || default_type
28
+ header = type ? "#{property};TYPE=#{type}" : property
29
+ label = entry[:raw_label] || entry[:label]
30
+ return self << "#{header}:#{value}" if label.nil?
31
+
32
+ @group_number += 1
33
+ self << "item#{@group_number}.#{header}:#{value}"
34
+ self << "item#{@group_number}.X-ABLABEL:#{VcardEncoding.text(label)}"
35
+ end
36
+
37
+ def to_s
38
+ lines = (@lines + ['END:VCARD']).map { |line| VcardEncoding.fold(line) }
39
+ "#{lines.join("\r\n")}\r\n"
40
+ end
41
+
42
+ private
43
+
44
+ def standard_type(property, label)
45
+ return unless label.to_s.match?(/\A[A-Za-z0-9-]+\z/)
46
+
47
+ type = label.upcase
48
+ return 'INTERNET,PREF' if property == 'EMAIL' && type == 'PREF'
49
+
50
+ type if TYPES.fetch(property, []).include?(type)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ # lib/abbu/exporters/vcard_encoding.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'uri'
5
+
6
+ module Abbu
7
+ module Exporters
8
+ # Internal wire encoding; URI values and TEXT values have different grammars.
9
+ module VcardEncoding
10
+ module_function
11
+
12
+ def text(value)
13
+ utf8(value).gsub(/\\|;|,|\r\n|\r|\n/) do |character|
14
+ character.match?(/[\r\n]/) ? '\\n' : "\\#{character}"
15
+ end
16
+ end
17
+
18
+ def structured(values)
19
+ values.map { |value| text(value) }.join(';')
20
+ end
21
+
22
+ def uri(value)
23
+ URI::DEFAULT_PARSER.escape(utf8(value), %r{[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]})
24
+ end
25
+
26
+ def token(value)
27
+ string = utf8(value)
28
+ raise ArgumentError, 'vCard parameter must be an ASCII token' unless string.match?(/\A[A-Za-z0-9-]+\z/)
29
+
30
+ string
31
+ end
32
+
33
+ def fold(line)
34
+ parts = [+'']
35
+ utf8(line).each_char do |character|
36
+ parts << +' ' if parts.last.bytesize + character.bytesize > 75
37
+ parts.last << character
38
+ end
39
+ parts.join("\r\n")
40
+ end
41
+
42
+ def utf8(value)
43
+ string = value.to_s.encode(Encoding::UTF_8)
44
+ raise ArgumentError, 'vCard text has invalid encoding' unless string.valid_encoding?
45
+ if string.match?(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/)
46
+ raise ArgumentError,
47
+ 'vCard text contains an unsupported control character'
48
+ end
49
+
50
+ string
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,6 +1,10 @@
1
1
  # lib/abbu/exporters/vcard_exporter.rb
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'uri'
5
+
6
+ require_relative 'vcard_document'
7
+
4
8
  module Abbu
5
9
  module Exporters
6
10
  class VcardExporter
@@ -9,21 +13,21 @@ module Abbu
9
13
  end
10
14
 
11
15
  def to_file(path)
12
- File.write(path, generate)
16
+ File.binwrite(path, generate)
13
17
  end
14
18
 
15
19
  def to_stdout
16
- puts generate
20
+ print generate
17
21
  end
18
22
 
19
23
  private
20
24
 
21
25
  def generate
22
- @contacts.map { |c| vcard_for(c) }.join("\n")
26
+ @contacts.map { |c| vcard_for(c) }.join
23
27
  end
24
28
 
25
29
  def vcard_for(contact) # rubocop:disable Metrics/MethodLength
26
- lines = ['BEGIN:VCARD', 'VERSION:3.0']
30
+ lines = VcardDocument.new
27
31
 
28
32
  append_name_fields(lines, contact)
29
33
  append_emails(lines, contact)
@@ -35,14 +39,14 @@ module Abbu
35
39
  append_instant_messages(lines, contact)
36
40
  append_verification_code(lines, contact)
37
41
  append_notes(lines, contact)
42
+ append_photo(lines, contact)
38
43
 
39
- lines << 'END:VCARD'
40
- lines.join("\n")
44
+ lines.to_s
41
45
  end
42
46
 
43
47
  def append_name_fields(lines, contact)
44
- lines << "FN:#{contact.full_name}"
45
- lines << "N:#{name_components(contact).join(';')}"
48
+ lines << "FN:#{VcardEncoding.text(contact.full_name)}"
49
+ lines << "N:#{VcardEncoding.structured(name_components(contact))}"
46
50
  append_nickname(lines, contact)
47
51
  append_company(lines, contact)
48
52
  append_title(lines, contact)
@@ -54,68 +58,81 @@ module Abbu
54
58
  end
55
59
 
56
60
  def append_nickname(lines, contact)
57
- lines << "NICKNAME:#{contact.nickname}" if contact.nickname
61
+ lines << "NICKNAME:#{VcardEncoding.text(contact.nickname)}" if contact.nickname
58
62
  end
59
63
 
60
64
  def append_company(lines, contact)
61
- lines << "ORG:#{contact.company}" if contact.company
65
+ lines << "ORG:#{VcardEncoding.text(contact.company)}" if contact.company
62
66
  end
63
67
 
64
68
  def append_title(lines, contact)
65
- lines << "TITLE:#{contact.job_title}" if contact.job_title
69
+ lines << "TITLE:#{VcardEncoding.text(contact.job_title)}" if contact.job_title
66
70
  end
67
71
 
68
72
  def append_phonetic_names(lines, contact)
69
- lines << "X-PHONETIC-FIRST-NAME:#{contact.phonetic_first_name}" if contact.phonetic_first_name
70
- lines << "X-PHONETIC-MIDDLE-NAME:#{contact.phonetic_middle_name}" if contact.phonetic_middle_name
71
- lines << "X-PHONETIC-LAST-NAME:#{contact.phonetic_last_name}" if contact.phonetic_last_name
73
+ %i[first middle last].each do |part|
74
+ value = contact.public_send(:"phonetic_#{part}_name")
75
+ lines << "X-PHONETIC-#{part.upcase}-NAME:#{VcardEncoding.text(value)}" if value
76
+ end
72
77
  end
73
78
 
74
79
  def append_verification_code(lines, contact)
75
- lines << "X-VERIFICATION-CODE:#{contact.verification_code}" if contact.verification_code
80
+ lines << "X-VERIFICATION-CODE:#{VcardEncoding.text(contact.verification_code)}" if contact.verification_code
81
+ end
82
+
83
+ def append_photo(lines, contact)
84
+ return unless contact.image_path
85
+
86
+ lines << "PHOTO;VALUE=URI:#{file_uri(contact.image_path)}"
87
+ end
88
+
89
+ def file_uri(path)
90
+ escaped_path = URI::DEFAULT_PARSER.escape(path.to_s)
91
+ URI::Generic.build(scheme: 'file', path: escaped_path)
76
92
  end
77
93
 
78
94
  def append_notes(lines, contact)
79
- contact.notes.each { |n| lines << "NOTE:#{n}" }
95
+ contact.notes.each { |n| lines << "NOTE:#{VcardEncoding.text(n)}" }
80
96
  end
81
97
 
82
98
  def append_emails(lines, contact)
83
99
  contact.emails.each do |e|
84
- label = e[:label] || 'INTERNET'
85
- lines << "EMAIL;TYPE=#{label}:#{e[:address]}"
100
+ lines.labeled('EMAIL', VcardEncoding.text(e[:address]), e, default_type: 'INTERNET')
86
101
  end
87
102
  end
88
103
 
89
104
  def append_phones(lines, contact)
90
105
  contact.phones.each do |p|
91
- label = p[:label] || 'VOICE'
92
- lines << "TEL;TYPE=#{label}:#{p[:number]}"
106
+ lines.labeled('TEL', VcardEncoding.text(p[:number]), p, default_type: 'VOICE')
93
107
  end
94
108
  end
95
109
 
96
110
  def append_addresses(lines, contact)
97
111
  contact.addresses.each do |a|
98
- label = a[:label] || 'HOME'
99
- lines << "ADR;TYPE=#{label}:;;#{a[:street]};#{a[:city]};#{a[:state]};#{a[:zip]};#{a[:country]}"
112
+ value = VcardEncoding.structured([nil, nil, a[:street], a[:city], a[:state], a[:zip], a[:country]])
113
+ lines.labeled('ADR', value, a)
100
114
  end
101
115
  end
102
116
 
103
117
  def append_urls(lines, contact)
104
118
  contact.urls.each do |u|
105
- lines << "URL:#{u[:url]}"
119
+ lines.labeled('URL', VcardEncoding.uri(u[:url]), u)
106
120
  end
107
121
  end
108
122
 
109
123
  def append_social_profiles(lines, contact)
110
124
  contact.social_profiles.each do |sp|
111
- lines << "X-SOCIALPROFILE;TYPE=#{sp[:service]}:#{sp[:username]}"
125
+ type = sp[:service] ? ";TYPE=#{VcardEncoding.token(sp[:service])}" : ''
126
+ lines << "X-SOCIALPROFILE#{type}:#{VcardEncoding.text(sp[:username])}"
112
127
  end
113
128
  end
114
129
 
115
130
  def append_instant_messages(lines, contact)
116
131
  contact.instant_messages.each do |im|
117
132
  service = im[:service]&.downcase || 'unknown'
118
- lines << "IMPP;TYPE=#{im[:label]}:#{service}:#{im[:address]}"
133
+ raise ArgumentError, 'IM service must be a URI scheme' unless service.match?(/\A[a-z][a-z0-9+.-]*\z/)
134
+
135
+ lines.labeled('IMPP', "#{service}:#{VcardEncoding.uri(im[:address])}", im)
119
136
  end
120
137
  end
121
138
 
@@ -124,8 +141,8 @@ module Abbu
124
141
  lines << "X-LUNAR-BDAY:#{format_vcard_date(contact.lunar_birthday)}" if contact.lunar_birthday
125
142
  return unless contact.anniversary
126
143
 
127
- lines << "X-ABDATE;type=pref:#{format_vcard_date(contact.anniversary)}"
128
- lines << 'X-ABLABEL:_$!<Anniversary>!$_'
144
+ entry = { label: 'Anniversary' }.merge(contact.anniversary.compact)
145
+ lines.labeled('X-ABDATE', format_vcard_date(contact.anniversary), entry)
129
146
  end
130
147
 
131
148
  def format_vcard_date(date)
data/lib/abbu/group.rb ADDED
@@ -0,0 +1,28 @@
1
+ # lib/abbu/group.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'query'
5
+
6
+ module Abbu
7
+ # A snapshot of an observed SQLite group membership, local to one input file.
8
+ # Names and record keys are evidence, not globally unique Apple identifiers.
9
+ class Group
10
+ attr_reader :record_id, :name, :source, :contacts
11
+
12
+ def initialize(record_id:, name:, source:, contacts:)
13
+ @record_id = record_id
14
+ @name = name&.dup&.freeze
15
+ @source = source.transform_values { |value| value&.dup&.freeze }.freeze
16
+ @contacts = Query.new(contacts.to_a.uniq).freeze
17
+ freeze
18
+ end
19
+
20
+ def include?(contact)
21
+ contacts.any? { |member| member.equal?(contact) }
22
+ end
23
+
24
+ def to_h
25
+ { record_id: record_id, name: name, source: source, contact_count: contacts.count }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # lib/abbu/group_catalog.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'group'
5
+
6
+ module Abbu
7
+ class GroupCatalog
8
+ def initialize(contacts)
9
+ @contacts = contacts
10
+ end
11
+
12
+ def groups
13
+ entries_by_key = memberships.group_by { |contact, membership| [contact.source[:path], membership[:record_id]] }
14
+ groups = entries_by_key.values.map { |entries| build_group(entries) }
15
+ groups.sort_by { |group| [group.source[:relative_path], group.record_id] }.freeze
16
+ end
17
+
18
+ private
19
+
20
+ def memberships
21
+ @contacts.flat_map do |contact|
22
+ contact.group_memberships.map { |membership| [contact, membership] }
23
+ end
24
+ end
25
+
26
+ def build_group(entries)
27
+ contact, membership = entries.first
28
+ Group.new(record_id: membership[:record_id], name: membership[:name], source: contact.source,
29
+ contacts: entries.map(&:first))
30
+ end
31
+ end
32
+ end
@@ -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,117 @@
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
+ require_relative 'source_catalog'
8
+
9
+ module Abbu
10
+ class LiveStore
11
+ DATABASE_GLOB = 'AddressBook-v*.abcddb'
12
+ DEFAULT_RELATIVE_PATH = 'Library/Application Support/AddressBook'
13
+
14
+ class Error < StandardError; end
15
+ class NotFoundError < Error; end
16
+ class PermissionError < Error; end
17
+ class UnsupportedPlatformError < Error; end
18
+
19
+ attr_reader :diagnostics, :path
20
+
21
+ def self.default_path(home: Dir.home, platform: RUBY_PLATFORM)
22
+ unless platform.include?('darwin')
23
+ raise UnsupportedPlatformError,
24
+ 'Automatic live Contacts discovery is available only on macOS; supply an AddressBook directory path.'
25
+ end
26
+
27
+ Pathname.new(home).join(DEFAULT_RELATIVE_PATH)
28
+ end
29
+
30
+ def initialize(path = nil, strict: false)
31
+ @strict = strict
32
+ @diagnostics = []
33
+ @path = Pathname.new(path || self.class.default_path).expand_path
34
+ validate!
35
+ end
36
+
37
+ def contacts
38
+ @contacts ||= Parsers::SqliteParser.new(
39
+ database_paths, root_path: @path, readonly: true, diagnostics: diagnostics, strict: @strict
40
+ ).contacts
41
+ rescue SQLite3::CantOpenException, Errno::EACCES => e
42
+ raise permission_error(e.message)
43
+ end
44
+
45
+ def database_paths
46
+ @database_paths ||= discover_database_paths.tap do |paths|
47
+ raise NotFoundError, missing_database_message if paths.empty?
48
+ end
49
+ end
50
+
51
+ def sources
52
+ @sources ||= SourceCatalog.new(database_paths, root_path: @path, contacts: contacts).sources
53
+ end
54
+
55
+ def groups
56
+ @groups ||= sources.flat_map(&:groups).freeze
57
+ end
58
+
59
+ def groups_for(contact)
60
+ groups.select { |group| group.include?(contact) }
61
+ end
62
+
63
+ private
64
+
65
+ def validate!
66
+ stat = @path.stat
67
+ raise NotFoundError, "Live Contacts store is not a directory: #{@path}" unless stat.directory?
68
+
69
+ ensure_readable_directory!(@path)
70
+ rescue Errno::ENOENT
71
+ raise NotFoundError, "Live Contacts store not found: #{@path}"
72
+ rescue Errno::EACCES => e
73
+ raise permission_error(e.message)
74
+ end
75
+
76
+ def discover_database_paths
77
+ paths = @path.glob(DATABASE_GLOB)
78
+ sources_path = @path.join('Sources')
79
+ paths.concat(source_database_paths(sources_path)) if sources_path.exist?
80
+ paths.sort.each { |db_path| ensure_readable_file!(db_path) }
81
+ rescue Errno::EACCES => e
82
+ raise permission_error(e.message)
83
+ end
84
+
85
+ def source_database_paths(sources_path)
86
+ ensure_readable_directory!(sources_path)
87
+ sources_path.children.sort.select(&:directory?).flat_map do |source_path|
88
+ ensure_readable_directory!(source_path)
89
+ source_path.glob(DATABASE_GLOB)
90
+ end
91
+ end
92
+
93
+ def ensure_readable_directory!(path)
94
+ return if path.readable? && path.executable?
95
+
96
+ raise permission_error("directory is not readable: #{path}")
97
+ end
98
+
99
+ def ensure_readable_file!(path)
100
+ return if path.file? && path.readable?
101
+
102
+ raise permission_error("database is not readable: #{path}")
103
+ end
104
+
105
+ def missing_database_message
106
+ "No #{DATABASE_GLOB} databases found in live Contacts store: #{@path}"
107
+ end
108
+
109
+ def permission_error(detail)
110
+ PermissionError.new(
111
+ "Cannot read live Contacts store at #{@path} (#{detail}). " \
112
+ 'Grant Full Disk Access to the terminal or application running abbu in ' \
113
+ 'System Settings > Privacy & Security > Full Disk Access, then retry.'
114
+ )
115
+ end
116
+ end
117
+ end