neu-mods 0.10.0 → 0.11.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: aa2cba0771dfcd203ae956c9f5a84cb58c004df684620e378bf4034b978ee6b3
4
- data.tar.gz: e3610d3984fecaebccdb88032ab07b4fab55b0e6932ee5f54218b654016ceca5
3
+ metadata.gz: f29586facbe4e40077b0875aa896886a812b231058e97c12822875fd6203edef
4
+ data.tar.gz: 0fc6d10f7b3a50ff8659497a2de526f0d9b394751c4b4acd09c1676338dfdb87
5
5
  SHA512:
6
- metadata.gz: 8bcbcf544da159f6e2377ae868808e73e78d254e9c597e2dcd676d140cbb6f59a8624ca698233bd0ab18866c9438ddae988a7fc0ac485ce7fa3557c34d199a76
7
- data.tar.gz: de478ac9c203c1edf05fb604c9da85b05e95e133fb20231716a6ef7dd9310292dacf753f13781d253ecbd93a04aa290e2eff40dc32c5331de0e278c1b07c736f
6
+ metadata.gz: 5801c6878c83a3ae03eef2af7970b1d492a215705d7105002eadb12740f5af35f383e490dc4bbc02f59a723c56c99e9b9afb2399a35b0f785613abe1ff11a922
7
+ data.tar.gz: 92ffead82d106913bb14ad273096397bb319290b9ce04db46c068412ce3da3cd7dc4670526c50b63413a4f70b10a145bfeef7feecc854abca0480ea3aed7c112
data/.version CHANGED
@@ -1 +1 @@
1
- 0.10.0
1
+ 0.11.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ require "neu-mods"
27
27
  doc = NEU::MODS::Document.parse(xml_string)
28
28
 
29
29
  # Projection (plain data)
30
- doc.plain_title # => "What's New - How We Respond to Disaster, Episode 1"
30
+ 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
@@ -42,9 +42,23 @@ doc.date_created_parts
42
42
  # shape it gave, so display cannot invent a month or a
43
43
  # day. The points are read by @point, not by document
44
44
  # order, and the end carries its OWN precision.
45
- # Same for date_issued_parts and copyright_date_parts;
46
- # each part is also a reader of its own, e.g.
45
+ # A keyDate="yes" node chooses the value, ahead of
46
+ # @point and document order; one date per type is the
47
+ # rule, so an unflagged repeat is discarded.
48
+ # Same for the other six originInfo dates --
49
+ # date_issued, copyright_date, date_captured,
50
+ # date_valid, date_other and date_modified. Each part is
51
+ # also a reader of its own, e.g.
47
52
  # doc.date_created_qualifier.
53
+ 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
57
+ doc.host_collections
58
+ # => [{ title:, volume:, issue:, start_page:, end_page:,
59
+ # date:, text:, details: [...], extents: [...] }, ...]
60
+ # this work's position in its host. The entry survives on
61
+ # its part alone, so a host with no titleInfo is kept
48
62
  doc.notes # => [{ type: "funding", value: "..." }, ...]
49
63
  doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
50
64
  # every relatedItem that is not a series or a host
@@ -69,7 +83,9 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
69
83
  # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
70
84
  NEU::MODS.compose_title(non_sort: "", title: "What's New",
71
85
  part_name: "How We Respond to Disaster", part_number: "Episode 1")
72
- # => "What's New - How We Respond to Disaster, Episode 1" (== doc.plain_title)
86
+ # => "What's New, Episode 1 - How We Respond to Disaster" (== doc.plain_title)
87
+ # The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
88
+ # cataloguing convention, and titleInfo is an unordered choice in the schema.
73
89
 
74
90
  # Selectors (live nodes — for editing)
75
91
  node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
@@ -52,10 +52,17 @@ module NEU
52
52
  # (reaching for Nokogiri in a decorator is the smell this avoids). Keys:
53
53
  # :non_sort :title :subtitle :part_name :part_number (nil or "" for absent).
54
54
  # Returns "" when there is no title. Exposed as NEU::MODS.compose_title.
55
+ #
56
+ # The part NUMBER precedes the part NAME. titleInfo is an unordered choice
57
+ # in the schema, so no document order is available to follow, and a fixed
58
+ # order that put the name first read the number as a trailing qualifier on
59
+ # the section rather than as the section it numbers. Cataloguing practice
60
+ # is "Part 2. The Marshes". The separator travels with the part, not with
61
+ # the position, so the swap moves the comma with the number.
55
62
  def self.compose_title(parts)
56
63
  return "" if parts[:title].to_s.strip.empty?
57
64
 
58
- optional = { ": " => parts[:subtitle], " - " => parts[:part_name], ", " => parts[:part_number] }
65
+ optional = { ": " => parts[:subtitle], ", " => parts[:part_number], " - " => parts[:part_name] }
59
66
  suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
60
67
  "#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
61
68
  end
@@ -205,8 +212,18 @@ module NEU
205
212
  # cataloguer who records that a person both wrote and edited a work means
206
213
  # both. Each term prefers the type="text" roleTerm, falling back to the raw
207
214
  # code (NOT MARC-relator-translated -- see README).
215
+ #
216
+ # A name with no name text drops out. A mods:name carrying only a role
217
+ # projected { name: nil, roles: ["edt"] }, which a display renders as a
218
+ # labelled empty row and which every consumer had to guard against with
219
+ # its own compact_blank. #preserved_names deliberately keeps it: that list
220
+ # tells a curator what the XML holds, so an element they need to fix has
221
+ # to stay visible there.
208
222
  def names
209
- doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
223
+ doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
224
+ entry = name_entry(node)
225
+ entry if entry[:name]
226
+ end
210
227
  end
211
228
 
212
229
  # Editable (depositor-managed) creators: the plain names (no authority
@@ -263,7 +280,25 @@ module NEU
263
280
  # back by nothing.
264
281
  def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
265
282
  def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
266
- def place_of_publication = texts_at("/mods:mods/mods:originInfo/mods:place/mods:placeTerm")
283
+
284
+ # 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.
290
+ #
291
+ # TODO: expand a marccountry code through a registry, as LanguageCodes
292
+ # does for eng -> English. That needs a vendored code list.
293
+ def place_of_publication
294
+ doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
295
+ text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
296
+ next text if text
297
+
298
+ clean(place.at_xpath("mods:placeTerm", NAMESPACE)&.text)
299
+ end
300
+ end
301
+
267
302
  def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
268
303
 
269
304
  # Serials. The @authority a record puts on a frequency is not projected:
@@ -338,10 +373,14 @@ module NEU
338
373
  # record, and a transcribed copy goes stale the moment that record is
339
374
  # edited. A part is the exception, because a volume, issue and page range
340
375
  # describe this article and no other record holds that fact.
376
+ #
377
+ # An entry survives on its part alone. Requiring a title discarded the
378
+ # one piece of the block that was ours along with the metadata that never
379
+ # was, and how to render a titleless host is the consumer's call.
341
380
  def host_collections
342
381
  doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
343
- title = child_text(node, "mods:titleInfo/mods:title")
344
- { title: title, **host_part(node) } if title
382
+ entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
383
+ entry if entry.values.any?
345
384
  end
346
385
  end
347
386
 
@@ -349,6 +388,13 @@ module NEU
349
388
  # catch-all below does not repeat them.
350
389
  NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
351
390
 
391
+ # The part detail types volume and issue already have named keys on a
392
+ # host entry, so #host_details does not repeat them -- the same split
393
+ # NAMED_RELATED_ITEM_TYPES makes for relatedItem. A caption is kept
394
+ # because it is the label a cataloguer wrote for the number ("chap."
395
+ # before "3"), which no consumer can reconstruct from an open @type.
396
+ NAMED_HOST_DETAIL_TYPES = %w[volume issue].freeze
397
+
352
398
  # Every other relatedItem, keeping its @type. MODS also defines
353
399
  # constituent, otherFormat, original, preceding, succeeding, isReferencedBy
354
400
  # and reviewOf, and a record carrying any of them projected nothing at all.
@@ -429,9 +475,20 @@ module NEU
429
475
  # The parts are projected as separate flat fields rather than one nested
430
476
  # value, because the value half has three consumers that need a real date
431
477
  # object -- a Solr sort key, a citation year and an OAI date.
478
+ # MODS puts seven date elements under originInfo and this reads all of
479
+ # them. dateCaptured is when the object was digitised and dateModified is
480
+ # when the resource changed -- preservation and cataloguing provenance,
481
+ # which a consumer may keep off a page but cannot recover from anywhere
482
+ # else. dateValid is the period the content holds for, and dateOther is
483
+ # where a date fitting no other element lands, which is where a quantity
484
+ # of migrated v1 date data goes.
432
485
  def date_created_parts = date_parts("dateCreated")
433
486
  def date_issued_parts = date_parts("dateIssued")
434
487
  def copyright_date_parts = date_parts("copyrightDate")
488
+ def date_captured_parts = date_parts("dateCaptured")
489
+ def date_valid_parts = date_parts("dateValid")
490
+ def date_other_parts = date_parts("dateOther")
491
+ def date_modified_parts = date_parts("dateModified")
435
492
 
436
493
  def date_created = date_created_parts[:value]
437
494
  def date_created_precision = date_created_parts[:precision]
@@ -454,6 +511,34 @@ module NEU
454
511
  def copyright_date_qualifier = copyright_date_parts[:qualifier]
455
512
  def copyright_date_key_date = copyright_date_parts[:key_date]
456
513
 
514
+ def date_captured = date_captured_parts[:value]
515
+ def date_captured_precision = date_captured_parts[:precision]
516
+ def date_captured_end = date_captured_parts[:end_value]
517
+ def date_captured_end_precision = date_captured_parts[:end_precision]
518
+ def date_captured_qualifier = date_captured_parts[:qualifier]
519
+ def date_captured_key_date = date_captured_parts[:key_date]
520
+
521
+ def date_valid = date_valid_parts[:value]
522
+ def date_valid_precision = date_valid_parts[:precision]
523
+ def date_valid_end = date_valid_parts[:end_value]
524
+ def date_valid_end_precision = date_valid_parts[:end_precision]
525
+ def date_valid_qualifier = date_valid_parts[:qualifier]
526
+ def date_valid_key_date = date_valid_parts[:key_date]
527
+
528
+ def date_other = date_other_parts[:value]
529
+ def date_other_precision = date_other_parts[:precision]
530
+ def date_other_end = date_other_parts[:end_value]
531
+ def date_other_end_precision = date_other_parts[:end_precision]
532
+ def date_other_qualifier = date_other_parts[:qualifier]
533
+ def date_other_key_date = date_other_parts[:key_date]
534
+
535
+ def date_modified = date_modified_parts[:value]
536
+ def date_modified_precision = date_modified_parts[:precision]
537
+ def date_modified_end = date_modified_parts[:end_value]
538
+ def date_modified_end_precision = date_modified_parts[:end_precision]
539
+ def date_modified_qualifier = date_modified_parts[:qualifier]
540
+ def date_modified_key_date = date_modified_parts[:key_date]
541
+
457
542
  # The [value, precision] pair the precision work introduced. Retained
458
543
  # because it is the documented entry point for a caller that wants both
459
544
  # halves and nothing else.
@@ -506,8 +591,9 @@ module NEU
506
591
  edition: :many,
507
592
  issuance: :many,
508
593
  frequency: :many,
509
- # Six rows per originInfo date. Flat rather than one nested value,
510
- # because the value half has consumers that need a real date object.
594
+ # Six rows per originInfo date, for each of the seven MODS defines.
595
+ # Flat rather than one nested value, because the value half has
596
+ # consumers that need a real date object.
511
597
  date_created: :one,
512
598
  date_created_precision: :one,
513
599
  date_created_end: :one,
@@ -526,6 +612,30 @@ module NEU
526
612
  copyright_date_end_precision: :one,
527
613
  copyright_date_qualifier: :one,
528
614
  copyright_date_key_date: :one,
615
+ date_captured: :one,
616
+ date_captured_precision: :one,
617
+ date_captured_end: :one,
618
+ date_captured_end_precision: :one,
619
+ date_captured_qualifier: :one,
620
+ date_captured_key_date: :one,
621
+ date_valid: :one,
622
+ date_valid_precision: :one,
623
+ date_valid_end: :one,
624
+ date_valid_end_precision: :one,
625
+ date_valid_qualifier: :one,
626
+ date_valid_key_date: :one,
627
+ date_other: :one,
628
+ date_other_precision: :one,
629
+ date_other_end: :one,
630
+ date_other_end_precision: :one,
631
+ date_other_qualifier: :one,
632
+ date_other_key_date: :one,
633
+ date_modified: :one,
634
+ date_modified_precision: :one,
635
+ date_modified_end: :one,
636
+ date_modified_end_precision: :one,
637
+ date_modified_qualifier: :one,
638
+ date_modified_key_date: :one,
529
639
 
530
640
  # physical description
531
641
  resource_type: :many,
@@ -621,14 +731,33 @@ module NEU
621
731
  # One originInfo date element, read by its attributes rather than by
622
732
  # position. A record is free to write point="end" first, and taking the
623
733
  # first node would then invert the range.
734
+ #
735
+ # ONE DATE PER TYPE is the rule, and a repeated, non-ranged, unflagged
736
+ # date of the same type is discarded. A range is one date with two ends,
737
+ # which @point already models, and every consumer of the value -- a sort
738
+ # key, a citation year, an OAI date -- holds exactly one. Note the
739
+ # contrast with a repeated publisher, which survives because
740
+ # publication_information is :many; dates differ because the value is
741
+ # singular downstream, not because repetition went unnoticed.
624
742
  def date_parts(element)
625
743
  nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
626
744
  return EMPTY_DATE if nodes.empty?
627
745
 
628
- start = nodes.find { |n| attr_value(n, "point") == "start" } ||
629
- nodes.find { |n| attr_value(n, "point") != "end" }
630
746
  finish = nodes.find { |n| attr_value(n, "point") == "end" }
631
- date_entry(start, finish, nodes)
747
+ date_entry(start_date_node(nodes), finish, nodes)
748
+ end
749
+
750
+ # The node the value comes from: the flagged one, then the declared
751
+ # start, then document order.
752
+ #
753
+ # keyDate leads because the flag is the record nominating its own
754
+ # principal date. Projecting the flag while reading the value from a
755
+ # different node made the two contradict each other -- a consumer was told
756
+ # the record chose this date and then handed the one before it.
757
+ def start_date_node(nodes)
758
+ nodes.find { |n| attr_value(n, "keyDate") == "yes" && attr_value(n, "point") != "end" } ||
759
+ nodes.find { |n| attr_value(n, "point") == "start" } ||
760
+ nodes.find { |n| attr_value(n, "point") != "end" }
632
761
  end
633
762
 
634
763
  # The end point carries its OWN precision. "1935-06" to "1940" is legal,
@@ -738,6 +867,14 @@ module NEU
738
867
  # punctuation of a citation is display policy, the same call #map_data
739
868
  # makes for cartographics. MODS leaves @unit optional, so a page extent
740
869
  # without one is read rather than dropped.
870
+ #
871
+ # Volume, issue and the page range keep named keys because they are the
872
+ # citation and a consumer asks for them by name. Everything else the
873
+ # schema allows under part arrives structured, because detail/@type and
874
+ # extent/@unit are open strings -- a fixed key per type cannot cover a
875
+ # vocabulary the schema does not close. #date is the article's year within
876
+ # the host, which after the title is the most-cited element of a journal
877
+ # citation and was reaching no consumer at all.
741
878
  def host_part(node)
742
879
  part = node.at_xpath("mods:part", NAMESPACE)
743
880
  return {} if part.nil?
@@ -747,8 +884,46 @@ module NEU
747
884
  volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
748
885
  issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
749
886
  start_page: child_text(part, "#{pages}/mods:start"),
750
- end_page: child_text(part, "#{pages}/mods:end")
751
- }.compact
887
+ end_page: child_text(part, "#{pages}/mods:end"),
888
+ date: child_text(part, "mods:date"),
889
+ text: child_text(part, "mods:text"),
890
+ details: host_details(part),
891
+ extents: host_extents(part)
892
+ }.reject { |_, value| value.nil? || value == [] }
893
+ end
894
+
895
+ def host_details(part)
896
+ part.xpath("mods:detail", NAMESPACE).filter_map do |node|
897
+ type = clean(node["type"])
898
+ next if NAMED_HOST_DETAIL_TYPES.include?(type)
899
+
900
+ entry = {
901
+ type: type,
902
+ number: child_text(node, "mods:number"),
903
+ caption: child_text(node, "mods:caption"),
904
+ title: child_text(node, "mods:title")
905
+ }
906
+ entry if entry.except(:type).values.any?
907
+ end
908
+ end
909
+
910
+ # Every extent EXCEPT the page range, which start_page and end_page hold.
911
+ # A unit other than page -- the minutes of a recording, the columns of a
912
+ # newspaper -- means nothing without its unit, so the unit travels with
913
+ # the numbers rather than being flattened away.
914
+ def host_extents(part)
915
+ part.xpath("mods:extent", NAMESPACE).filter_map do |node|
916
+ unit = clean(node["unit"])
917
+ next if unit.nil? || unit == "page"
918
+
919
+ {
920
+ unit: unit,
921
+ start: child_text(node, "mods:start"),
922
+ end: child_text(node, "mods:end"),
923
+ total: child_text(node, "mods:total"),
924
+ list: child_text(node, "mods:list")
925
+ }
926
+ end
752
927
  end
753
928
 
754
929
  def text_at(xpath)
@@ -19,6 +19,15 @@ module NEU
19
19
  # next title edit, leaving the record with no primary title at all. nil is
20
20
  # the right answer instead -- MODSMerge creates a proper primary titleInfo
21
21
  # from nil, and each variant is projected under its own field.
22
+ #
23
+ # Where a record carries two UNTYPED titleInfo and marks neither, .first
24
+ # decides and the second reaches no field. That is the schema's answer
25
+ # rather than a shortfall: @usage is fixed="primary" and exists precisely
26
+ # to nominate the principal title, @type is a closed enumeration of the
27
+ # four variants, and MODS 3.5 gives a legitimate second untyped title an
28
+ # @altRepGroup (one title in two scripts) or an @otherType. An unmarked
29
+ # duplicate carries none of those, so MODS gives it no meaning to
30
+ # preserve, and it stays in the preservation XML with no projected field.
22
31
  def primary_title_info
23
32
  doc.at_xpath("/mods:mods/mods:titleInfo[@usage='primary']", NAMESPACE) ||
24
33
  doc.xpath("/mods:mods/mods:titleInfo", NAMESPACE).reject { |ti| variant_title?(ti) }.first
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.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-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri