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.
- checksums.yaml +4 -4
- data/README.md +127 -17
- data/bin/abbu +185 -54
- data/docs/ABBU.md +226 -7
- data/docs/CHANGELOG.md +193 -0
- data/docs/CONTRIBUTING.md +61 -0
- data/docs/TODO.md +180 -0
- data/lib/abbu/archive.rb +68 -8
- data/lib/abbu/contact.rb +13 -4
- data/lib/abbu/diagnostic.rb +27 -0
- data/lib/abbu/exporters/csv_exporter.rb +26 -12
- data/lib/abbu/exporters/json_exporter.rb +17 -1
- data/lib/abbu/exporters/vcard_exporter.rb +91 -7
- data/lib/abbu/image_extractor.rb +134 -0
- data/lib/abbu/live_store.rb +104 -0
- data/lib/abbu/parse_error.rb +13 -0
- data/lib/abbu/parsers/plist_parser.rb +172 -4
- data/lib/abbu/parsers/sqlite_parser.rb +158 -55
- data/lib/abbu/query.rb +73 -0
- data/lib/abbu/schema_inspector.rb +133 -0
- data/lib/abbu/utils/contact_identity.rb +97 -0
- data/lib/abbu/utils/deduplicator.rb +130 -2
- data/lib/abbu/utils/image_resolver.rb +59 -0
- data/lib/abbu/utils/label_normalizer.rb +15 -0
- data/lib/abbu/utils/source_descriptor.rb +35 -0
- data/lib/abbu/version.rb +1 -1
- data/lib/abbu.rb +14 -2
- data/tasks/abbu.rake +1 -1
- metadata +50 -16
- data/CHANGELOG.md +0 -67
- data/bin/cleanse +0 -7
- data/bin/console +0 -10
- data/bin/dev +0 -7
- data/bin/lint +0 -7
- data/bin/outdated +0 -7
- data/bin/test +0 -7
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|