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
@@ -0,0 +1,53 @@
1
+ # lib/abbu/source.rb
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'group_catalog'
5
+ require_relative 'query'
6
+
7
+ module Abbu
8
+ # An observed input container, not an inferred Apple account or provider.
9
+ # Metadata and membership are frozen; contacts retain their existing mutability.
10
+ class Source
11
+ attr_reader :path, :relative_path, :kind, :identifier, :files, :contacts, :group_names, :groups
12
+
13
+ def initialize(path:, relative_path:, identifier:, files:, contacts:)
14
+ @path = copy_string(path)
15
+ @relative_path = copy_string(relative_path)
16
+ @identifier = copy_string(identifier)
17
+ @kind = identifier.nil? ? 'root' : 'source'
18
+ @files = snapshot_files(files)
19
+ @contacts = Query.new(contacts.to_a.dup).freeze
20
+ @group_names = snapshot_group_names
21
+ @groups = GroupCatalog.new(@contacts).groups
22
+ freeze
23
+ end
24
+
25
+ # No currently supported evidence identifies an account provider.
26
+ def provider
27
+ nil
28
+ end
29
+
30
+ def groups_for(contact)
31
+ groups.select { |group| group.include?(contact) }
32
+ end
33
+
34
+ def to_h
35
+ { path: path, relative_path: relative_path, kind: kind, identifier: identifier,
36
+ provider: provider, files: files, contact_count: contacts.count, group_names: group_names }
37
+ end
38
+
39
+ private
40
+
41
+ def copy_string(value)
42
+ value&.dup&.freeze
43
+ end
44
+
45
+ def snapshot_files(files)
46
+ files.map { |file| file.transform_values { |value| copy_string(value) }.freeze }.freeze
47
+ end
48
+
49
+ def snapshot_group_names
50
+ @contacts.flat_map(&:groups).compact.uniq.sort.map { |name| copy_string(name) }.freeze
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ # lib/abbu/source_catalog.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'pathname'
5
+ require_relative 'source'
6
+ require_relative 'utils/source_descriptor'
7
+
8
+ module Abbu
9
+ # Internal adapter over the same file descriptors used by the parsers.
10
+ class SourceCatalog
11
+ def initialize(paths, root_path:, contacts:)
12
+ @root_path = Pathname.new(root_path).expand_path
13
+ @files = paths.map { |path| Utils::SourceDescriptor.new(path, root_path: root_path).to_h }
14
+ .sort_by { |file| file.fetch(:relative_path) }
15
+ @contacts_by_path = contacts.group_by { |contact| contact.source&.fetch(:path, nil) }
16
+ end
17
+
18
+ def sources
19
+ @files.group_by { |file| file.fetch(:identifier) }.map do |identifier, files|
20
+ relative_path = identifier.nil? ? '.' : "Sources/#{identifier}"
21
+ Source.new(path: @root_path.join(relative_path).to_s, relative_path: relative_path,
22
+ identifier: identifier, files: files, contacts: contacts_for(files))
23
+ end.sort_by(&:relative_path).freeze
24
+ end
25
+
26
+ private
27
+
28
+ def contacts_for(files)
29
+ files.flat_map { |file| @contacts_by_path.fetch(file.fetch(:path), []) }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,40 @@
1
+ # lib/abbu/timestamp_range.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+
6
+ module Abbu
7
+ # Internal filter over observed timestamps, never a claim about human edits.
8
+ class TimestampRange
9
+ FORMAT = /\A\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/
10
+
11
+ def initialize(field, since: nil, before: nil)
12
+ raise ArgumentError, 'field must be :created_at or :modified_at' unless %i[created_at modified_at].include?(field)
13
+ raise ArgumentError, 'at least one timestamp bound is required' if since.nil? && before.nil?
14
+
15
+ @since = parse(since) unless since.nil?
16
+ @before = parse(before) unless before.nil?
17
+ validate_order
18
+ end
19
+
20
+ def include?(value)
21
+ !value.nil? && (@since.nil? || value >= @since) && (@before.nil? || value < @before)
22
+ end
23
+
24
+ private
25
+
26
+ def validate_order
27
+ raise ArgumentError, 'since must be earlier than before' if @since && @before && @since >= @before
28
+ end
29
+
30
+ def parse(value)
31
+ return value if value.is_a?(Time)
32
+
33
+ unless value.is_a?(String) && FORMAT.match?(value)
34
+ raise ArgumentError, 'timestamp must be a Time or ISO 8601 datetime with explicit timezone'
35
+ end
36
+
37
+ DateTime.iso8601(value, Date::GREGORIAN).to_time
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,97 @@
1
+ # lib/abbu/utils/contact_identity.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Abbu
5
+ module Utils
6
+ class ContactIdentity
7
+ Signal = Struct.new(:type, :normalized, :raw, keyword_init: true)
8
+
9
+ attr_reader :contact
10
+
11
+ def initialize(contact)
12
+ @contact = contact
13
+ end
14
+
15
+ def emails
16
+ contact.emails.filter_map do |entry|
17
+ raw = entry.is_a?(Hash) ? entry[:address] : entry
18
+ normalized = normalize_email(raw)
19
+ Signal.new(type: :email, normalized: normalized, raw: raw).freeze if valid_email?(normalized)
20
+ end
21
+ end
22
+
23
+ def phones
24
+ contact.phones.filter_map do |entry|
25
+ raw = entry.is_a?(Hash) ? entry[:number] : entry
26
+ normalized = normalize_phone(raw)
27
+ Signal.new(type: phone_type(normalized), normalized: normalized, raw: raw).freeze if normalized
28
+ end
29
+ end
30
+
31
+ def name
32
+ signal(:name, contact.full_name)
33
+ end
34
+
35
+ def organization
36
+ signal(:organization, contact.company)
37
+ end
38
+
39
+ def source
40
+ contact.source
41
+ end
42
+
43
+ def same_source?(other)
44
+ source_key = source&.fetch(:relative_path, nil)
45
+ source_key && source_key == other.source&.fetch(:relative_path, nil)
46
+ end
47
+
48
+ private
49
+
50
+ def signal(type, raw)
51
+ normalized = normalize_text(raw)
52
+ Signal.new(type: type, normalized: normalized, raw: raw).freeze unless normalized.empty?
53
+ end
54
+
55
+ def normalize_text(value)
56
+ value.to_s.unicode_normalize(:nfkc).downcase
57
+ .gsub(/[^\p{Alnum}\p{Mark}]+/u, ' ')
58
+ .strip
59
+ .gsub(/\s+/, ' ')
60
+ end
61
+
62
+ def normalize_email(value)
63
+ value.to_s.unicode_normalize(:nfkc).strip.downcase
64
+ end
65
+
66
+ def valid_email?(value)
67
+ value.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
68
+ end
69
+
70
+ def normalize_phone(value)
71
+ raw = value.to_s.strip
72
+ extension = raw[/\b(?:ext\.?|x)\s*(\d+)\z/i, 1]
73
+ core = raw.sub(/\b(?:ext\.?|x)\s*\d+\z/i, '')
74
+ digits = core.gsub(/\D/, '')
75
+ international = international?(core)
76
+ digits = digits.delete_prefix('00') if core.start_with?('00')
77
+ return unless valid_phone_length?(digits, international)
78
+
79
+ prefix = international ? '+' : 'national:'
80
+ normalized = "#{prefix}#{digits}"
81
+ extension ? "#{normalized};ext=#{extension}" : normalized
82
+ end
83
+
84
+ def valid_phone_length?(digits, international)
85
+ international ? digits.length.between?(8, 15) : digits.length >= 7
86
+ end
87
+
88
+ def international?(raw)
89
+ raw.start_with?('+', '00')
90
+ end
91
+
92
+ def phone_type(normalized)
93
+ normalized.start_with?('+') ? :international_phone : :source_local_phone
94
+ end
95
+ end
96
+ end
97
+ end
@@ -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.8.1'
6
6
  end
data/lib/abbu.rb CHANGED
@@ -3,16 +3,29 @@
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'
12
+ require_relative 'abbu/source'
7
13
  require_relative 'abbu/parsers/sqlite_parser'
8
14
  require_relative 'abbu/parsers/plist_parser'
9
15
  require_relative 'abbu/exporters/csv_exporter'
10
16
  require_relative 'abbu/exporters/json_exporter'
11
17
  require_relative 'abbu/exporters/vcard_exporter'
18
+ require_relative 'abbu/image_extractor'
19
+ require_relative 'abbu/utils/contact_identity'
12
20
  require_relative 'abbu/utils/deduplicator'
21
+ require_relative 'abbu/utils/label_normalizer'
13
22
 
14
23
  module Abbu
15
- def self.open(path)
16
- Archive.new(path)
24
+ def self.open(path, strict: false)
25
+ Archive.new(path, strict: strict)
26
+ end
27
+
28
+ def self.open_live(path = nil, strict: false)
29
+ LiveStore.new(path, strict: strict)
17
30
  end
18
31
  end
data/sig/group.rbs ADDED
@@ -0,0 +1,36 @@
1
+ # sig/group.rbs
2
+ module Abbu
3
+ class Contact
4
+ attr_accessor group_memberships: Array[{ record_id: Integer, name: String? }]
5
+ end
6
+
7
+ class Group
8
+ attr_reader record_id: Integer
9
+ attr_reader name: String?
10
+ attr_reader source: Hash[Symbol, String?]
11
+ attr_reader contacts: Query
12
+ def initialize: (record_id: Integer, name: String?, source: Hash[Symbol, String?],
13
+ contacts: Enumerable[Contact]) -> void
14
+ def include?: (Contact contact) -> bool
15
+ def to_h: () -> Hash[Symbol, untyped]
16
+ end
17
+
18
+ class Source
19
+ attr_reader groups: Array[Group]
20
+ def groups_for: (Contact contact) -> Array[Group]
21
+ end
22
+
23
+ class Archive
24
+ def groups: () -> Array[Group]
25
+ def groups_for: (Contact contact) -> Array[Group]
26
+ end
27
+
28
+ class LiveStore
29
+ def groups: () -> Array[Group]
30
+ def groups_for: (Contact contact) -> Array[Group]
31
+ end
32
+
33
+ class Query
34
+ def in_group: (Group group) -> Query
35
+ end
36
+ end
data/sig/query.rbs ADDED
@@ -0,0 +1,21 @@
1
+ # sig/query.rbs
2
+ module Abbu
3
+ class Contact
4
+ end
5
+
6
+ class Query
7
+ include Enumerable[Contact]
8
+
9
+ def initialize: (Enumerable[Contact] contacts) -> void
10
+ def each: () { (Contact) -> void } -> Array[Contact]
11
+ | () -> Enumerator[Contact, Array[Contact]]
12
+ def where: (Hash[Symbol, untyped] criteria) -> Query
13
+ def search: (String term) -> Query
14
+ def find_by_email: (String email) -> Query
15
+ def find_by_phone: (String phone) -> Query
16
+ def to_a: () -> Array[Contact]
17
+ def created_since: (Time | String value) -> Query
18
+ def modified_since: (Time | String value) -> Query
19
+ def date_range: (:created_at | :modified_at field, ?since: Time | String | nil, ?before: Time | String | nil) -> Query
20
+ end
21
+ end
data/sig/source.rbs ADDED
@@ -0,0 +1,25 @@
1
+ # sig/source.rbs
2
+ module Abbu
3
+ class Source
4
+ attr_reader path: String
5
+ attr_reader relative_path: String
6
+ attr_reader kind: String
7
+ attr_reader identifier: String?
8
+ attr_reader files: Array[Hash[Symbol, String?]]
9
+ attr_reader contacts: Query
10
+ attr_reader group_names: Array[String]
11
+
12
+ def initialize: (path: String, relative_path: String, identifier: String?,
13
+ files: Array[Hash[Symbol, String?]], contacts: Enumerable[Contact]) -> void
14
+ def provider: () -> nil
15
+ def to_h: () -> Hash[Symbol, untyped]
16
+ end
17
+
18
+ class Archive
19
+ def sources: () -> Array[Source]
20
+ end
21
+
22
+ class LiveStore
23
+ def sources: () -> Array[Source]
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ # sig/vcard_exporter.rbs
2
+ module Abbu
3
+ module Exporters
4
+ class VcardExporter
5
+ def initialize: (Enumerable[Contact] contacts) -> void
6
+ def to_file: (String | Pathname path) -> Integer
7
+ def to_stdout: () -> nil
8
+ end
9
+ end
10
+ 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