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,258 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "zlib"
7
+
8
+ module ActiveSanction
9
+ module Parsers
10
+ class Spreadsheet
11
+ # The ZIP container an .xlsx workbook arrives in, read with nothing but
12
+ # `zlib`.
13
+ #
14
+ # archive = Archive.new(bytes)
15
+ # archive.names # => ["[Content_Types].xml", "xl/workbook.xml", ...]
16
+ # archive.fetch("xl/workbook.xml") # => "<?xml version=..."
17
+ #
18
+ # ### Why this is here rather than in a gem
19
+ #
20
+ # An .xlsx file is a ZIP of XML parts, and the gem already reads XML. The
21
+ # only thing standing between it and a spreadsheet is the container, and
22
+ # the container is a 1990 format with a fixed-width header -- a hundred
23
+ # lines of unpacking, against a spreadsheet gem that would be the first
24
+ # third-party dependency this library has taken for one publisher's
25
+ # choice of file format. The gemspec's rule is that a compliance library
26
+ # should not be the reason a deployment installs something; that rule is
27
+ # worth more than the hundred lines.
28
+ #
29
+ # What is deliberately not implemented is everything a *general* ZIP
30
+ # reader needs and a published spreadsheet never uses: encryption, spanned
31
+ # archives, and ZIP64. Each of them raises by name rather than being
32
+ # half-read, because a container this cannot read is a payload that must
33
+ # not be parsed as though it were empty.
34
+ #
35
+ # ### Read from the central directory, not from the local headers
36
+ #
37
+ # A ZIP entry's sizes appear twice -- once in the central directory at the
38
+ # end of the file and once in the local header in front of the bytes --
39
+ # and the local copy is allowed to be zeroes, with the real sizes written
40
+ # in a data descriptor *after* the compressed data. Excel does exactly
41
+ # that on some writes. So sizes and offsets are taken from the central
42
+ # directory, which is authoritative, and the local header is read only for
43
+ # the two lengths that say where the entry's bytes actually begin.
44
+ #
45
+ # @api private
46
+ class Archive
47
+ extend T::Sig
48
+
49
+ EOCD_SIGNATURE = T.let("PK\x05\x06".b, String)
50
+ CENTRAL_SIGNATURE = T.let("PK\x01\x02".b, String)
51
+ LOCAL_SIGNATURE = T.let("PK\x03\x04".b, String)
52
+
53
+ # 22 bytes of fixed header plus a comment that the format caps at 64 KB.
54
+ EOCD_FIXED = T.let(22, Integer)
55
+ MAX_COMMENT = T.let(0xFFFF, Integer)
56
+
57
+ CENTRAL_FIXED = T.let(46, Integer)
58
+ LOCAL_FIXED = T.let(30, Integer)
59
+
60
+ STORED = T.let(0, Integer)
61
+ DEFLATED = T.let(8, Integer)
62
+
63
+ # The value every ZIP64 field is replaced by in the 32-bit record that
64
+ # cannot hold it. Seeing one means the real number is in an extra field
65
+ # this does not read -- see the class comment.
66
+ OVERFLOW_32 = T.let(0xFFFFFFFF, Integer)
67
+ OVERFLOW_16 = T.let(0xFFFF, Integer)
68
+
69
+ # General purpose bit 0. Set on an entry whose bytes are encrypted,
70
+ # which inflates to noise rather than failing, so it is refused here.
71
+ ENCRYPTED = T.let(0x0001, Integer)
72
+
73
+ # A raw deflate stream -- no zlib header, no adler checksum -- which is
74
+ # what a ZIP entry holds and what a negative window size selects.
75
+ RAW_DEFLATE = T.let(-Zlib::MAX_WBITS, Integer)
76
+
77
+ sig { params(payload: T.untyped).void }
78
+ def initialize(payload)
79
+ @bytes = T.let(binary(payload), String)
80
+ @entries = T.let(read_central_directory, T::Hash[String, T::Hash[Symbol, Integer]])
81
+ end
82
+
83
+ # The part names in the archive, in central-directory order.
84
+ sig { returns(T::Array[String]) }
85
+ def names = @entries.keys
86
+
87
+ sig { params(name: T.untyped).returns(T::Boolean) }
88
+ def include?(name) = @entries.key?(name.to_s)
89
+
90
+ # One part's bytes, decompressed, or nil when the archive has no such
91
+ # part. The parts of a workbook are all XML, and the XML reader decodes
92
+ # them, so what comes back here is binary.
93
+ sig { params(name: T.untyped).returns(T.nilable(String)) }
94
+ def [](name)
95
+ entry = @entries[name.to_s]
96
+ entry.nil? ? nil : extract(name.to_s, entry)
97
+ end
98
+
99
+ # For a part the caller cannot proceed without. Names what the archive
100
+ # does hold, because the usual cause of a missing part is that the
101
+ # payload is not the workbook it was taken for.
102
+ sig { params(name: T.untyped).returns(String) }
103
+ def fetch(name)
104
+ self[name] || raise(ParseError, "#{name.to_s.inspect} is not in this archive. It holds: #{names.join(", ")}")
105
+ end
106
+
107
+ sig { returns(String) }
108
+ def inspect = "#<#{self.class} #{@entries.size} part(s)>"
109
+
110
+ private
111
+
112
+ sig { params(payload: T.untyped).returns(String) }
113
+ def binary(payload)
114
+ string = payload.to_s
115
+ string.encoding == Encoding::BINARY ? string : string.dup.force_encoding(Encoding::BINARY)
116
+ end
117
+
118
+ # The end-of-central-directory record is the only fixed landmark in a
119
+ # ZIP, and it is at the end -- behind a comment of unknown length, so it
120
+ # is searched for backwards. A payload with no such record is not a
121
+ # truncated spreadsheet that could be salvaged; it is a file whose index
122
+ # was never received, and none of the entries can be located without it.
123
+ sig { returns(Integer) }
124
+ def eocd_offset
125
+ last = @bytes.bytesize - EOCD_FIXED
126
+ offset = last.negative? ? nil : @bytes.rindex(EOCD_SIGNATURE, last)
127
+ return offset if offset && offset >= last - MAX_COMMENT
128
+
129
+ raise ParseError,
130
+ "expected a ZIP archive (an .xlsx workbook is one), and found no end-of-central-directory " \
131
+ "record in #{@bytes.bytesize} byte(s) -- the payload is truncated, or is not a workbook"
132
+ end
133
+
134
+ sig { returns(T::Hash[String, T::Hash[Symbol, Integer]]) }
135
+ def read_central_directory
136
+ eocd = eocd_offset
137
+ count, size, start = @bytes[eocd + 10, 12].to_s.unpack("vVV")
138
+ refuse_zip64!(count, start)
139
+ walk_central_directory(Integer(start), Integer(size), Integer(count))
140
+ end
141
+
142
+ sig { params(count: T.untyped, start: T.untyped).void }
143
+ def refuse_zip64!(count, start)
144
+ return unless count == OVERFLOW_16 || start == OVERFLOW_32
145
+
146
+ raise ParseError,
147
+ "this is a ZIP64 archive, which this reader does not implement. No published sanctions list is " \
148
+ "anywhere near the 4 GB that requires one, so the payload is almost certainly not a workbook"
149
+ end
150
+
151
+ sig { params(start: Integer, size: Integer, count: Integer).returns(T::Hash[String, T::Hash[Symbol, Integer]]) }
152
+ def walk_central_directory(start, size, count)
153
+ entries = {}
154
+ offset = start
155
+ finish = start + size
156
+ count.times do
157
+ break if offset + CENTRAL_FIXED > finish
158
+
159
+ name, entry, offset = read_central_entry(offset)
160
+ entries[name] = entry
161
+ end
162
+ entries
163
+ end
164
+
165
+ sig { params(offset: Integer).returns([String, T::Hash[Symbol, Integer], Integer]) }
166
+ def read_central_entry(offset)
167
+ header = @bytes[offset, CENTRAL_FIXED].to_s
168
+ unless header.start_with?(CENTRAL_SIGNATURE)
169
+ raise ParseError.new("the central directory ends inside an entry header", offset: offset)
170
+ end
171
+
172
+ entry = central_entry(header)
173
+ lengths = header[28, 6].to_s.unpack("vvv").map { |length| Integer(length) }
174
+ name = @bytes[offset + CENTRAL_FIXED, T.must(lengths.first)].to_s.force_encoding(Encoding::UTF_8)
175
+ [name, entry, offset + CENTRAL_FIXED + lengths.sum]
176
+ end
177
+
178
+ # Flags, compression method, both sizes and where the entry's local
179
+ # header sits. The four bytes skipped are the modification timestamp,
180
+ # and the four after them the CRC, which the size check below stands in
181
+ # for -- a part that inflates to the stated length has not been
182
+ # truncated, and a part whose deflate stream is damaged raises out of
183
+ # zlib before it can be measured.
184
+ sig { params(header: String).returns(T::Hash[Symbol, Integer]) }
185
+ def central_entry(header)
186
+ flags, method, compressed, uncompressed = header[8, 20].to_s.unpack("vvx8VV")
187
+ { flags: Integer(flags), method: Integer(method), compressed: Integer(compressed),
188
+ uncompressed: Integer(uncompressed), local: Integer(header[42, 4].to_s.unpack1("V")) }
189
+ end
190
+
191
+ sig { params(name: String, entry: T::Hash[Symbol, Integer]).returns(String) }
192
+ def extract(name, entry)
193
+ refuse_unreadable!(name, entry)
194
+ data = @bytes[data_offset(name, entry), Integer(entry.fetch(:compressed))].to_s
195
+ bytes = entry.fetch(:method) == STORED ? data : inflate(name, data)
196
+ verify_size!(name, bytes, Integer(entry.fetch(:uncompressed)))
197
+ bytes
198
+ end
199
+
200
+ sig { params(name: String, entry: T::Hash[Symbol, Integer]).void }
201
+ def refuse_unreadable!(name, entry)
202
+ if entry.fetch(:flags).anybits?(ENCRYPTED)
203
+ raise ParseError, "#{name.inspect} is encrypted, and this reader holds no password"
204
+ end
205
+ if entry.values_at(:compressed, :uncompressed, :local).include?(OVERFLOW_32)
206
+ raise ParseError, "#{name.inspect} states its size in a ZIP64 extra field, which this reader does not read"
207
+ end
208
+ return if [STORED, DEFLATED].include?(entry.fetch(:method))
209
+
210
+ raise ParseError,
211
+ "#{name.inspect} is stored with compression method #{entry.fetch(:method)}, and this reader " \
212
+ "implements only stored (0) and deflate (8), which is everything a spreadsheet writer emits"
213
+ end
214
+
215
+ # The local header repeats the name and carries its own extra field,
216
+ # and the two lengths are the only thing read from it -- see the class
217
+ # comment on why the sizes are not.
218
+ sig { params(name: String, entry: T::Hash[Symbol, Integer]).returns(Integer) }
219
+ def data_offset(name, entry)
220
+ offset = Integer(entry.fetch(:local))
221
+ header = @bytes[offset, LOCAL_FIXED].to_s
222
+ unless header.start_with?(LOCAL_SIGNATURE)
223
+ raise ParseError.new("the central directory points at #{name.inspect}, which is not a local header",
224
+ offset: offset)
225
+ end
226
+
227
+ name_length, extra_length = header[26, 4].to_s.unpack("vv")
228
+ offset + LOCAL_FIXED + Integer(name_length) + Integer(extra_length)
229
+ end
230
+
231
+ sig { params(name: String, data: String).returns(String) }
232
+ def inflate(name, data)
233
+ stream = Zlib::Inflate.new(RAW_DEFLATE)
234
+ begin
235
+ stream.inflate(data) << stream.finish
236
+ ensure
237
+ stream.close
238
+ end
239
+ rescue Zlib::Error => e
240
+ raise ParseError, "#{name.inspect} could not be decompressed: #{e.class} #{e.message}"
241
+ end
242
+
243
+ # The central directory states what the part should weigh, and a part
244
+ # that arrives short is a truncated download rather than a shorter
245
+ # spreadsheet. Saying so here is what stops half a sanctions list being
246
+ # parsed as though it were the whole one.
247
+ sig { params(name: String, bytes: String, expected: Integer).void }
248
+ def verify_size!(name, bytes, expected)
249
+ return if expected.zero? || bytes.bytesize == expected
250
+
251
+ raise ParseError,
252
+ "#{name.inspect} unpacked to #{bytes.bytesize} byte(s) where the archive's own index says " \
253
+ "#{expected} -- the payload is truncated or corrupt"
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,213 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/parsers/xml_records"
7
+
8
+ module ActiveSanction
9
+ module Parsers
10
+ class Spreadsheet
11
+ # One pass over one workbook. Enumerable, and lazy in the same sense the
12
+ # XML reader is: the sheet is streamed row by row, so what is held is one
13
+ # row plus the shared string table -- which is not optional, a cell being
14
+ # an offset into it.
15
+ #
16
+ # reader = table.read(bytes)
17
+ # reader.each { |row| row[:reference] }
18
+ # reader.warnings # => rows that could not be read
19
+ # reader.sheet_names # => every sheet, whether or not it was read
20
+ #
21
+ # Re-enumerating re-reads from the start, which also resets #warnings --
22
+ # so `reader.count` followed by `reader.warnings` reports the warnings
23
+ # from the counting pass, not from two passes appended together. The ZIP
24
+ # is unpacked once and reused across passes.
25
+ #
26
+ # @api private
27
+ class Reader
28
+ extend T::Sig
29
+ extend T::Generic
30
+ include Enumerable
31
+
32
+ Elem = type_member { { fixed: Row } }
33
+
34
+ ROWS = T.let(Parsers::XmlRecords.new(records: %w[row]), Parsers::XmlRecords)
35
+
36
+ # `A`, `AB`, `XFD`: the letters in a cell's `r="C7"` address, which is
37
+ # the only thing that says which column a cell is in. A row states only
38
+ # the cells that hold something, so position in the row means nothing.
39
+ ADDRESS = T.let(/\A([A-Z]+)/, Regexp)
40
+
41
+ LETTERS = T.let(26, Integer)
42
+
43
+ DATE_FORMATS = T.let({ day: "%Y-%m-%d", month: "%Y-%m", year: "%Y" }.freeze, T::Hash[Symbol, String])
44
+
45
+ sig { returns(Spreadsheet) }
46
+ attr_reader :table
47
+
48
+ sig { returns(T::Array[Warning]) }
49
+ attr_reader :warnings
50
+
51
+ sig { params(table: Spreadsheet, payload: T.untyped).void }
52
+ def initialize(table:, payload:)
53
+ @table = T.let(table, Spreadsheet)
54
+ @payload = T.let(payload, T.untyped)
55
+ @warnings = T.let([], T::Array[Warning])
56
+ @workbook = T.let(nil, T.nilable(Workbook))
57
+ end
58
+
59
+ # The workbook's sheets, in the order it lists them. Worth printing when
60
+ # a publisher adds a second tab: only one of them is being read.
61
+ sig { returns(T::Array[String]) }
62
+ def sheet_names = workbook.sheet_names
63
+
64
+ # When the workbook says it was last saved -- see Workbook#modified.
65
+ # Costs one part of the archive and no pass over the sheet.
66
+ sig { returns(T.nilable(String)) }
67
+ def modified = workbook.modified
68
+
69
+ sig { override.params(block: T.nilable(T.proc.params(row: Row).void)).returns(T.untyped) }
70
+ def each(&block)
71
+ return enum_for(:each) unless block
72
+
73
+ @warnings = []
74
+ rows = ROWS.read(workbook.sheet(table.sheet))
75
+ columns = T.let(nil, T.nilable(T::Array[Symbol]))
76
+ rows.each do |node|
77
+ cells = read_row(node)
78
+ columns = table.columns || header!(cells, node) and next if columns.nil?
79
+
80
+ # A `<row>` with no cell holding anything is spacing, not a record.
81
+ block.call(build(columns, cells, number(node))) if cells.any?
82
+ end
83
+ @warnings.concat(rows.warnings)
84
+ self
85
+ end
86
+
87
+ # Every row, in memory. The convenience the small sheets get to use.
88
+ sig { returns(T::Array[Row]) }
89
+ def to_a = each.to_a
90
+
91
+ sig { returns(String) }
92
+ def inspect = "#<#{self.class} #{table.sheet_name} of #{sheet_names.size} sheet(s)>"
93
+
94
+ private
95
+
96
+ sig { returns(Workbook) }
97
+ def workbook
98
+ @workbook ||= Workbook.new(table: table, archive: Archive.new(@payload))
99
+ end
100
+
101
+ # Column index to value, indexed from the cell's own address so that a
102
+ # row which states only the cells it filled still lines up with the
103
+ # header. Nothing here assumes the row is contiguous, because it is not:
104
+ # a row whose first two cells are empty starts at `C`.
105
+ sig { params(node: Parsers::XmlRecords::Record).returns(T::Hash[Integer, String]) }
106
+ def read_row(node)
107
+ node.nodes("c").each_with_object({}) do |cell, values|
108
+ index = column_index(cell.attribute("r"))
109
+ value = table.value(cell_value(cell))
110
+ values[index] = value if index && value
111
+ end
112
+ end
113
+
114
+ # `C` is 2, `AA` is 26, in the bijective base-26 a spreadsheet numbers
115
+ # its columns with.
116
+ sig { params(address: T.nilable(String)).returns(T.nilable(Integer)) }
117
+ def column_index(address)
118
+ letters = ADDRESS.match(address.to_s)&.captures&.first
119
+ return nil if letters.nil?
120
+
121
+ letters.each_char.inject(0) { |index, letter| (index * LETTERS) + (letter.ord - 64) } - 1
122
+ end
123
+
124
+ # A cell states its type in `t` and its value in `<v>` -- except an
125
+ # inline string, which is the one shape that carries its text where a
126
+ # shared string's offset would be.
127
+ sig { params(cell: Parsers::XmlRecords::Record).returns(T.nilable(String)) }
128
+ def cell_value(cell)
129
+ case cell.attribute("t")
130
+ when "s" then shared(cell["v"])
131
+ when "inlineStr" then table.unescape(cell.values("is/t").join)
132
+ when "b" then cell["v"] == "1" ? "TRUE" : "FALSE"
133
+ when "str", "e", "d" then cell["v"]
134
+ else numeric(cell)
135
+ end
136
+ end
137
+
138
+ sig { params(offset: T.nilable(String)).returns(T.nilable(String)) }
139
+ def shared(offset) = offset.nil? ? nil : workbook.strings[offset.to_i]
140
+
141
+ # A number is a date when the cell's style says it is displayed as one,
142
+ # and is otherwise passed through as the publisher wrote it -- `1963`
143
+ # stays `1963`, because a year is not a date and rendering it as one
144
+ # would invent a January the 1st nobody published.
145
+ sig { params(cell: Parsers::XmlRecords::Record).returns(T.nilable(String)) }
146
+ def numeric(cell)
147
+ raw = cell["v"]
148
+ precision = workbook.precision(cell.attribute("s")&.to_i)
149
+ return raw if raw.nil? || precision.nil?
150
+
151
+ formatted(raw, precision) || raw
152
+ end
153
+
154
+ # ISO 8601, truncated to what the cell's format actually displays: a
155
+ # `mmm-yy` cell says a month and its serial's day is whatever Excel
156
+ # needed to store one. PartialDate::Parser reads all three shapes, so an
157
+ # adapter gets the publisher's own precision without a format of its own.
158
+ sig { params(raw: String, precision: Symbol).returns(T.nilable(String)) }
159
+ def formatted(raw, precision)
160
+ workbook.date(raw)&.strftime(DATE_FORMATS.fetch(precision))
161
+ end
162
+
163
+ # Column names from the sheet's own first row, normalized the way the
164
+ # delimited reader normalizes a CSV header, so that `Name of Individual
165
+ # or Entity` and `name_of_individual_or_entity` are the same column
166
+ # however the publisher capitalized it this quarter.
167
+ sig { params(cells: T::Hash[Integer, String], node: Parsers::XmlRecords::Record).returns(T::Array[Symbol]) }
168
+ def header!(cells, node)
169
+ raise ParseError.new("expected a header row, and it is empty", line: number(node)) if cells.empty?
170
+
171
+ (0..T.must(cells.keys.max)).map { |index| normalize_header(cells[index], index) }
172
+ end
173
+
174
+ # A header cell the publisher left blank still names a column, because
175
+ # the columns after it have to keep their positions.
176
+ sig { params(value: T.nilable(String), index: Integer).returns(Symbol) }
177
+ def normalize_header(value, index)
178
+ name = value.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
179
+ name.empty? ? :"column_#{index + 1}" : name.to_sym
180
+ end
181
+
182
+ sig { params(node: Parsers::XmlRecords::Record).returns(Integer) }
183
+ def number(node) = node.attribute("r")&.to_i || node.line || 0
184
+
185
+ sig do
186
+ params(columns: T::Array[Symbol], cells: T::Hash[Integer, String], number: Integer).returns(Row)
187
+ end
188
+ def build(columns, cells, number)
189
+ record_width(columns, cells, number)
190
+ Row.new(values: table.coerce(columns, cells), number: number)
191
+ end
192
+
193
+ # A row with a value in a column the header never named is kept, and the
194
+ # extra value is dropped rather than the row. A publisher appending a
195
+ # column mid-year should degrade the fields nobody has mapped yet, not
196
+ # the whole list -- but it must say so, because a column silently
197
+ # ignored is how a new sanctions measure stops being read.
198
+ sig { params(columns: T::Array[Symbol], cells: T::Hash[Integer, String], number: Integer).void }
199
+ def record_width(columns, cells, number)
200
+ beyond = cells.keys.select { |index| index >= columns.size }
201
+ return if beyond.empty?
202
+
203
+ @warnings << Warning.new(
204
+ line: number,
205
+ message: "row #{number} holds #{beyond.size} value(s) past the #{columns.size} column(s) " \
206
+ "this sheet named",
207
+ snippet: beyond.map { |index| cells[index] }.join(" | ")
208
+ )
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,88 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Parsers
8
+ class Spreadsheet
9
+ # One row of a sheet: column name to value, with every cell already
10
+ # resolved to the string it displays as -- a shared string looked up, a
11
+ # date serial rendered, a blank cell nil.
12
+ #
13
+ # row[:name_of_individual_or_entity] # => "MOHAMMAD HASSAN AKHUND"
14
+ # row[:date_of_birth] # => "1963-01-30"
15
+ # row[:alias_strength] # => nil, the cell being empty
16
+ # row.number # => 2, the sheet's own row number
17
+ #
18
+ # Deliberately not a Hash, for the reason DelimitedTable::Row is not one:
19
+ # a Hash would answer `row[:date_of_brith]` with nil and let a typo in an
20
+ # adapter look like an empty column all the way into a snapshot. #[]
21
+ # raises on a name the sheet never had, and #fetch is there for the
22
+ # genuinely optional case.
23
+ #
24
+ # It is a separate class from the delimited table's row rather than a
25
+ # shared one because the two disagree about what locates a record. A CSV
26
+ # row is at a line of a file; a sheet row is at a row number the
27
+ # spreadsheet itself assigns, which is what is showing in the corner of
28
+ # the window when somebody opens the published file to check a record --
29
+ # and which survives the workbook being re-saved with different XML.
30
+ class Row
31
+ extend T::Sig
32
+
33
+ sig { returns(T::Hash[Symbol, T.nilable(String)]) }
34
+ attr_reader :values
35
+
36
+ # The sheet's own row number, 1-based and counting the header row --
37
+ # the number a reader of the published file will see beside the record.
38
+ sig { returns(Integer) }
39
+ attr_reader :number
40
+
41
+ sig { params(values: T::Hash[Symbol, T.nilable(String)], number: Integer).void }
42
+ def initialize(values:, number:)
43
+ @values = T.let(values.freeze, T::Hash[Symbol, T.nilable(String)])
44
+ @number = T.let(number, Integer)
45
+ freeze
46
+ end
47
+
48
+ # Raises on a column the sheet never had, which is almost always a typo
49
+ # in an adapter rather than a question about the data.
50
+ sig { params(column: Symbol).returns(T.nilable(String)) }
51
+ def [](column)
52
+ values.fetch(column) do
53
+ raise MissingKey, "no column #{column.inspect} in this sheet. Read: #{columns.join(", ")}"
54
+ end
55
+ end
56
+
57
+ sig { params(column: Symbol, default: T.untyped).returns(T.untyped) }
58
+ def fetch(column, default = nil) = values.fetch(column, default)
59
+
60
+ sig { params(column: Symbol).returns(T::Boolean) }
61
+ def null?(column) = self[column].nil?
62
+
63
+ sig { returns(T::Array[Symbol]) }
64
+ def columns = values.keys
65
+
66
+ sig { returns(T::Hash[Symbol, T.nilable(String)]) }
67
+ def to_h = values
68
+
69
+ sig { params(other: T.untyped).returns(T::Boolean) }
70
+ def ==(other)
71
+ return false unless other.instance_of?(self.class)
72
+
73
+ values == other.values && number == other.number
74
+ end
75
+ alias eql? ==
76
+
77
+ sig { returns(Integer) }
78
+ def hash = [self.class, values, number].hash
79
+
80
+ sig { returns(String) }
81
+ def inspect
82
+ filled = values.compact
83
+ "#<#{self.class} row=#{number} #{filled.map { |name, value| "#{name}=#{value.inspect}" }.join(" ")}>"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end