abbu 0.4.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.
@@ -3,6 +3,8 @@
3
3
 
4
4
  require 'uri'
5
5
 
6
+ require_relative 'vcard_document'
7
+
6
8
  module Abbu
7
9
  module Exporters
8
10
  class VcardExporter
@@ -11,21 +13,21 @@ module Abbu
11
13
  end
12
14
 
13
15
  def to_file(path)
14
- File.write(path, generate)
16
+ File.binwrite(path, generate)
15
17
  end
16
18
 
17
19
  def to_stdout
18
- puts generate
20
+ print generate
19
21
  end
20
22
 
21
23
  private
22
24
 
23
25
  def generate
24
- @contacts.map { |c| vcard_for(c) }.join("\n")
26
+ @contacts.map { |c| vcard_for(c) }.join
25
27
  end
26
28
 
27
29
  def vcard_for(contact) # rubocop:disable Metrics/MethodLength
28
- lines = ['BEGIN:VCARD', 'VERSION:3.0']
30
+ lines = VcardDocument.new
29
31
 
30
32
  append_name_fields(lines, contact)
31
33
  append_emails(lines, contact)
@@ -39,13 +41,12 @@ module Abbu
39
41
  append_notes(lines, contact)
40
42
  append_photo(lines, contact)
41
43
 
42
- lines << 'END:VCARD'
43
- lines.join("\n")
44
+ lines.to_s
44
45
  end
45
46
 
46
47
  def append_name_fields(lines, contact)
47
- lines << "FN:#{contact.full_name}"
48
- lines << "N:#{name_components(contact).join(';')}"
48
+ lines << "FN:#{VcardEncoding.text(contact.full_name)}"
49
+ lines << "N:#{VcardEncoding.structured(name_components(contact))}"
49
50
  append_nickname(lines, contact)
50
51
  append_company(lines, contact)
51
52
  append_title(lines, contact)
@@ -57,25 +58,26 @@ module Abbu
57
58
  end
58
59
 
59
60
  def append_nickname(lines, contact)
60
- lines << "NICKNAME:#{contact.nickname}" if contact.nickname
61
+ lines << "NICKNAME:#{VcardEncoding.text(contact.nickname)}" if contact.nickname
61
62
  end
62
63
 
63
64
  def append_company(lines, contact)
64
- lines << "ORG:#{contact.company}" if contact.company
65
+ lines << "ORG:#{VcardEncoding.text(contact.company)}" if contact.company
65
66
  end
66
67
 
67
68
  def append_title(lines, contact)
68
- lines << "TITLE:#{contact.job_title}" if contact.job_title
69
+ lines << "TITLE:#{VcardEncoding.text(contact.job_title)}" if contact.job_title
69
70
  end
70
71
 
71
72
  def append_phonetic_names(lines, contact)
72
- lines << "X-PHONETIC-FIRST-NAME:#{contact.phonetic_first_name}" if contact.phonetic_first_name
73
- lines << "X-PHONETIC-MIDDLE-NAME:#{contact.phonetic_middle_name}" if contact.phonetic_middle_name
74
- 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
75
77
  end
76
78
 
77
79
  def append_verification_code(lines, contact)
78
- 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
79
81
  end
80
82
 
81
83
  def append_photo(lines, contact)
@@ -90,46 +92,47 @@ module Abbu
90
92
  end
91
93
 
92
94
  def append_notes(lines, contact)
93
- contact.notes.each { |n| lines << "NOTE:#{n}" }
95
+ contact.notes.each { |n| lines << "NOTE:#{VcardEncoding.text(n)}" }
94
96
  end
95
97
 
96
98
  def append_emails(lines, contact)
97
99
  contact.emails.each do |e|
98
- label = e[:label] || 'INTERNET'
99
- lines << "EMAIL;TYPE=#{label}:#{e[:address]}"
100
+ lines.labeled('EMAIL', VcardEncoding.text(e[:address]), e, default_type: 'INTERNET')
100
101
  end
101
102
  end
102
103
 
103
104
  def append_phones(lines, contact)
104
105
  contact.phones.each do |p|
105
- label = p[:label] || 'VOICE'
106
- lines << "TEL;TYPE=#{label}:#{p[:number]}"
106
+ lines.labeled('TEL', VcardEncoding.text(p[:number]), p, default_type: 'VOICE')
107
107
  end
108
108
  end
109
109
 
110
110
  def append_addresses(lines, contact)
111
111
  contact.addresses.each do |a|
112
- label = a[:label] || 'HOME'
113
- 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)
114
114
  end
115
115
  end
116
116
 
117
117
  def append_urls(lines, contact)
118
118
  contact.urls.each do |u|
119
- lines << "URL:#{u[:url]}"
119
+ lines.labeled('URL', VcardEncoding.uri(u[:url]), u)
120
120
  end
121
121
  end
122
122
 
123
123
  def append_social_profiles(lines, contact)
124
124
  contact.social_profiles.each do |sp|
125
- 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])}"
126
127
  end
127
128
  end
128
129
 
129
130
  def append_instant_messages(lines, contact)
130
131
  contact.instant_messages.each do |im|
131
132
  service = im[:service]&.downcase || 'unknown'
132
- 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)
133
136
  end
134
137
  end
135
138
 
@@ -138,12 +141,8 @@ module Abbu
138
141
  lines << "X-LUNAR-BDAY:#{format_vcard_date(contact.lunar_birthday)}" if contact.lunar_birthday
139
142
  return unless contact.anniversary
140
143
 
141
- lines << "X-ABDATE;type=pref:#{format_vcard_date(contact.anniversary)}"
142
- lines << "X-ABLABEL:#{anniversary_label(contact.anniversary)}"
143
- end
144
-
145
- def anniversary_label(anniversary)
146
- anniversary[:raw_label] || anniversary[:label] || 'Anniversary'
144
+ entry = { label: 'Anniversary' }.merge(contact.anniversary.compact)
145
+ lines.labeled('X-ABDATE', format_vcard_date(contact.anniversary), entry)
147
146
  end
148
147
 
149
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
@@ -4,6 +4,7 @@
4
4
  require 'pathname'
5
5
  require 'sqlite3'
6
6
  require_relative 'parsers/sqlite_parser'
7
+ require_relative 'source_catalog'
7
8
 
8
9
  module Abbu
9
10
  class LiveStore
@@ -47,6 +48,18 @@ module Abbu
47
48
  end
48
49
  end
49
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
+
50
63
  private
51
64
 
52
65
  def validate!
@@ -105,12 +105,14 @@ module Abbu
105
105
 
106
106
  def groups_for(db, record_id)
107
107
  query = <<-SQL
108
- SELECT g.ZFIRSTNAME
108
+ SELECT g.Z_PK, g.ZFIRSTNAME
109
109
  FROM Z_ABCDCONTACTGROUP j
110
110
  JOIN ZABCDRECORD g ON j.Z_GROUP = g.Z_PK
111
111
  WHERE j.Z_CONTACT = ?
112
112
  SQL
113
- optional_rows(db, record_id, 'Z_ABCDCONTACTGROUP', query).map { |row| row['ZFIRSTNAME'] }
113
+ optional_rows(db, record_id, 'Z_ABCDCONTACTGROUP', query).map do |row|
114
+ { record_id: row['Z_PK'], name: row['ZFIRSTNAME'] }
115
+ end
114
116
  end
115
117
 
116
118
  def urls_for(db, record_id)
@@ -237,7 +239,8 @@ module Abbu
237
239
  contact.emails = emails_for(db, record_id)
238
240
  contact.phones = phones_for(db, record_id)
239
241
  contact.addresses = addresses_for(db, record_id)
240
- contact.groups = groups_for(db, record_id)
242
+ contact.group_memberships = groups_for(db, record_id)
243
+ contact.groups = contact.group_memberships.map { |membership| membership[:name] }
241
244
  contact.urls = urls_for(db, record_id)
242
245
  contact.notes = notes_for(db, record_id)
243
246
  contact.related_names = related_names_for(db, record_id)
data/lib/abbu/query.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # lib/abbu/query.rb
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative 'timestamp_range'
5
+
4
6
  module Abbu
5
7
  class Query
6
8
  include Enumerable
@@ -49,6 +51,23 @@ module Abbu
49
51
  @contacts.dup
50
52
  end
51
53
 
54
+ def in_group(group)
55
+ self.class.new(select { |contact| group.include?(contact) })
56
+ end
57
+
58
+ def modified_since(value)
59
+ date_range(:modified_at, since: value)
60
+ end
61
+
62
+ def created_since(value)
63
+ date_range(:created_at, since: value)
64
+ end
65
+
66
+ def date_range(field, since: nil, before: nil)
67
+ range = TimestampRange.new(field, since: since, before: before)
68
+ self.class.new(select { |contact| range.include?(contact.public_send(field)) })
69
+ end
70
+
52
71
  private
53
72
 
54
73
  def matches_criteria?(contact, criteria)
@@ -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
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.4.0'
5
+ VERSION = '0.8.1'
6
6
  end
data/lib/abbu.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'abbu/archive'
9
9
  require_relative 'abbu/live_store'
10
10
  require_relative 'abbu/query'
11
11
  require_relative 'abbu/schema_inspector'
12
+ require_relative 'abbu/source'
12
13
  require_relative 'abbu/parsers/sqlite_parser'
13
14
  require_relative 'abbu/parsers/plist_parser'
14
15
  require_relative 'abbu/exporters/csv_exporter'
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
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.4.0
4
+ version: 0.8.1
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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -220,8 +220,11 @@ files:
220
220
  - README.md
221
221
  - bin/abbu
222
222
  - docs/ABBU.md
223
+ - docs/API_STABILITY.md
223
224
  - docs/CHANGELOG.md
224
225
  - docs/CONTRIBUTING.md
226
+ - docs/FORMAT_COMPATIBILITY.md
227
+ - docs/RELEASING.md
225
228
  - docs/TODO.md
226
229
  - examples/deduplicate_contacts.rb
227
230
  - examples/export_to_api.rb
@@ -236,7 +239,11 @@ files:
236
239
  - lib/abbu/diagnostic.rb
237
240
  - lib/abbu/exporters/csv_exporter.rb
238
241
  - lib/abbu/exporters/json_exporter.rb
242
+ - lib/abbu/exporters/vcard_document.rb
243
+ - lib/abbu/exporters/vcard_encoding.rb
239
244
  - lib/abbu/exporters/vcard_exporter.rb
245
+ - lib/abbu/group.rb
246
+ - lib/abbu/group_catalog.rb
240
247
  - lib/abbu/image_extractor.rb
241
248
  - lib/abbu/live_store.rb
242
249
  - lib/abbu/parse_error.rb
@@ -244,12 +251,19 @@ files:
244
251
  - lib/abbu/parsers/sqlite_parser.rb
245
252
  - lib/abbu/query.rb
246
253
  - lib/abbu/schema_inspector.rb
254
+ - lib/abbu/source.rb
255
+ - lib/abbu/source_catalog.rb
256
+ - lib/abbu/timestamp_range.rb
247
257
  - lib/abbu/utils/contact_identity.rb
248
258
  - lib/abbu/utils/deduplicator.rb
249
259
  - lib/abbu/utils/image_resolver.rb
250
260
  - lib/abbu/utils/label_normalizer.rb
251
261
  - lib/abbu/utils/source_descriptor.rb
252
262
  - lib/abbu/version.rb
263
+ - sig/group.rbs
264
+ - sig/query.rbs
265
+ - sig/source.rbs
266
+ - sig/vcard_exporter.rbs
253
267
  - tasks/abbu.rake
254
268
  homepage: https://github.com/scarver2/abbu
255
269
  licenses: