traject 3.8.3 → 3.9.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.
@@ -0,0 +1,499 @@
1
+ require 'yell'
2
+
3
+ require 'traject/util'
4
+ require 'traject/qualified_const_get'
5
+ require 'traject/thread_pool'
6
+
7
+ require 'json'
8
+ require 'httpx'
9
+
10
+ require 'uri'
11
+ require 'thread' # for Mutex/Queue
12
+ require 'concurrent' # for atomic_fixnum
13
+
14
+ # Write to Solr using the JSON interface
15
+ #
16
+ # Uses httpx ruby client (unlike the original version which used httpclient package)
17
+ # https://honeyryderchuck.gitlab.io/httpx/
18
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/home.html
19
+ #
20
+ # This should work under both MRI and JRuby, with JRuby possiby getting
21
+ # better performance due to the threading model.
22
+ #
23
+ # Solr updates are by default sent with no commit params. This will definitely
24
+ # maximize your performance, and *especially* for bulk/batch indexing is recommended --
25
+ # use Solr auto commit in your Solr configuration instead, possibly with `commit_on_close`
26
+ # setting here.
27
+ #
28
+ # However, if you want the writer to send `commitWithin=true`, `commit=true`,
29
+ # `softCommit=true`, or any other URL parameters valid for Solr update handlers,
30
+ # you can configure this with `solr_writer.solr_update_args` setting. See:
31
+ # https://lucene.apache.org/solr/guide/7_0/near-real-time-searching.html#passing-commit-and-commitwithin-parameters-as-part-of-the-url
32
+ # Eg:
33
+ #
34
+ # settings do
35
+ # provide "solr_writer.solr_update_args", { commitWithin: 1000 }
36
+ # end
37
+ #
38
+ # (That it's a hash makes it infeasible to set/override on command line, if this is
39
+ # annoying for you let us know)
40
+ #
41
+ # `solr_update_args` will apply to batch and individual update requests, but
42
+ # not to commit sent if `commit_on_close`. You can also instead set
43
+ # `solr_writer.solr_commit_args` for that (or pass in an arg to #commit if calling
44
+ # manually)
45
+ #
46
+ # ## Relevant settings
47
+ #
48
+ # * solr.url (optional if solr.update_url is set) The URL to the solr core to index into.
49
+ # (Can include embedded HTTP basic auth as eg `http://user:pass@host/solr`)
50
+ #
51
+ # * solr.update_url: The actual update url. If unset, we'll first see if
52
+ # "#{solr.url}/update/json" exists, and if not use "#{solr.url}/update". (Can include
53
+ # embedded HTTP basic auth as eg `http://user:pass@host/solr)
54
+ #
55
+ # * solr_writer.batch_size: How big a batch to send to solr. Default is 100.
56
+ # My tests indicate that this setting doesn't change overall index speed by a ton.
57
+ #
58
+ # * solr_writer.thread_pool: How many threads to use for the writer. Default is 1.
59
+ # Likely useful even under MRI since thread will be waiting on Solr for some time.
60
+ #
61
+ # * solr_writer.max_skipped: How many records skipped due to errors before we
62
+ # bail out with a fatal error? Set to -1 for unlimited skips. Default 0,
63
+ # raise and abort on a single record that could not be added to Solr.
64
+ #
65
+ # * solr_writer.skippable_exceptions: List of classes that will be rescued internal to
66
+ # SolrJsonWriter, and handled with max_skipped logic. Defaults to
67
+ # `[HTTPX::TimeoutError, SocketError, Errno::ECONNREFUSED, Traject::SolrJsonWriter::BadHttpResponse]`
68
+ #
69
+ # * solr_writer.solr_update_args: A _hash_ of query params to send to solr update url.
70
+ # Will be sent with every update request. Eg `{ softCommit: true }` or `{ commitWithin: 1000 }`.
71
+ # See also `solr_writer.solr_commit_args`
72
+ #
73
+ # * solr_writer.commit_on_close: Set to true (or "true") if you want to commit at the
74
+ # end of the indexing run. (Old "solrj_writer.commit_on_close" supported for backwards
75
+ # compat only.)
76
+ #
77
+ # * solr_writer.commit_solr_update_args: A hash of query params to send when committing.
78
+ # Will be used for automatic `close_on_commit`, as well as any manual calls to #commit.
79
+ # If set, must include {"commit" => "true"} or { "softCommit" => "true" } if you actually
80
+ # want commits to happen when SolrJsonWriter tries to commit! But can be used to switch to softCommits
81
+ # (hard commits default), or specify additional params like optimize etc.
82
+ #
83
+ # * solr_writer.http_timeout: Value in seconds, will be set on the httpx connect and request
84
+ # timeouts. No way to set them individually at present. Default 60
85
+ #
86
+ # * solr_writer.commit_timeout: If commit_on_close, how long to wait for Solr before
87
+ # giving up as a timeout (http client receive_timeout). Default 10 minutes. Solr can be slow at commits. Overrides solr_writer.timeout
88
+ #
89
+ # * solr_json_writer.http_client Set your own httpx session object. Can be used for testing,
90
+ # or to configure the client with your desired plugins or configuration. If you set your
91
+ # own, you are reponsible for setting timeouts, auth, etc. INCLUDING persistent connection
92
+ # plugin you surely want for performance.
93
+ #
94
+ #
95
+ class Traject::SolrJsonHttpxWriter
96
+ include Traject::QualifiedConstGet
97
+
98
+ URI_REGEXP = URI::Parser.new.make_regexp.freeze
99
+
100
+ DEFAULT_MAX_SKIPPED = 0
101
+ DEFAULT_BATCH_SIZE = 100
102
+
103
+ # The passed-in settings
104
+ attr_reader :settings, :thread_pool_size
105
+
106
+ # A queue to hold documents before sending to solr
107
+ attr_reader :batched_queue
108
+
109
+ def initialize(argSettings)
110
+ @settings = Traject::Indexer::Settings.new(argSettings)
111
+
112
+ # Set max errors
113
+ @max_skipped = (@settings['solr_writer.max_skipped'] || DEFAULT_MAX_SKIPPED).to_i
114
+ if @max_skipped < 0
115
+ @max_skipped = nil
116
+ end
117
+
118
+
119
+ # Figure out where to send updates, and if with basic auth
120
+ @solr_update_url, basic_auth_user, basic_auth_password = self.determine_solr_update_url
121
+
122
+ # Note httpx is totally thread-safe so it's okay we're using the client obj
123
+ # in multiple threads in parallel.
124
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/Connection-Pools
125
+ @http_client = if @settings["solr_json_writer.http_client"]
126
+ @settings["solr_json_writer.http_client"]
127
+ else
128
+ # Persistent connections prob important for performance
129
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/Persistent
130
+ #
131
+ # Our local defined-below error-raising plugins
132
+ client = HTTPX.plugin(:persistent).plugin(HttpxRaiseErrorPlugin)
133
+
134
+ if @settings["solr_writer.http_timeout"]
135
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/Timeouts
136
+ client = client.with(timeout: { connect_timeout: @settings["solr_writer.http_timeout"], request_timeout: @settings["solr_writer.http_timeout"]})
137
+ end
138
+
139
+ if basic_auth_user || basic_auth_password
140
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/Auth
141
+ client = client.plugin(:basic_auth).basic_auth(basic_auth_user, basic_auth_password)
142
+ end
143
+
144
+ client
145
+ end
146
+
147
+ @batch_size = (settings["solr_writer.batch_size"] || DEFAULT_BATCH_SIZE).to_i
148
+ @batch_size = 1 if @batch_size < 1
149
+
150
+ # Store error count in an AtomicInteger, so multi threads can increment
151
+ # it safely, if we're threaded.
152
+ @skipped_record_incrementer = Concurrent::AtomicFixnum.new(0)
153
+
154
+
155
+ # How many threads to use for the writer?
156
+ # if our thread pool settings are 0, it'll just create a null threadpool that
157
+ # executes in calling context.
158
+ @thread_pool_size = (@settings["solr_writer.thread_pool"] || 1).to_i
159
+
160
+ @batched_queue = Queue.new
161
+ @thread_pool = Traject::ThreadPool.new(@thread_pool_size)
162
+
163
+ # old setting solrj_writer supported for backwards compat, as we make
164
+ # this the new default writer.
165
+ @commit_on_close = (settings["solr_writer.commit_on_close"] || settings["solrj_writer.commit_on_close"]).to_s == "true"
166
+
167
+
168
+ @solr_update_args = settings["solr_writer.solr_update_args"]
169
+ @commit_solr_update_args = settings["solr_writer.commit_solr_update_args"]
170
+
171
+ logger.info(" #{self.class.name} writing to '#{@solr_update_url}' #{"(with HTTP basic auth)" if basic_auth_user || basic_auth_password}in batches of #{@batch_size} with #{@thread_pool_size} bg threads")
172
+ end
173
+
174
+
175
+ # Add a single context to the queue, ready to be sent to solr
176
+ def put(context)
177
+ @thread_pool.raise_collected_exception!
178
+
179
+ @batched_queue << context
180
+ if @batched_queue.size >= @batch_size
181
+ batch = Traject::Util.drain_queue(@batched_queue)
182
+ @thread_pool.maybe_in_thread_pool(batch) {|batch_arg| send_batch(batch_arg) }
183
+ end
184
+ end
185
+
186
+ # Not part of standard writer API.
187
+ #
188
+ # If we are batching adds, and have some not-yet-written ones queued up --
189
+ # flush em all to solr.
190
+ #
191
+ # This should be thread-safe to call, but the write does take place in
192
+ # the caller's thread, no threading is done for you here, regardless of setting
193
+ # of solr_writer.thread_pool
194
+ def flush
195
+ send_batch( Traject::Util.drain_queue(@batched_queue) )
196
+ end
197
+
198
+ # configured update url, with either settings @solr_update_args or passed in
199
+ # query_params added to it
200
+ def solr_update_url_with_query(query_params)
201
+ if query_params
202
+ @solr_update_url + '?' + URI.encode_www_form(query_params)
203
+ else
204
+ @solr_update_url
205
+ end
206
+ end
207
+
208
+ # Send the given batch of contexts. If something goes wrong, send
209
+ # them one at a time.
210
+ # @param [Array<Traject::Indexer::Context>] an array of contexts
211
+ def send_batch(batch)
212
+ return if batch.empty?
213
+
214
+ logger.debug("#{self.class.name}: sending batch of #{batch.size} to Solr")
215
+
216
+ json_package = JSON.generate(batch.map { |c| c.output_hash })
217
+
218
+ begin
219
+ resp = @http_client.post(solr_update_url_with_query(@solr_update_args), body: json_package, headers: {"content-type" => "application/json"})
220
+ rescue StandardError => exception
221
+ end
222
+
223
+ if exception || resp.status != 200
224
+ error_message = exception ?
225
+ Traject::Util.exception_to_log_message(exception) :
226
+ "Solr response: #{resp.status}: #{resp.body}"
227
+
228
+ logger.error "Error in Solr batch add. Will retry documents individually at performance penalty: #{error_message}"
229
+
230
+ batch.each do |c|
231
+ send_single(c)
232
+ end
233
+ end
234
+ end
235
+
236
+
237
+ # Send a single context to Solr, logging an error if need be
238
+ # @param [Traject::Indexer::Context] c The context whose document you want to send
239
+ def send_single(c)
240
+ logger.debug("#{self.class.name}: sending single record to Solr: #{c.output_hash}")
241
+
242
+ json_package = JSON.generate([c.output_hash])
243
+ begin
244
+ post_url = solr_update_url_with_query(@solr_update_args)
245
+ resp = @http_client.post(post_url, body: json_package, headers: {"Content-type" => "application/json"})
246
+
247
+ unless resp.status == 200
248
+ raise BadHttpResponse.new("Unexpected HTTP response status #{resp.status} from POST #{post_url}", resp)
249
+ end
250
+
251
+ # Catch Timeouts and network errors -- as well as non-200 http responses --
252
+ # as skipped records, but otherwise allow unexpected errors to propagate up.
253
+ rescue *skippable_exceptions => exception
254
+ msg = if exception.kind_of?(BadHttpResponse)
255
+ "Solr error response: #{exception.response.status}: #{exception.response.body}"
256
+ else
257
+ Traject::Util.exception_to_log_message(exception)
258
+ end
259
+
260
+ logger.error "Could not add record #{c.record_inspect}: #{msg}"
261
+ logger.debug("\t" + exception.backtrace.join("\n\t")) if exception
262
+ logger.debug(c.source_record.to_s) if c.source_record
263
+
264
+ @skipped_record_incrementer.increment
265
+ if @max_skipped and skipped_record_count > @max_skipped
266
+ # re-raising in rescue means the last encountered error will be available as #cause
267
+ # on raised exception, a feature in ruby 2.1+.
268
+ raise MaxSkippedRecordsExceeded.new("#{self.class.name}: Exceeded maximum number of skipped records (#{@max_skipped}): aborting: #{exception.message}")
269
+ end
270
+ end
271
+ end
272
+
273
+
274
+ # Very beginning of a delete implementation. POSTs a delete request to solr
275
+ # for id in arg (value of Solr UniqueID field, usually `id` field).
276
+ #
277
+ # Right now, does it inline and immediately, no use of background threads or batching.
278
+ # This could change.
279
+ #
280
+ # Right now, if unsuccesful for any reason, will raise immediately out of here.
281
+ # Could raise any of the `skippable_exceptions` (timeouts, network errors), an
282
+ # exception will be raised right out of here.
283
+ #
284
+ # Will use `solr_writer.solr_update_args` settings.
285
+ #
286
+ # There is no built-in way to direct a record to be deleted from an indexing config
287
+ # file at the moment, this is just a loose method on the writer.
288
+ def delete(id)
289
+ logger.debug("#{self.class.name}: Sending delete to Solr for #{id}")
290
+
291
+ json_package = {delete: id}
292
+ resp = @http_client.post(solr_update_url_with_query(@solr_update_args), body: JSON.generate(json_package), headers: {"Content-type" => "application/json"})
293
+ if resp.status != 200
294
+ raise RuntimeError.new("Could not delete #{id.inspect}, http response #{resp.status}: #{resp.body}")
295
+ end
296
+ end
297
+
298
+ # Send a delete all query.
299
+ #
300
+ # This method takes no params and will not automatically commit the deletes.
301
+ # @example @writer.delete_all!
302
+ def delete_all!
303
+ delete(query: "*:*")
304
+ end
305
+
306
+ # Get the logger from the settings, or default to an effectively null logger
307
+ def logger
308
+ settings["logger"] ||= Yell.new(STDERR, :level => "gt.fatal") # null logger
309
+ end
310
+
311
+ # On close, we need to (a) raise any exceptions we might have, (b) send off
312
+ # the last (possibly empty) batch, and (c) commit if instructed to do so
313
+ # via the solr_writer.commit_on_close setting.
314
+ def close
315
+ @thread_pool.raise_collected_exception!
316
+
317
+ # Finish off whatever's left. Do it in the thread pool for
318
+ # consistency, and to ensure expected order of operations, so
319
+ # it goes to the end of the queue behind any other work.
320
+ batch = Traject::Util.drain_queue(@batched_queue)
321
+ if batch.length > 0
322
+ @thread_pool.maybe_in_thread_pool { send_batch(batch) }
323
+ end
324
+
325
+ if @thread_pool_size && @thread_pool_size > 0
326
+ # Wait for shutdown, and time it.
327
+ logger.debug "#{self.class.name}: Shutting down thread pool, waiting if needed..."
328
+ elapsed = @thread_pool.shutdown_and_wait
329
+ if elapsed > 60
330
+ logger.warn "Waited #{elapsed} seconds for all threads, you may want to increase solr_writer.thread_pool (currently #{@settings["solr_writer.thread_pool"]})"
331
+ end
332
+ logger.debug "#{self.class.name}: Thread pool shutdown complete"
333
+ logger.warn "#{self.class.name}: #{skipped_record_count} skipped records" if skipped_record_count > 0
334
+ end
335
+
336
+ # check again now that we've waited, there could still be some
337
+ # that didn't show up before.
338
+ @thread_pool.raise_collected_exception!
339
+
340
+ # Commit if we're supposed to
341
+ if @commit_on_close
342
+ commit
343
+ end
344
+ end
345
+
346
+
347
+ # Send a commit
348
+ #
349
+ # Called automatially by `close_on_commit` setting, but also can be called manually.
350
+ #
351
+ # If settings `solr_writer.commit_solr_update_args` is set, will be used by default.
352
+ # That setting needs `{ commit: true }` or `{softCommit: true}` if you want it to
353
+ # actually do a commit!
354
+ #
355
+ # Optional query_params argument is the actual args to send, you must be sure
356
+ # to make it include "commit: true" or "softCommit: true" for it to actually commit!
357
+ # But you may want to include other params too, like optimize etc. query_param
358
+ # argument replaces setting `solr_writer.commit_solr_update_args`, they are not merged.
359
+ #
360
+ # @param [Hash] query_params optional query params to send to solr update. Default {"commit" => "true"}
361
+ #
362
+ # @example @writer.commit
363
+ # @example @writer.commit(softCommit: true)
364
+ # @example @writer.commit(commit: true, optimize: true, waitFlush: false)
365
+ def commit(query_params = nil)
366
+ query_params ||= @commit_solr_update_args || {"commit" => "true"}
367
+ logger.info "#{self.class.name} sending commit to solr at url #{@solr_update_url}..."
368
+
369
+ resp = @http_client.with(timeout: { request_timeout: settings["commit_timeout"] || (10 * 60).to_i }).
370
+ get(solr_update_url_with_query(query_params))
371
+ unless resp.status == 200
372
+ raise RuntimeError.new("Could not commit to Solr: #{resp.status} #{resp.body}")
373
+ end
374
+ end
375
+
376
+
377
+ # Return count of encountered skipped records. Most accurate to call
378
+ # it after #close, in which case it should include full count, even
379
+ # under async thread_pool.
380
+ def skipped_record_count
381
+ @skipped_record_incrementer.value
382
+ end
383
+
384
+
385
+ # Relatively complex logic to determine if we have a valid URL and what it is,
386
+ # and if we have basic_auth info
387
+ #
388
+ # Empties out user and password embedded in URI returned, to help avoid logging it.
389
+ #
390
+ # @returns [update_url, basic_auth_user, basic_auth_password]
391
+ def determine_solr_update_url
392
+ url = if settings['solr.update_url']
393
+ check_solr_update_url(settings['solr.update_url'])
394
+ else
395
+ derive_solr_update_url_from_solr_url(settings['solr.url'])
396
+ end
397
+
398
+ parsed_uri = URI.parse(url)
399
+ user_from_uri, password_from_uri = parsed_uri.user, parsed_uri.password
400
+ parsed_uri.user, parsed_uri.password = nil, nil
401
+
402
+ basic_auth_user = @settings["solr_writer.basic_auth_user"] || user_from_uri
403
+ basic_auth_password = @settings["solr_writer.basic_auth_password"] || password_from_uri
404
+
405
+ return [parsed_uri.to_s, basic_auth_user, basic_auth_password]
406
+ end
407
+
408
+
409
+ # If we've got a solr.update_url, make sure it's ok
410
+ def check_solr_update_url(url)
411
+ unless /^#{URI_REGEXP}$/.match(url)
412
+ raise ArgumentError.new("#{self.class.name} setting `solr.update_url` doesn't look like a URL: `#{url}`")
413
+ end
414
+ url
415
+ end
416
+
417
+ def derive_solr_update_url_from_solr_url(url)
418
+ # Nil? Then we bail
419
+ if url.nil?
420
+ raise ArgumentError.new("#{self.class.name}: Neither solr.update_url nor solr.url set; need at least one")
421
+ end
422
+
423
+ # Not a URL? Bail
424
+ unless /^#{URI_REGEXP}$/.match(url)
425
+ raise ArgumentError.new("#{self.class.name} setting `solr.url` doesn't look like a URL: `#{url}`")
426
+ end
427
+
428
+ # Assume the /update/json handler
429
+ return [url.chomp('/'), 'update', 'json'].join('/')
430
+ end
431
+
432
+ class MaxSkippedRecordsExceeded < RuntimeError ; end
433
+
434
+ # Adapted from HTTPClient::BadResponseError.
435
+ # It's got a #response accessor that will give you the HTTPClient
436
+ # Response object that had a bad status, although relying on that
437
+ # would tie you to our HTTPClient implementation that maybe should
438
+ # be considered an implementation detail, so I dunno.
439
+ class BadHttpResponse < RuntimeError
440
+ # HTTP::Message:: a response
441
+ attr_reader :response
442
+
443
+ def initialize(msg, response = nil) # :nodoc:
444
+ solr_error = find_solr_error(response)
445
+ msg += ": #{solr_error}" if solr_error
446
+
447
+ super(msg)
448
+
449
+ @response = response
450
+ end
451
+
452
+ private
453
+
454
+ # If we can get the error out of a JSON response, please do,
455
+ # to include in error message.
456
+ def find_solr_error(response)
457
+ return nil unless response&.content_type&.mime_type&.start_with?("application/json") && response.body && !response.body.empty?
458
+
459
+ parsed = JSON.parse(response.body.to_s)
460
+
461
+ parsed && parsed.dig("error", "msg")
462
+ rescue JSON::ParserError
463
+ return nil
464
+ end
465
+ end
466
+
467
+ private
468
+
469
+ def skippable_exceptions
470
+ @skippable_exceptions ||= (settings["solr_writer.skippable_exceptions"] || [HTTPX::TimeoutError, SocketError, Errno::ECONNREFUSED, Traject::SolrJsonHttpxWriter::BadHttpResponse])
471
+ end
472
+
473
+ # HTTPX has really weird annoying error handling.
474
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/Error-Handling
475
+ #
476
+ # We add a custom plugin to make errors raise more ruby way.
477
+ # https://honeyryderchuck.gitlab.io/httpx/wiki/Custom-Plugins
478
+ #
479
+ # Works as long as we're not doing multiple parallel requests in one call,
480
+ # which we aren't, DRY's up our error handling.
481
+ module HttpxRaiseErrorPlugin
482
+ module InstanceMethods
483
+ def fetch_response(...)
484
+ super.tap do |response|
485
+ # we don't want to raise on 4xx and 5xx responses, just actual errors with no response!
486
+ response.raise_for_status if response&.error && !response&.error.kind_of?(HTTPX::HTTPError)
487
+ end
488
+ end
489
+
490
+ def send_requests(*requests)
491
+ if requests.length > 1
492
+ raise ArgumentError.new("HttpxRaiseErrorPlugin only supports one request arg at a time, got #{requests.length}")
493
+ end
494
+ super
495
+ end
496
+ end
497
+ end
498
+
499
+ end
@@ -1,3 +1,3 @@
1
1
  module Traject
2
- VERSION = "3.8.3"
2
+ VERSION = "3.9.0"
3
3
  end
@@ -1,5 +1,5 @@
1
1
  # Translation map for marc geographic codes constructed by `rake load_maps:marc_geographic` task
2
- # Scraped from http://www.loc.gov/marc/geoareas/gacs_code.html at 2015-01-27 23:00:08 -0500
2
+ # Scraped from https://www.loc.gov/marc/geoareas/gacs_code.html at 2025-03-26 10:36:45 -0400
3
3
  # Intentionally includes discontinued codes.
4
4
 
5
5
  'a': 'Asia'
@@ -140,7 +140,7 @@
140
140
  'e-an': 'Andorra'
141
141
  'e-au': 'Austria'
142
142
  'e-be': 'Belgium'
143
- 'e-bn': 'Bosnia and Hercegovina'
143
+ 'e-bn': 'Bosnia and Herzegovina'
144
144
  'e-bu': 'Bulgaria'
145
145
  'e-bw': 'Belarus'
146
146
  'e-ci': 'Croatia'
@@ -150,6 +150,7 @@
150
150
  'e-fi': 'Finland'
151
151
  'e-fr': 'France'
152
152
  'e-ge': 'Germany (East)'
153
+ 'e-gg': 'Guernsey'
153
154
  'e-gi': 'Gibraltar'
154
155
  'e-gr': 'Greece'
155
156
  'e-gw': 'Germany (West)'
@@ -157,7 +158,9 @@
157
158
  'e-hu': 'Hungary'
158
159
  'e-ic': 'Iceland'
159
160
  'e-ie': 'Ireland'
161
+ 'e-im': 'Isle of Man'
160
162
  'e-it': 'Italy'
163
+ 'e-je': 'Jersey'
161
164
  'e-kv': 'Kosovo'
162
165
  'e-lh': 'Liechtenstein'
163
166
  'e-li': 'Lithuania'
@@ -215,7 +218,7 @@
215
218
  'e-urv': 'Volgo-Viatskii Region, RSFSR'
216
219
  'e-urw': 'Siberia, Western (Russia)'
217
220
  'e-vc': 'Vatican City'
218
- 'e-xn': 'Macedonia (Republic)'
221
+ 'e-xn': 'North Macedonia'
219
222
  'e-xo': 'Slovakia'
220
223
  'e-xr': 'Czech Republic'
221
224
  'e-xv': 'Slovenia'
@@ -280,7 +283,7 @@
280
283
  'f-sj': 'Sudan'
281
284
  'f-sl': 'Sierra Leone'
282
285
  'f-so': 'Somalia'
283
- 'f-sq': 'Swaziland'
286
+ 'f-sq': 'Eswatini'
284
287
  'f-ss': 'Western Sahara'
285
288
  'f-sx': 'Namibia'
286
289
  'f-tg': 'Togo'
@@ -1,4 +1,5 @@
1
1
  # Map Language Codes (in 008[35-37], 041) to User Friendly Term
2
+ # Scraped from https://www.loc.gov/standards/codelists/languages.xml at 2025-03-26 10:40:03 -0400
2
3
 
3
4
  # MARC language codes (including obsolete codes), from https://www.loc.gov/standards/codelists/languages.xml
4
5
  aar: Afar
@@ -111,7 +112,7 @@ dar: Dargwa
111
112
  day: Dayak
112
113
  del: Delaware
113
114
  den: Slavey
114
- dgr: Dogrib
115
+ dgr: Tlicho
115
116
  din: Dinka
116
117
  div: Divehi
117
118
  doi: Dogri
@@ -1380,7 +1381,7 @@ bqh: Baima
1380
1381
  bqi: Bakhtiari
1381
1382
  bqj: Bandial
1382
1383
  bqk: Banda-Mbrès
1383
- bql: Bilakura
1384
+ bql: Karian
1384
1385
  bqm: Wumboko
1385
1386
  bqn: Bulgarian Sign Language
1386
1387
  bqo: Balo
@@ -1749,6 +1750,7 @@ clk: Idu-Mishmi
1749
1750
  cll: Chala
1750
1751
  clm: Clallam
1751
1752
  clo: Lowland Oaxaca Chontal
1753
+ cls: Classical Sanskrit
1752
1754
  clt: Lautu Chin
1753
1755
  clu: Caluyanun
1754
1756
  clw: Chulym
@@ -1928,7 +1930,7 @@ dau: Dar Sila Daju
1928
1930
  dav: Taita
1929
1931
  daw: Davawenyo
1930
1932
  dax: Dayi
1931
- daz: Dao
1933
+ daz: Moi-Wadea
1932
1934
  dba: Bangime
1933
1935
  dbb: Deno
1934
1936
  dbd: Dadiya
@@ -1969,7 +1971,6 @@ def: Dezfuli
1969
1971
  deg: Degema
1970
1972
  deh: Dehwari
1971
1973
  dei: Demisa
1972
- dek: Dek
1973
1974
  dem: Dem
1974
1975
  dep: Pidgin Delaware
1975
1976
  deq: Dendi (Central African Republic)
@@ -2803,6 +2804,7 @@ hng: Hungu
2803
2804
  hnh: ǁAni
2804
2805
  hni: Hani
2805
2806
  hnj: Hmong Njua
2807
+ hnm: Hainanese
2806
2808
  hnn: Hanunoo
2807
2809
  hno: Northern Hindko
2808
2810
  hns: Caribbean Hindustani
@@ -3011,6 +3013,7 @@ iso: Isoko
3011
3013
  isr: Israeli Sign Language
3012
3014
  ist: Istriot
3013
3015
  isu: Isu (Menchum Division)
3016
+ isv: Interslavic
3014
3017
  itb: Binongan Itneg
3015
3018
  itd: Southern Tidung
3016
3019
  ite: Itene
@@ -4075,6 +4078,7 @@ luc: Aringa
4075
4078
  lud: Ludian
4076
4079
  lue: Luvale
4077
4080
  luf: Laua
4081
+ luh: Leizhou Chinese
4078
4082
  luj: Luna
4079
4083
  luk: Lunanakha
4080
4084
  lul: "Olu'bo"
@@ -4396,7 +4400,7 @@ mme: Mae
4396
4400
  mmf: Mundat
4397
4401
  mmg: North Ambrym
4398
4402
  mmh: Mehináku
4399
- mmi: Musar
4403
+ mmi: Hember Avu
4400
4404
  mmj: Majhwar
4401
4405
  mmk: Mukha-Dora
4402
4406
  mml: Man Met
@@ -5089,7 +5093,6 @@ nsx: Nsongo
5089
5093
  nsy: Nasal
5090
5094
  nsz: Nisenan
5091
5095
  ntd: Northern Tidung
5092
- nte: Nathembo
5093
5096
  ntg: Ngantangarra
5094
5097
  nti: Natioro
5095
5098
  ntj: Ngaanyatjarra
@@ -5855,6 +5858,7 @@ row: Dela-Oenale
5855
5858
  rpn: Repanbitip
5856
5859
  rpt: Rapting
5857
5860
  rri: Ririo
5861
+ rrm: Moriori
5858
5862
  rro: Waima
5859
5863
  rrt: Arritinngithigh
5860
5864
  rsb: Romano-Serbian
@@ -6064,6 +6068,7 @@ siy: Sivandi
6064
6068
  siz: Siwi
6065
6069
  sja: Epena
6066
6070
  sjb: Sajau Basap
6071
+ sjc: Shaojiang Chinese
6067
6072
  sjd: Kildin Sami
6068
6073
  sje: Pite Sami
6069
6074
  sjg: Assangori
@@ -7060,6 +7065,7 @@ vrs: Varisi
7060
7065
  vrt: Burmbar
7061
7066
  vsi: Moldova Sign Language
7062
7067
  vsl: Venezuelan Sign Language
7068
+ vsn: Vedic Sanskrit
7063
7069
  vsv: Valencian Sign Language
7064
7070
  vto: Vitou
7065
7071
  vum: Vumbu
@@ -7736,6 +7742,7 @@ yms: Mysian
7736
7742
  ymx: Northern Muji
7737
7743
  ymz: Muzi
7738
7744
  yna: Aluo
7745
+ ynb: Yamben
7739
7746
  ynd: Yandruwandha
7740
7747
  yne: "Lang'e"
7741
7748
  yng: Yango
@@ -1,4 +1,5 @@
1
1
  # Encoding: UTF-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'test_helper'
4
5
  require 'stringio'