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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. 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