active_sanction 1.0.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 +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/partial_date"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Sources
|
|
10
|
+
class AustraliaDfat < Base
|
|
11
|
+
# The dates DFAT writes, which are not one format and were never meant to
|
|
12
|
+
# be read by a machine.
|
|
13
|
+
#
|
|
14
|
+
# PublishedDate.dates("13/06/1961, Approximately 1963")
|
|
15
|
+
# # => [[#<PartialDate 1961-06-13>, #<PartialDate ~1963>], []]
|
|
16
|
+
#
|
|
17
|
+
# PublishedDate.listing("Listed on 25 Jan. 2001 (amended on 3 Sep. 2003)")
|
|
18
|
+
# # => #<PartialDate 2001-01-25>
|
|
19
|
+
#
|
|
20
|
+
# ### One cell, any number of birth dates, in any number of spellings
|
|
21
|
+
#
|
|
22
|
+
# The Date of Birth column holds every date the listing carries, and a
|
|
23
|
+
# committee that received four reports of when somebody was born publishes
|
|
24
|
+
# four. `1945, 1946, 1947, 1948, 1949, 1950, 1955, 1956, 1957, 1958` is one
|
|
25
|
+
# cell of this list, and so are `a) 5/12/1970 b) 1969`, `Approximately:
|
|
26
|
+
# Between 1972 and 1975`, `12 April 1965` and `03/1955`. The separators
|
|
27
|
+
# are commas, the letters of an enumeration DFAT inherits from the UN's
|
|
28
|
+
# own text on 17 rows, and -- on 28 more -- nothing but a space between
|
|
29
|
+
# two dates.
|
|
30
|
+
#
|
|
31
|
+
# Four records out of the 3,906 carry a fragment this cannot read, and all
|
|
32
|
+
# four are typed wrong at the source: `1980.1981`, `/02/1961`,
|
|
33
|
+
# `7/02/1950/11/1950`, `10/061962`. Each is kept verbatim in the record's
|
|
34
|
+
# remarks rather than dropped, so a date this version cannot parse is
|
|
35
|
+
# still in front of whoever reads the hit.
|
|
36
|
+
#
|
|
37
|
+
# ### Day first, because Australia writes day first
|
|
38
|
+
#
|
|
39
|
+
# `05/12/1970` is the fifth of December. Nothing in the file says so, and
|
|
40
|
+
# reading it the American way would move some of the 2,013 dates written
|
|
41
|
+
# this way by up to eleven months -- silently, and only for the days below
|
|
42
|
+
# the thirteenth. What settles it is the publisher: DFAT writes Australian
|
|
43
|
+
# dates, and not one of the 2,013 has a middle component above twelve,
|
|
44
|
+
# which a file in the other order would produce on any date after the
|
|
45
|
+
# twelfth of a month.
|
|
46
|
+
#
|
|
47
|
+
# Excel serial dates do not come through here at all: they arrive as
|
|
48
|
+
# ISO 8601 from the spreadsheet reader, which knows the cell's format,
|
|
49
|
+
# and PartialDate::Parser reads those directly.
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
52
|
+
module PublishedDate
|
|
53
|
+
extend T::Sig
|
|
54
|
+
|
|
55
|
+
# `a)`, `b)`: the enumerators, which separate values rather than being
|
|
56
|
+
# part of one. Also removed from the addresses, for the same reason.
|
|
57
|
+
ENUMERATOR = T.let(/(?:\A|[[:space:]])[a-z]\)[[:space:]]*/, Regexp)
|
|
58
|
+
|
|
59
|
+
# Two dates with nothing between them but space. A digit on each side is
|
|
60
|
+
# what distinguishes `14/04/1970 14/04/1971`, which is two dates, from
|
|
61
|
+
# `12 April 1965`, which is one.
|
|
62
|
+
ADJACENT = T.let(/(?<=\d)[[:space:]]+(?=\d)/, Regexp)
|
|
63
|
+
|
|
64
|
+
# DFAT writes `Approximately:` before a span and `Approximately` before
|
|
65
|
+
# a year, and PartialDate::Parser knows the second spelling only.
|
|
66
|
+
APPROXIMATELY = T.let(/\Aapproximately:/i, Regexp)
|
|
67
|
+
|
|
68
|
+
# Every kind of space, because the cells carry more than one: 203 of the
|
|
69
|
+
# 6,823 birth dates end in a non-breaking space, which `String#strip`
|
|
70
|
+
# does not remove and which leaves an otherwise perfectly good
|
|
71
|
+
# `24/08/1962` matching no pattern at all.
|
|
72
|
+
SPACE = T.let(/[[:space:]]+/, Regexp)
|
|
73
|
+
|
|
74
|
+
DAY_MONTH_YEAR = T.let(%r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z}, Regexp)
|
|
75
|
+
MONTH_YEAR = T.let(%r{\A(\d{1,2})/(\d{4})\z}, Regexp)
|
|
76
|
+
|
|
77
|
+
# A listing date is the one that follows the word "on", which is how
|
|
78
|
+
# every row that states one states it: `Listed on 25 January 2001`,
|
|
79
|
+
# `Designated under the Autonomous Sanctions Regulations 2011 on 2 March
|
|
80
|
+
# 2012`. Matching the bare year in a legislative title -- which is what
|
|
81
|
+
# the other 63% of the column consists of -- would report the year an
|
|
82
|
+
# instrument was made as the day a person was sanctioned.
|
|
83
|
+
LISTED_ON = T.let(/(\w[\w-]*)[[:space:]]+on:?[[:space:]]+(\d{1,2}[[:space:]]+[A-Za-z]+\.?,?[[:space:]]+\d{4})/,
|
|
84
|
+
Regexp)
|
|
85
|
+
|
|
86
|
+
# A row that opens with a date and explains it afterwards: `28 Jan. 2003
|
|
87
|
+
# (amended on 2 Jul. 2007, ...)`.
|
|
88
|
+
LISTED_FIRST = T.let(/\A(\d{1,2}[[:space:]]+[A-Za-z]+\.?,?[[:space:]]+\d{4})/, Regexp)
|
|
89
|
+
|
|
90
|
+
# The words that make the date after them the wrong one. 17 rows read
|
|
91
|
+
# `... List 2001 (updated on 5 Aug. 2004)`, where the first date in the
|
|
92
|
+
# cell is when the listing was last touched rather than when it was
|
|
93
|
+
# made, and a designation dated by its own last amendment is worse than
|
|
94
|
+
# one left undated.
|
|
95
|
+
AMENDMENT = T.let(/\A(?:amend|relist|re-list|updat|expir)/i, Regexp)
|
|
96
|
+
|
|
97
|
+
module_function
|
|
98
|
+
|
|
99
|
+
# Every date in one cell, and the fragments that could not be read --
|
|
100
|
+
# which the adapter keeps in remarks rather than losing.
|
|
101
|
+
sig { params(text: T.untyped).returns([T::Array[PartialDate], T::Array[String]]) }
|
|
102
|
+
def dates(text)
|
|
103
|
+
dates = []
|
|
104
|
+
unread = []
|
|
105
|
+
fragments(text).each do |fragment|
|
|
106
|
+
date = one(fragment)
|
|
107
|
+
date.nil? ? unread << fragment : dates << date
|
|
108
|
+
end
|
|
109
|
+
[dates.uniq, unread]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The date the designation was made, from the Listing Information prose,
|
|
113
|
+
# or nil where DFAT names only the instrument -- which is 63% of the
|
|
114
|
+
# list, and is the publisher saying nothing rather than this failing to
|
|
115
|
+
# read it.
|
|
116
|
+
sig { params(text: T.untyped).returns(T.nilable(PartialDate)) }
|
|
117
|
+
def listing(text)
|
|
118
|
+
string = text.to_s
|
|
119
|
+
opening = LISTED_FIRST.match(string)
|
|
120
|
+
return PartialDate::Parser.call(opening[1]) if opening
|
|
121
|
+
|
|
122
|
+
match = LISTED_ON.match(string)
|
|
123
|
+
return nil if match.nil? || AMENDMENT.match?(match[1].to_s)
|
|
124
|
+
|
|
125
|
+
PartialDate::Parser.call(match[2])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One cell split into the values it holds. Commas first, because they
|
|
129
|
+
# are the separator DFAT means; then the enumerators and the bare space,
|
|
130
|
+
# which are what the UN's own prose left behind.
|
|
131
|
+
sig { params(text: T.untyped).returns(T::Array[String]) }
|
|
132
|
+
def fragments(text)
|
|
133
|
+
text.to_s.gsub(ENUMERATOR, ",").split(",").flat_map { |part| part.split(ADJACENT) }
|
|
134
|
+
.map { |part| part.gsub(SPACE, " ").strip }.reject(&:empty?)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
sig { params(fragment: String).returns(T.nilable(PartialDate)) }
|
|
138
|
+
def one(fragment)
|
|
139
|
+
slashed(fragment) || PartialDate::Parser.call(fragment.sub(APPROXIMATELY, "approximately"))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The two shapes with slashes in them, which PartialDate::Parser does
|
|
143
|
+
# not read and must not guess at: `05/12/1970` is a date in one order
|
|
144
|
+
# and a different date in the other, and only the publisher settles it.
|
|
145
|
+
sig { params(fragment: String).returns(T.nilable(PartialDate)) }
|
|
146
|
+
def slashed(fragment)
|
|
147
|
+
if (match = DAY_MONTH_YEAR.match(fragment))
|
|
148
|
+
build(year: match[3].to_i, month: match[2].to_i, day: match[1].to_i)
|
|
149
|
+
elsif (match = MONTH_YEAR.match(fragment))
|
|
150
|
+
build(year: match[2].to_i, month: match[1].to_i)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A shape the regexes accept can still be an impossible date -- 31
|
|
155
|
+
# February -- and that is PartialDate's judgment, not ours. An
|
|
156
|
+
# unreadable fragment is reported as unread rather than dropped.
|
|
157
|
+
sig { params(attributes: T.untyped).returns(T.nilable(PartialDate)) }
|
|
158
|
+
def build(**attributes)
|
|
159
|
+
PartialDate.new(**attributes)
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/entity"
|
|
7
|
+
require "active_sanction/name"
|
|
8
|
+
require "active_sanction/address"
|
|
9
|
+
require "active_sanction/identifier"
|
|
10
|
+
require "active_sanction/partial_date"
|
|
11
|
+
require "active_sanction/sources/remarks"
|
|
12
|
+
|
|
13
|
+
module ActiveSanction
|
|
14
|
+
module Sources
|
|
15
|
+
class AustraliaDfat < Base
|
|
16
|
+
# The rows sharing one reference, turned into an Entity.
|
|
17
|
+
#
|
|
18
|
+
# Separate from the adapter because they are two jobs: the adapter says
|
|
19
|
+
# what the list is and where it lives, and this says what DFAT's columns
|
|
20
|
+
# mean. The mapping is where all the judgment sits, so it is worth being
|
|
21
|
+
# able to read it on its own.
|
|
22
|
+
#
|
|
23
|
+
# ### A record is several rows, and the reference says which
|
|
24
|
+
#
|
|
25
|
+
# DFAT publishes one row per *name*, not per person. `1000` is the primary
|
|
26
|
+
# name and `1000a`, `1000b`, `1000c` are its aliases, and every other
|
|
27
|
+
# column is repeated on each of them. The 11,163 rows of the published
|
|
28
|
+
# list are 3,906 records: 3,906 primary names, 6,802 aliases and 455 names
|
|
29
|
+
# in original script.
|
|
30
|
+
#
|
|
31
|
+
# The repetition is not quite exact. 80 groups disagree with themselves
|
|
32
|
+
# about the additional information, 39 about the birth dates and 23 about
|
|
33
|
+
# the address -- an alias row carrying a detail the primary row does not,
|
|
34
|
+
# usually because the two came from different UN listings of the same
|
|
35
|
+
# person. So every column is unioned across the group rather than read off
|
|
36
|
+
# the primary row: two of the 3,906 records have a place of birth, and one
|
|
37
|
+
# an address, only because an alias row carried it.
|
|
38
|
+
#
|
|
39
|
+
# ### The trap in this list is the Control Date
|
|
40
|
+
#
|
|
41
|
+
# It is on all 11,163 rows, it is a real date, and it is not the day
|
|
42
|
+
# anybody was sanctioned. DFAT's own guide defines it as "the last date
|
|
43
|
+
# the sanction entry was updated or edited on the Consolidated List",
|
|
44
|
+
# which for the Taliban listings of January 2001 is a date in 2026. An
|
|
45
|
+
# adapter that mapped it to `listed_on` would report the whole list as
|
|
46
|
+
# having been sanctioned within the last few months, and would do it
|
|
47
|
+
# without anything looking wrong.
|
|
48
|
+
#
|
|
49
|
+
# The listing date is in the Listing Information prose instead, on the
|
|
50
|
+
# 1,438 records that state one; see PublishedDate.listing. The Control
|
|
51
|
+
# Date is kept in remarks, labelled as what it is.
|
|
52
|
+
#
|
|
53
|
+
# @api private
|
|
54
|
+
class Record
|
|
55
|
+
extend T::Sig
|
|
56
|
+
|
|
57
|
+
# The `Type` column, which is populated on every row and is the only
|
|
58
|
+
# place the list says what a listing is about. 346 rows are vessels:
|
|
59
|
+
# without the distinct type a search for a person can rank a tanker.
|
|
60
|
+
TYPES = T.let(
|
|
61
|
+
{ "individual" => :individual, "entity" => :organization, "vessel" => :vessel }.freeze,
|
|
62
|
+
T::Hash[String, Symbol]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# `Name Type`, case-folded. A name in original script is the same name
|
|
66
|
+
# written another way rather than the designation proper, so it is filed
|
|
67
|
+
# as an alias -- which is what leaves `Entity#primary_name` answering
|
|
68
|
+
# with the one name DFAT calls primary.
|
|
69
|
+
NAME_KINDS = T.let(
|
|
70
|
+
{ "primary name" => :primary, "alias" => :aka, "original script" => :aka }.freeze,
|
|
71
|
+
T::Hash[String, Symbol]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# `Alias Strength`, which DFAT publishes on all 6,802 aliases -- 6,346
|
|
75
|
+
# strong, 456 weak -- and on nothing else. Its guide defines strong as an
|
|
76
|
+
# alias assessed to be closely associated with the listing and weak as
|
|
77
|
+
# one that is not, which is the distinction the UN grades Good and Low,
|
|
78
|
+
# so it maps onto the grade the scorer already knows how to discount.
|
|
79
|
+
ALIAS_QUALITIES = T.let({ "strong" => :good, "weak" => :low }.freeze, T::Hash[String, Symbol])
|
|
80
|
+
|
|
81
|
+
# The four measures, which DFAT publishes as 1 and 0 rather than as the
|
|
82
|
+
# TRUE and FALSE its guide describes. Real screening context -- an arms
|
|
83
|
+
# embargo and an asset freeze are not the same finding -- with nowhere
|
|
84
|
+
# in the canonical model to live, so they are named in remarks.
|
|
85
|
+
MEASURES = T.let(
|
|
86
|
+
{ targeted_financial_sanction: "targeted financial sanction", travel_ban: "travel ban",
|
|
87
|
+
arms_embargo: "arms embargo", maritime_restriction: "maritime restriction" }.freeze,
|
|
88
|
+
T::Hash[Symbol, String]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
TRUTHY = T.let(%w[1 true yes y].freeze, T::Array[String])
|
|
92
|
+
|
|
93
|
+
# Columns kept verbatim in remarks, in the order they are printed. Place
|
|
94
|
+
# of birth is real screening signal with no member of its own; the
|
|
95
|
+
# instrument is the legislative vehicle, which is narrower than the
|
|
96
|
+
# framework in `programs` and changes every time a list is amended.
|
|
97
|
+
EXTRA_FIELDS = T.let(
|
|
98
|
+
[[:place_of_birth, "Place of birth"], [:instrument_of_designation, "Instrument of designation"],
|
|
99
|
+
[:control_date, "Control date (last edited, not listed)"]].freeze,
|
|
100
|
+
T::Array[[Symbol, String]]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# DFAT separates citizenships with a semicolon and nothing else, on all
|
|
104
|
+
# 399 rows that publish more than one.
|
|
105
|
+
SEMICOLON = T.let(";", String)
|
|
106
|
+
|
|
107
|
+
# What separates the two prose columns once they are joined.
|
|
108
|
+
PARAGRAPH = T.let("\n\n", String)
|
|
109
|
+
|
|
110
|
+
# Trailing spaces, and the carriage returns Excel escapes as `_x000D_`
|
|
111
|
+
# and the spreadsheet reader unescapes. A name that keeps either is a
|
|
112
|
+
# name nothing will ever match.
|
|
113
|
+
WHITESPACE = T.let(/[[:space:]]+/, Regexp)
|
|
114
|
+
|
|
115
|
+
sig { returns(T::Array[Parsers::Spreadsheet::Row]) }
|
|
116
|
+
attr_reader :rows
|
|
117
|
+
|
|
118
|
+
sig { params(rows: T::Array[Parsers::Spreadsheet::Row]).void }
|
|
119
|
+
def initialize(rows)
|
|
120
|
+
@rows = T.let(rows, T::Array[Parsers::Spreadsheet::Row])
|
|
121
|
+
@dates_of_birth = T.let(nil, T.nilable(T::Array[PartialDate]))
|
|
122
|
+
@unread_dates = T.let([], T::Array[String])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The entity, or nil for a group whose rows are all nameless -- which
|
|
126
|
+
# cannot be screened against and is never what DFAT meant to publish.
|
|
127
|
+
sig { returns(T.nilable(Entity)) }
|
|
128
|
+
def entity
|
|
129
|
+
return nil if names.empty?
|
|
130
|
+
|
|
131
|
+
Entity.new(source: :australia_dfat, source_ref: source_ref, type: type, names: names,
|
|
132
|
+
addresses: addresses, identifiers: identifiers, dates_of_birth: dates_of_birth,
|
|
133
|
+
nationalities: nationalities, programs: programs, listed_on: listed_on,
|
|
134
|
+
remarks: remarks)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# DFAT's own reference with the alias suffix removed: the `1000` that
|
|
138
|
+
# `1000`, `1000a` and `1000b` are all part of. Stable across syncs, and
|
|
139
|
+
# the number DFAT will quote back in a permit application.
|
|
140
|
+
sig { returns(T.nilable(String)) }
|
|
141
|
+
def source_ref = AustraliaDfat.group(primary[:reference])
|
|
142
|
+
|
|
143
|
+
sig { returns(Symbol) }
|
|
144
|
+
def type = TYPES.fetch(primary[:type].to_s.downcase, :organization)
|
|
145
|
+
|
|
146
|
+
# In published order, which puts the primary name first. A spelling
|
|
147
|
+
# DFAT files twice -- once as an alias and once in original script --
|
|
148
|
+
# keeps the first kind it was given.
|
|
149
|
+
sig { returns(T::Array[Name]) }
|
|
150
|
+
def names
|
|
151
|
+
@names ||= T.let(dedupe(rows.filter_map { |row| name(row) }), T.nilable(T::Array[Name]))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Memoized, because reading them is also what fills the list of dates
|
|
155
|
+
# this could not use, which #remarks then keeps rather than losing.
|
|
156
|
+
sig { returns(T::Array[PartialDate]) }
|
|
157
|
+
def dates_of_birth
|
|
158
|
+
@dates_of_birth ||= begin
|
|
159
|
+
@unread_dates = []
|
|
160
|
+
column(:date_of_birth).flat_map { |cell| read_dates(cell) }.uniq
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Prose, the way the UN and the UK publish it: `Russia`, `Democratic
|
|
165
|
+
# People's Republic of Korea (North Korea)`. Country resolves it to an
|
|
166
|
+
# ISO code at scoring time and treats one it cannot resolve as absent
|
|
167
|
+
# rather than as a conflict.
|
|
168
|
+
sig { returns(T::Array[String]) }
|
|
169
|
+
def nationalities
|
|
170
|
+
column(:citizenship).flat_map { |cell| cell.split(SEMICOLON) }.filter_map { |value| collapse(value) }.uniq
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# One column of free text, which DFAT does not decompose and this does
|
|
174
|
+
# not guess at. The whole published string is the street, because it is
|
|
175
|
+
# the address rather than an annotation about one -- and 859 rows hold
|
|
176
|
+
# more than one address, enumerated `a) ... b) ...` the way the birth
|
|
177
|
+
# dates are.
|
|
178
|
+
sig { returns(T::Array[Address]) }
|
|
179
|
+
def addresses
|
|
180
|
+
column(:address).flat_map { |cell| AustraliaDfat.enumerated(cell) }.uniq.filter_map { |line| address(line) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# IMO numbers, on the 344 vessel rows that carry one. Filed the way the
|
|
184
|
+
# UK adapter files them: a registry number that is not any of the four
|
|
185
|
+
# document kinds, labelled in its note.
|
|
186
|
+
sig { returns(T::Array[Identifier]) }
|
|
187
|
+
def identifiers
|
|
188
|
+
column(:imo_number).filter_map { |number| identifier(number) }.uniq
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The sanctions framework, which is the closest thing DFAT publishes to
|
|
192
|
+
# OFAC's programme codes: `1267 (ISIL (Da'esh) and Al-Qaida)`,
|
|
193
|
+
# `Autonomous (Russia)`. Populated on every row, and 29 values cover the
|
|
194
|
+
# whole list.
|
|
195
|
+
sig { returns(T::Array[String]) }
|
|
196
|
+
def programs = column(:committees).uniq
|
|
197
|
+
|
|
198
|
+
sig { returns(T.nilable(PartialDate)) }
|
|
199
|
+
def listed_on = column(:listing_information).filter_map { |cell| PublishedDate.listing(cell) }.first
|
|
200
|
+
|
|
201
|
+
# DFAT's own prose verbatim -- the identifying detail and the listing
|
|
202
|
+
# narrative, which are two columns and one voice -- then the columns
|
|
203
|
+
# that have nowhere else to go, behind the marker that makes them
|
|
204
|
+
# trivial to strip again.
|
|
205
|
+
sig { returns(T.nilable(String)) }
|
|
206
|
+
def remarks
|
|
207
|
+
dates_of_birth # for its side effect: it is what fills @unread_dates, which #extras keeps
|
|
208
|
+
Remarks.build(published_prose, extras)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
sig { returns(String) }
|
|
212
|
+
def inspect = "#<#{self.class} #{source_ref} #{rows.size} row(s)>"
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# The row DFAT calls the primary name, falling back to the first row of
|
|
217
|
+
# the group: a group with no primary name is not published today, and if
|
|
218
|
+
# one ever is, its aliases still describe a real designation.
|
|
219
|
+
sig { returns(Parsers::Spreadsheet::Row) }
|
|
220
|
+
def primary
|
|
221
|
+
@primary ||= T.let(rows.find { |row| row[:name_type].to_s.downcase == "primary name" } || T.must(rows.first),
|
|
222
|
+
T.nilable(Parsers::Spreadsheet::Row))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# One column across every row of the group, blanks dropped, order kept.
|
|
226
|
+
# Unioned rather than read off the primary row -- see the class comment
|
|
227
|
+
# on the 79 groups that disagree with themselves.
|
|
228
|
+
#
|
|
229
|
+
# De-duplicated on the collapsed text rather than on the string, because
|
|
230
|
+
# DFAT's repetition is not always byte-for-byte: an alias row routinely
|
|
231
|
+
# repeats the primary row's address or place of birth with one more
|
|
232
|
+
# trailing space, and keeping both would print the same sentence twice
|
|
233
|
+
# in the remark.
|
|
234
|
+
sig { params(name: Symbol).returns(T::Array[String]) }
|
|
235
|
+
def column(name)
|
|
236
|
+
seen = T.let({}, T::Hash[String, String])
|
|
237
|
+
rows.each do |row|
|
|
238
|
+
text = row[name]
|
|
239
|
+
key = collapse(text)
|
|
240
|
+
seen[key] ||= T.must(text).strip if key
|
|
241
|
+
end
|
|
242
|
+
seen.values
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
sig { params(row: Parsers::Spreadsheet::Row).returns(T.nilable(Name)) }
|
|
246
|
+
def name(row)
|
|
247
|
+
value = collapse(row[:name_of_individual_or_entity])
|
|
248
|
+
return nil if value.nil?
|
|
249
|
+
|
|
250
|
+
Name.new(value: value, kind: NAME_KINDS.fetch(row[:name_type].to_s.downcase, :aka),
|
|
251
|
+
quality: ALIAS_QUALITIES[row[:alias_strength].to_s.downcase])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A spelling published twice is two index entries that can only ever
|
|
255
|
+
# fire together. The first wins, so a name published as primary keeps
|
|
256
|
+
# its kind.
|
|
257
|
+
sig { params(published: T::Array[Name]).returns(T::Array[Name]) }
|
|
258
|
+
def dedupe(published)
|
|
259
|
+
seen = T.let({}, T::Hash[String, Name])
|
|
260
|
+
published.each { |name| seen[name.value] ||= name }
|
|
261
|
+
seen.values
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
sig { params(cell: String).returns(T::Array[PartialDate]) }
|
|
265
|
+
def read_dates(cell)
|
|
266
|
+
dates, unread = PublishedDate.dates(cell)
|
|
267
|
+
@unread_dates.concat(unread - dates.map(&:to_s))
|
|
268
|
+
dates
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
sig { params(line: String).returns(T.nilable(Address)) }
|
|
272
|
+
def address(line)
|
|
273
|
+
Address.new(street: collapse(line))
|
|
274
|
+
rescue ArgumentError
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
sig { params(number: String).returns(T.nilable(Identifier)) }
|
|
279
|
+
def identifier(number)
|
|
280
|
+
Identifier.new(kind: :other, value: number, note: "IMO number")
|
|
281
|
+
rescue ArgumentError
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The two columns DFAT writes prose in: the detail that identifies the
|
|
286
|
+
# person, and the account of how the listing came about. Both are the
|
|
287
|
+
# publisher's own words, so both are kept ahead of the marker.
|
|
288
|
+
sig { returns(T.nilable(String)) }
|
|
289
|
+
def published_prose
|
|
290
|
+
prose = (column(:additional_information) + column(:listing_information)).filter_map { |text| collapse(text) }
|
|
291
|
+
prose.empty? ? nil : prose.uniq.join(PARAGRAPH)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Label/value pairs for Remarks.build, which drops the ones the record
|
|
295
|
+
# left blank.
|
|
296
|
+
sig { returns(T::Array[T.untyped]) }
|
|
297
|
+
def extras
|
|
298
|
+
EXTRA_FIELDS.map { |name, label| [label, column(name)] } +
|
|
299
|
+
[["Measures", measures], ["Date of birth, as published", @unread_dates.uniq]]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The measures actually imposed, named rather than left as the four ones
|
|
303
|
+
# and zeroes DFAT publishes them as.
|
|
304
|
+
sig { returns(T::Array[String]) }
|
|
305
|
+
def measures
|
|
306
|
+
MEASURES.filter_map { |name, label| label if column(name).any? { |value| TRUTHY.include?(value.downcase) } }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
310
|
+
def collapse(value)
|
|
311
|
+
return nil if value.nil?
|
|
312
|
+
|
|
313
|
+
text = value.to_s.gsub(WHITESPACE, " ").strip
|
|
314
|
+
text.empty? ? nil : text
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|