neu-mods 0.11.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f29586facbe4e40077b0875aa896886a812b231058e97c12822875fd6203edef
4
- data.tar.gz: 0fc6d10f7b3a50ff8659497a2de526f0d9b394751c4b4acd09c1676338dfdb87
3
+ metadata.gz: 66a8dc19c0e19ccd43f170c117414ddb1df82c3cec889a83c27c6416f778173b
4
+ data.tar.gz: 445176fc7abc0748d47eddae0ee99137321c743b53a9de3432bb5e45a418ea68
5
5
  SHA512:
6
- metadata.gz: 5801c6878c83a3ae03eef2af7970b1d492a215705d7105002eadb12740f5af35f383e490dc4bbc02f59a723c56c99e9b9afb2399a35b0f785613abe1ff11a922
7
- data.tar.gz: 92ffead82d106913bb14ad273096397bb319290b9ce04db46c068412ce3da3cd7dc4670526c50b63413a4f70b10a145bfeef7feecc854abca0480ea3aed7c112
6
+ metadata.gz: 1f453251ae9fd5c98737370fcd26e69d85cc8cf615d4e16bf1fd2b0e0f6cb80a23a9b6819fb1f364044161725a9f828c8df3776b760b5b59f24ac0589ed956b3
7
+ data.tar.gz: 7a04ed96b243039b19457a7df660966f9cd73ec1de92585403fa02a2c0711a80455029d1107cb4d7f3efac9d39b3acf0069bd1ce00031856fff2ce0b350558e6
data/.version CHANGED
@@ -1 +1 @@
1
- 0.11.0
1
+ 0.12.0
data/README.md CHANGED
@@ -31,12 +31,16 @@ doc.plain_title # => "What's New, Episode 1 - How We Respond to Disaster"
31
31
  doc.title_parts # => { non_sort:, subtitle:, title:, part_name:, part_number: }
32
32
  # byte-faithful -- the edit forms pre-fill from these
33
33
  doc.abstract # => normalized, paragraph-joined String
34
- doc.languages # => ["English"] a code-only <languageTerm>eng</> included
34
+ doc.languages # => [{ term: "English", object_part: nil, script: nil }, ...]
35
+ # a code-only <languageTerm>eng</> is read through the
36
+ # ISO 639 registry. @objectPart rides along because
37
+ # objectPart="subtitles" says the SUBTITLES are Spanish,
38
+ # not the resource
35
39
  doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
36
40
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
37
41
  doc.date_created_parts
38
42
  # => { value:, precision:, end_value:, end_precision:,
39
- # qualifier:, key_date: } everything the record
43
+ # qualifier:, key_date:, text: } everything the record
40
44
  # declared about one date. w3cdtf YYYY, YYYY-MM and
41
45
  # YYYY-MM-DD all parse, and the precision says which
42
46
  # shape it gave, so display cannot invent a month or a
@@ -45,20 +49,31 @@ doc.date_created_parts
45
49
  # A keyDate="yes" node chooses the value, ahead of
46
50
  # @point and document order; one date per type is the
47
51
  # rule, so an unflagged repeat is discarded.
52
+ # A value that is not a w3cdtf date projects NO date
53
+ # and keeps its literal in :text -- "19uu", "ca. 1920"
54
+ # and "undated" are statements a cataloguer made, and
55
+ # guessing a date for them is worse than either losing
56
+ # them or showing them as written.
48
57
  # Same for the other six originInfo dates --
49
58
  # date_issued, copyright_date, date_captured,
50
59
  # date_valid, date_other and date_modified. Each part is
51
60
  # also a reader of its own, e.g.
52
61
  # doc.date_created_qualifier.
53
62
  doc.place_of_publication
54
- # => ["Boston"] the type="text" placeTerm wins, so a
55
- # marccountry code does not reach a places facet as a
56
- # place name; a code-only place still projects its code
63
+ # => ["Boston"] the type="text" placeTerm wins, and a
64
+ # bare marccountry code drops rather than reaching a
65
+ # places facet as a place name. A bare code under any
66
+ # other authority still projects
57
67
  doc.host_collections
58
68
  # => [{ title:, volume:, issue:, start_page:, end_page:,
59
69
  # date:, text:, details: [...], extents: [...] }, ...]
60
70
  # this work's position in its host. The entry survives on
61
71
  # its part alone, so a host with no titleInfo is kept
72
+ doc.identifiers # => [{ type: "isbn", value: "...", invalid: false }, ...]
73
+ # @invalid means cancelled or superseded, so it travels
74
+ doc.table_of_contents
75
+ # => ["Ch 1\nCh 2"] line breaks kept: in a contents list
76
+ # the break is the structure, not stray formatting
62
77
  doc.notes # => [{ type: "funding", value: "..." }, ...]
63
78
  doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
64
79
  # every relatedItem that is not a series or a host
@@ -18,6 +18,15 @@ module NEU
18
18
  str.to_s.tr(NBSP, " ").gsub(/\s+/, " ").strip
19
19
  end
20
20
 
21
+ # canonical_ws per line, keeping the line breaks. For a field where a
22
+ # newline is structure rather than formatting -- tableOfContents, where
23
+ # the break separates one entry from the next. Blank lines drop, so a
24
+ # double-spaced list does not project empty entries.
25
+ def canonical_lines(str)
26
+ str.to_s.tr(NBSP, " ").split("\n").map { |line| line.gsub(/\s+/, " ").strip }
27
+ .reject(&:empty?).join("\n")
28
+ end
29
+
21
30
  # Treat values differing only by insignificant whitespace (NBSP vs space,
22
31
  # collapsible runs, leading/trailing) as equal.
23
32
  def whitespace_equivalent?(current, incoming)
@@ -113,6 +113,12 @@ module NEU
113
113
  def use_and_reproduction = access_conditions_of_type("use and reproduction")
114
114
  def restriction_on_access = access_conditions_of_type("restriction on access")
115
115
 
116
+ # An open-string @type reduced to its letters and digits, so casing, word
117
+ # separators and camelCasing cannot decide whether a field matches.
118
+ def self.fold_type(str)
119
+ NEU::MODS.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
120
+ end
121
+
116
122
  # --- Subjects ------------------------------------------------------------
117
123
 
118
124
  # The editable free-text keyword set (Cerberus simple form): topics under the
@@ -250,17 +256,24 @@ module NEU
250
256
 
251
257
  # --- Scalars / simple arrays --------------------------------------------
252
258
 
253
- # Prefer the type="text" term, and translate a code-only one through the
254
- # ISO 639 registry. A record saying `eng` projects "English", so the
255
- # display and the Solr language facet read the same value rather than the
256
- # facet showing codes. An unrecognised code survives as itself.
259
+ # { term:, object_part:, script: } per language element. Prefer the
260
+ # type="text" term, and translate a code-only one through the ISO 639
261
+ # registry. A record saying `eng` projects "English", so the display and
262
+ # the Solr language facet read the same value rather than the facet
263
+ # showing codes. An unrecognised code survives as itself.
264
+ #
265
+ # An entry rather than a bare string because @objectPart changes what the
266
+ # record is claiming. `<language objectPart="subtitles">spa` says the
267
+ # subtitles are Spanish, and projected flat it said the resource was --
268
+ # which is the case a captioned video hits every time. The script rides
269
+ # along for the same reason a name's role does: a consumer cannot
270
+ # recover it from the term.
257
271
  def languages
258
272
  doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
259
- text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
260
- next clean(text.text) if text
273
+ term = language_term(lang)
274
+ next unless term
261
275
 
262
- code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
263
- code && LanguageCodes.term(code)
276
+ { term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang) }
264
277
  end
265
278
  end
266
279
 
@@ -282,20 +295,29 @@ module NEU
282
295
  def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
283
296
 
284
297
  # Prefer the type="text" term per place, falling back to a coded one --
285
- # the pattern #role_term_value and #languages already use. Unfiltered, a
286
- # marccountry code reached the display and the Solr places facet as a
287
- # place name, so "mau" sat in the list beside Boston. A code still
288
- # projects when it is all the record gives, because dropping it would
289
- # lose the only statement the record made.
298
+ # the pattern #role_term_value and #languages already use.
299
+ #
300
+ # A bare marccountry code is the exception, and it drops. "mau" is not a
301
+ # place name, and unfiltered it reached the display and the Solr places
302
+ # facet as one, sitting in the list beside Boston. That is the call
303
+ # #geographic_code_subjects already makes for a MARC GAC code. A code
304
+ # under any other authority survives, because there the code may be the
305
+ # only statement the record made and nothing here can say it is not text.
290
306
  #
291
307
  # TODO: expand a marccountry code through a registry, as LanguageCodes
292
- # does for eng -> English. That needs a vendored code list.
308
+ # does for eng -> English. That needs a vendored code list, and would let
309
+ # this project "Massachusetts" instead of dropping the element.
310
+ MARC_COUNTRY_AUTHORITY = "marccountry"
311
+
293
312
  def place_of_publication
294
313
  doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
295
314
  text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
296
315
  next text if text
297
316
 
298
- clean(place.at_xpath("mods:placeTerm", NAMESPACE)&.text)
317
+ code = place.at_xpath("mods:placeTerm", NAMESPACE)
318
+ next if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
319
+
320
+ clean(code&.text)
299
321
  end
300
322
  end
301
323
 
@@ -307,7 +329,17 @@ module NEU
307
329
  # inconsistent.
308
330
  def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
309
331
 
310
- def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
332
+ # Read with its line breaks intact. A legacy contents list separates its
333
+ # entries by newline, and the whitespace collapse every other field wants
334
+ # ran the entries together into one line -- there the break IS the
335
+ # structure, not stray formatting. A "--"-separated list is unaffected.
336
+ def table_of_contents
337
+ doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
338
+ lines = NEU::MODS.canonical_lines(node.text)
339
+ lines unless lines.empty?
340
+ end
341
+ end
342
+
311
343
  def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
312
344
 
313
345
  # A note about the object rather than about the work -- "Scanned at 600
@@ -417,10 +449,13 @@ module NEU
417
449
  # is a DOI, and a display cannot decide to linkify it. The same argument
418
450
  # #notes already makes for its @type, and #permanent_url already proves
419
451
  # the attribute is load-bearing by special-casing @type='hdl'.
452
+ # @invalid rides along because in MODS it means the identifier is
453
+ # cancelled, superseded or simply wrong. Projected flat, a dead ISBN read
454
+ # exactly like a live one and invited a reader to use it.
420
455
  def identifiers
421
456
  doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
422
457
  value = clean(node.text)
423
- { type: clean(node["type"]), value: value } if value
458
+ { type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes" } if value
424
459
  end
425
460
  end
426
461
 
@@ -429,10 +464,6 @@ module NEU
429
464
  node && clean(node.text)
430
465
  end
431
466
 
432
- # The three w3cdtf date shapes a dateCreated may stop at: year, year-month,
433
- # or a full date. Matching the shape explicitly, rather than widening
434
- # DateTime.parse, is what lets the declared precision fall out of the parse
435
- # instead of being guessed after it.
436
467
  # The eleven children the XSD allows under hierarchicalGeographic, in the
437
468
  # order MODS lists them -- broadest first, which is also the order a
438
469
  # consumer composing a place string wants to reverse.
@@ -453,15 +484,27 @@ module NEU
453
484
  language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
454
485
  }.freeze
455
486
 
456
- W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
487
+ # The w3cdtf date shapes a date element may stop at: year, year-month, a
488
+ # full date, or a full date with a time. Matching the shape explicitly,
489
+ # rather than widening DateTime.parse, is what lets the declared precision
490
+ # fall out of the parse instead of being guessed after it.
491
+ #
492
+ # A value outside these shapes is NOT a date, and #parse_w3cdtf says so
493
+ # rather than guessing. Ruby's DateTime.parse fills the components it
494
+ # cannot find from the CURRENT date, so "19uu" -- standard MARC 008 fill,
495
+ # which the v1 corpus carries at scale -- asserted today's date at "day"
496
+ # precision, and the assertion changed daily. What the record actually
497
+ # wrote survives in the matching *_text field instead.
498
+ W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
457
499
 
458
500
  # What #date_parts returns when the element is absent entirely, so an
459
501
  # absent date is distinguishable from one present and unparseable.
460
- EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
461
- end_precision: nil, qualifier: nil, key_date: nil }.freeze
502
+ EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
503
+ qualifier: nil, key_date: nil, text: nil }.freeze
462
504
 
463
505
  # Everything a record declared about one originInfo date, as
464
- # { value:, precision:, end_value:, end_precision:, qualifier:, key_date: }.
506
+ # { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
507
+ # text: }.
465
508
  #
466
509
  # A date is not a scalar. Precision established that: a year-only date
467
510
  # parses to January 1st, and no consumer downstream can tell that month
@@ -474,7 +517,9 @@ module NEU
474
517
  #
475
518
  # The parts are projected as separate flat fields rather than one nested
476
519
  # value, because the value half has three consumers that need a real date
477
- # object -- a Solr sort key, a citation year and an OAI date.
520
+ # object -- a Solr sort key, a citation year and an OAI date. Those three
521
+ # are also why the literal gets its own field rather than sharing the
522
+ # value: a sort key cannot hold "ca. 1920", and a display can.
478
523
  # MODS puts seven date elements under originInfo and this reads all of
479
524
  # them. dateCaptured is when the object was digitised and dateModified is
480
525
  # when the resource changed -- preservation and cataloguing provenance,
@@ -496,6 +541,7 @@ module NEU
496
541
  def date_created_end_precision = date_created_parts[:end_precision]
497
542
  def date_created_qualifier = date_created_parts[:qualifier]
498
543
  def date_created_key_date = date_created_parts[:key_date]
544
+ def date_created_text = date_created_parts[:text]
499
545
 
500
546
  def date_issued = date_issued_parts[:value]
501
547
  def date_issued_precision = date_issued_parts[:precision]
@@ -503,6 +549,7 @@ module NEU
503
549
  def date_issued_end_precision = date_issued_parts[:end_precision]
504
550
  def date_issued_qualifier = date_issued_parts[:qualifier]
505
551
  def date_issued_key_date = date_issued_parts[:key_date]
552
+ def date_issued_text = date_issued_parts[:text]
506
553
 
507
554
  def copyright_date = copyright_date_parts[:value]
508
555
  def copyright_date_precision = copyright_date_parts[:precision]
@@ -510,6 +557,7 @@ module NEU
510
557
  def copyright_date_end_precision = copyright_date_parts[:end_precision]
511
558
  def copyright_date_qualifier = copyright_date_parts[:qualifier]
512
559
  def copyright_date_key_date = copyright_date_parts[:key_date]
560
+ def copyright_date_text = copyright_date_parts[:text]
513
561
 
514
562
  def date_captured = date_captured_parts[:value]
515
563
  def date_captured_precision = date_captured_parts[:precision]
@@ -517,6 +565,7 @@ module NEU
517
565
  def date_captured_end_precision = date_captured_parts[:end_precision]
518
566
  def date_captured_qualifier = date_captured_parts[:qualifier]
519
567
  def date_captured_key_date = date_captured_parts[:key_date]
568
+ def date_captured_text = date_captured_parts[:text]
520
569
 
521
570
  def date_valid = date_valid_parts[:value]
522
571
  def date_valid_precision = date_valid_parts[:precision]
@@ -524,6 +573,7 @@ module NEU
524
573
  def date_valid_end_precision = date_valid_parts[:end_precision]
525
574
  def date_valid_qualifier = date_valid_parts[:qualifier]
526
575
  def date_valid_key_date = date_valid_parts[:key_date]
576
+ def date_valid_text = date_valid_parts[:text]
527
577
 
528
578
  def date_other = date_other_parts[:value]
529
579
  def date_other_precision = date_other_parts[:precision]
@@ -531,6 +581,7 @@ module NEU
531
581
  def date_other_end_precision = date_other_parts[:end_precision]
532
582
  def date_other_qualifier = date_other_parts[:qualifier]
533
583
  def date_other_key_date = date_other_parts[:key_date]
584
+ def date_other_text = date_other_parts[:text]
534
585
 
535
586
  def date_modified = date_modified_parts[:value]
536
587
  def date_modified_precision = date_modified_parts[:precision]
@@ -538,6 +589,7 @@ module NEU
538
589
  def date_modified_end_precision = date_modified_parts[:end_precision]
539
590
  def date_modified_qualifier = date_modified_parts[:qualifier]
540
591
  def date_modified_key_date = date_modified_parts[:key_date]
592
+ def date_modified_text = date_modified_parts[:text]
541
593
 
542
594
  # The [value, precision] pair the precision work introduced. Retained
543
595
  # because it is the documented entry point for a caller that wants both
@@ -591,7 +643,7 @@ module NEU
591
643
  edition: :many,
592
644
  issuance: :many,
593
645
  frequency: :many,
594
- # Six rows per originInfo date, for each of the seven MODS defines.
646
+ # Seven rows per originInfo date, for each of the seven MODS defines.
595
647
  # Flat rather than one nested value, because the value half has
596
648
  # consumers that need a real date object.
597
649
  date_created: :one,
@@ -600,42 +652,49 @@ module NEU
600
652
  date_created_end_precision: :one,
601
653
  date_created_qualifier: :one,
602
654
  date_created_key_date: :one,
655
+ date_created_text: :one,
603
656
  date_issued: :one,
604
657
  date_issued_precision: :one,
605
658
  date_issued_end: :one,
606
659
  date_issued_end_precision: :one,
607
660
  date_issued_qualifier: :one,
608
661
  date_issued_key_date: :one,
662
+ date_issued_text: :one,
609
663
  copyright_date: :one,
610
664
  copyright_date_precision: :one,
611
665
  copyright_date_end: :one,
612
666
  copyright_date_end_precision: :one,
613
667
  copyright_date_qualifier: :one,
614
668
  copyright_date_key_date: :one,
669
+ copyright_date_text: :one,
615
670
  date_captured: :one,
616
671
  date_captured_precision: :one,
617
672
  date_captured_end: :one,
618
673
  date_captured_end_precision: :one,
619
674
  date_captured_qualifier: :one,
620
675
  date_captured_key_date: :one,
676
+ date_captured_text: :one,
621
677
  date_valid: :one,
622
678
  date_valid_precision: :one,
623
679
  date_valid_end: :one,
624
680
  date_valid_end_precision: :one,
625
681
  date_valid_qualifier: :one,
626
682
  date_valid_key_date: :one,
683
+ date_valid_text: :one,
627
684
  date_other: :one,
628
685
  date_other_precision: :one,
629
686
  date_other_end: :one,
630
687
  date_other_end_precision: :one,
631
688
  date_other_qualifier: :one,
632
689
  date_other_key_date: :one,
690
+ date_other_text: :one,
633
691
  date_modified: :one,
634
692
  date_modified_precision: :one,
635
693
  date_modified_end: :one,
636
694
  date_modified_end_precision: :one,
637
695
  date_modified_qualifier: :one,
638
696
  date_modified_key_date: :one,
697
+ date_modified_text: :one,
639
698
 
640
699
  # physical description
641
700
  resource_type: :many,
@@ -708,24 +767,30 @@ module NEU
708
767
 
709
768
  # --- helpers -------------------------------------------------------------
710
769
 
711
- # A shape-matched but impossible date (2026-13, 2026-02-30) reaches DateTime
712
- # and raises; it falls to the "" sentinel like any other unparseable value.
713
- # Anything outside the three shapes keeps the old permissive parse, so a
714
- # timestamp still projects as a full date.
770
+ # [DateTime, precision] for a w3cdtf date, or nil for a string that is not
771
+ # one. A shape-matched but impossible date (2026-13, 2026-02-30) reaches
772
+ # DateTime, raises, and is nil like any other unreadable value; the caller
773
+ # keeps its literal text.
774
+ #
775
+ # A full timestamp goes through DateTime.parse rather than being rebuilt,
776
+ # so the time of day a dateModified declares survives. Its precision is
777
+ # "day" because that is the finest granularity a consumer renders.
715
778
  def parse_w3cdtf(str)
716
779
  m = W3CDTF_DATE.match(str)
717
- return [DateTime.parse(str), "day"] unless m
718
-
719
- precision = if m[3]
720
- "day"
721
- elsif m[2]
722
- "month"
723
- else
724
- "year"
725
- end
726
- [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
780
+ return nil unless m
781
+ return [DateTime.parse(str), "day"] if m[4]
782
+
783
+ [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), w3cdtf_precision(m)]
727
784
  rescue Date::Error
728
- ["", nil]
785
+ nil
786
+ end
787
+
788
+ # The granularity the record stopped at, which is the whole point of
789
+ # matching the shape rather than widening the parse.
790
+ def w3cdtf_precision(match)
791
+ return "day" if match[3]
792
+
793
+ match[2] ? "month" : "year"
729
794
  end
730
795
 
731
796
  # One originInfo date element, read by its attributes rather than by
@@ -770,7 +835,7 @@ module NEU
770
835
  # itself: MODS enumerates approximate, inferred and questionable, but the
771
836
  # record still said something.
772
837
  def date_entry(start, finish, nodes)
773
- value, precision = node_date(start)
838
+ value, precision, text = node_date(start)
774
839
  end_value, end_precision = node_date(finish)
775
840
  {
776
841
  value: value,
@@ -778,17 +843,28 @@ module NEU
778
843
  end_value: end_value,
779
844
  end_precision: end_precision,
780
845
  qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
781
- key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
846
+ key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
847
+ text: text
782
848
  }
783
849
  end
784
850
 
851
+ # [value, precision, text]. A node whose text is not a w3cdtf date yields
852
+ # no value and keeps its literal instead: "ca. 1920", "19th century" and
853
+ # "1918-1921" in one element are all real statements a cataloguer made,
854
+ # and a preservation repository must neither invent a date for them nor
855
+ # delete them. Only the start node's literal is kept -- the observed
856
+ # corpus writes an unreadable date as one element, and an end point that
857
+ # needs its own literal has never been seen.
785
858
  def node_date(node)
786
- return [nil, nil] unless node
859
+ return [nil, nil, nil] unless node
787
860
 
788
861
  str = NEU::MODS.canonical_ws(node.text)
789
- return [nil, nil] if str.empty?
862
+ return [nil, nil, nil] if str.empty?
863
+
864
+ parsed = parse_w3cdtf(str)
865
+ return [parsed[0], parsed[1], nil] if parsed
790
866
 
791
- parse_w3cdtf(str)
867
+ [nil, nil, str]
792
868
  end
793
869
 
794
870
  def attr_value(node, name)
@@ -953,6 +1029,26 @@ module NEU
953
1029
  doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
954
1030
  end
955
1031
 
1032
+ # The language of one element: the text term, or a code read through the
1033
+ # ISO 639 registry.
1034
+ def language_term(lang)
1035
+ text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
1036
+ return clean(text.text) if text
1037
+
1038
+ code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
1039
+ code && LanguageCodes.term(code)
1040
+ end
1041
+
1042
+ # A scriptTerm, text form preferred. No registry expands a code here: the
1043
+ # ISO 15924 list is not vendored, and inventing a half-translation would
1044
+ # be worse than handing the consumer what the record wrote.
1045
+ def script_term(lang)
1046
+ text = lang.at_xpath("mods:scriptTerm[@type='text']", NAMESPACE)
1047
+ return clean(text.text) if text
1048
+
1049
+ clean(lang.at_xpath("mods:scriptTerm", NAMESPACE)&.text)
1050
+ end
1051
+
956
1052
  def child_text(parent, xpath)
957
1053
  return nil unless parent
958
1054
 
@@ -977,12 +1073,20 @@ module NEU
977
1073
  NEU::MODS.canonical_ws(str)
978
1074
  end
979
1075
 
980
- # The schema leaves accessCondition/@type an open string, so match on the
981
- # canonicalised, case-folded value rather than in the XPath: real records
982
- # carry "Use and Reproduction" as readily as the MODS-recommended casing.
1076
+ # The schema leaves accessCondition/@type an open string, so match on a
1077
+ # folded key rather than in the XPath. Real records carry "Use and
1078
+ # Reproduction", "useAndReproduction" and "restriction-on-access" as
1079
+ # readily as the MODS-recommended casing, and an unmatched
1080
+ # restrictionOnAccess fell through to the generic #access_condition --
1081
+ # which is the same defect the two typed fields exist to prevent, reached
1082
+ # by a different route: a restriction presented to a reader as a licence.
1083
+ #
1084
+ # A genuinely unrecognised type still falls through, which is what
1085
+ # #access_condition is for.
983
1086
  def access_conditions_of_type(type)
1087
+ wanted = Projection.fold_type(type)
984
1088
  nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
985
- .select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
1089
+ .select { |node| Projection.fold_type(node["type"]) == wanted }
986
1090
  join_paragraphs(nodes)
987
1091
  end
988
1092
 
data/lib/neu-mods.rb CHANGED
@@ -35,6 +35,7 @@ module NEU
35
35
 
36
36
  # Whitespace no-op guard (see Canonicalize).
37
37
  def canonical_ws(str) = Canonicalize.canonical_ws(str)
38
+ def canonical_lines(str) = Canonicalize.canonical_lines(str)
38
39
  def whitespace_equivalent?(current, incoming) = Canonicalize.whitespace_equivalent?(current, incoming)
39
40
 
40
41
  # Curator-freetext normalization for the access copy (see TextNormalizer).
metadata CHANGED
@@ -1,55 +1,55 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - '>='
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.13'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - '>='
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.13'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rspec
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ~>
32
32
  - !ruby/object:Gem::Version
33
33
  version: '3.12'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ~>
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.12'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rubocop
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ~>
46
46
  - !ruby/object:Gem::Version
47
47
  version: '1.60'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ~>
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.60'
55
55
  description: 'Nokogiri-native, dependency-light reading/projection contract over MODS
@@ -62,7 +62,7 @@ executables: []
62
62
  extensions: []
63
63
  extra_rdoc_files: []
64
64
  files:
65
- - ".version"
65
+ - .version
66
66
  - Gemfile
67
67
  - README.md
68
68
  - Rakefile
@@ -85,16 +85,16 @@ require_paths:
85
85
  - lib
86
86
  required_ruby_version: !ruby/object:Gem::Requirement
87
87
  requirements:
88
- - - ">="
88
+ - - '>='
89
89
  - !ruby/object:Gem::Version
90
90
  version: '3.0'
91
91
  required_rubygems_version: !ruby/object:Gem::Requirement
92
92
  requirements:
93
- - - ">="
93
+ - - '>='
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 3.2.33
97
+ rubygems_version: 3.0.9
98
98
  signing_key:
99
99
  specification_version: 4
100
100
  summary: Northeastern-flavored MODS XML projection + selection for the DRS.