neu-mods 0.9.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: 5416d755319f464e252ef4fa07d0372efb11e1379d76ad6155b78f42a61c221a
4
- data.tar.gz: ddfcf9eb792155c216bf9710ef6cfcc379f914791a782da30577158bb02a1da7
3
+ metadata.gz: f29586facbe4e40077b0875aa896886a812b231058e97c12822875fd6203edef
4
+ data.tar.gz: 0fc6d10f7b3a50ff8659497a2de526f0d9b394751c4b4acd09c1676338dfdb87
5
5
  SHA512:
6
- metadata.gz: d465b12175d8b15f7da7d7c355af0246294b3e7df0c276be775b6ccf41c14840f716fa7624ed86a54bff69da56625041cf7ca1f245e9ff6bb8f4035b44f70799
7
- data.tar.gz: ce373c9bb16d1a7e6142a4e0556e669ddb07d0bbe77db172f7954d4d54eb298b4a688c5cf3a60bd5237611670bbfb162c67d7d9c1bd5377a6579d0c78b9a9030
6
+ metadata.gz: 5801c6878c83a3ae03eef2af7970b1d492a215705d7105002eadb12740f5af35f383e490dc4bbc02f59a723c56c99e9b9afb2399a35b0f785613abe1ff11a922
7
+ data.tar.gz: 92ffead82d106913bb14ad273096397bb319290b9ce04db46c068412ce3da3cd7dc4670526c50b63413a4f70b10a145bfeef7feecc854abca0480ea3aed7c112
data/.version CHANGED
@@ -1 +1 @@
1
- 0.9.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)
@@ -80,7 +96,7 @@ doc.to_xml
80
96
  # node selection (for replace-on-save), and structure-aware build.
81
97
  doc.editable_personal_creators # => [{ given:, family: }] (plain, Creator role)
82
98
  doc.editable_corporate_creators # => [{ name: }]
83
- doc.preserved_names # => [{ name:, role: }] (authority-bearing / non-Creator — read-only)
99
+ doc.preserved_names # => [{ name:, roles: }] (authority-bearing / non-Creator — read-only)
84
100
  doc.editable_creator_nodes("personal") # => live <name> nodes to replace
85
101
  doc.build_personal_name(given: "Jenny", family: "Smith") # => a plain personal <name> node
86
102
  doc.build_corporate_name(name: "Northeastern University") # => a plain corporate <name> node
@@ -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
@@ -114,6 +121,26 @@ module NEU
114
121
  keyword_subjects.flat_map { |s| s.xpath("mods:topic", NAMESPACE).map { |t| t.text.strip } }
115
122
  end
116
123
 
124
+ # Neither child carries heading text: cartographics is a structured
125
+ # coordinate a reader reaches through #map_data, and geographicCode is a
126
+ # MARC code rather than a place name.
127
+ HEADING_OMITTED_CHILDREN = %w[cartographics geographicCode].freeze
128
+
129
+ # Every top-level <subject> as ONE heading, its parts in document order.
130
+ # A pre-coordinated heading like "Salt marshes--Massachusetts--20th
131
+ # century" is a single statement, and the per-axis fields below cannot say
132
+ # which parts belonged together: they pool every topic on the record into
133
+ # one list, so a fragment of a heading and a whole heading read alike.
134
+ #
135
+ # Kept in parts rather than joined. The separator is display policy, the
136
+ # same call #map_data makes for cartographics.
137
+ def subject_headings
138
+ doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
139
+ parts = subject_heading_parts(node)
140
+ { parts: parts } unless parts.empty?
141
+ end
142
+ end
143
+
117
144
  # Every <topic> under any top-level <subject> (the access-copy projection,
118
145
  # equivalent to Atlas's extract_topical_subjects).
119
146
  def topical_subjects = texts_at("/mods:mods/mods:subject/mods:topic")
@@ -130,6 +157,11 @@ module NEU
130
157
  def personal_name_subjects = name_subjects("personal")
131
158
  def corporate_name_subjects = name_subjects("corporate")
132
159
 
160
+ # The last unprojected member of a closed set: every other subject child
161
+ # already has a field, so leaving this one out made "what a subject can
162
+ # carry" arbitrary rather than complete.
163
+ def occupation_subjects = texts_at("/mods:mods/mods:subject/mods:occupation")
164
+
133
165
  def genre_subjects = texts_at("/mods:mods/mods:subject/mods:genre")
134
166
 
135
167
  # A MARC GAC code. Projected as the record wrote it: turning it into a
@@ -141,10 +173,7 @@ module NEU
141
173
  # any other titleInfo, so it composes through the same port as the main
142
174
  # title rather than taking titleInfo/title alone.
143
175
  def title_subjects
144
- doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map do |node|
145
- parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
146
- clean(Projection.compose_title(parts))
147
- end
176
+ doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map { |node| composed_title_of(node) }
148
177
  end
149
178
 
150
179
  # Kept structured for the reason #map_data is. Flattening country / state
@@ -172,17 +201,29 @@ module NEU
172
201
  def name_entry(node)
173
202
  {
174
203
  name: name_display_value_w_date(node),
175
- role: name_role(node),
204
+ roles: name_roles(node),
176
205
  affiliation: texts_under(node, "mods:affiliation")
177
206
  }
178
207
  end
179
208
 
180
- # All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
209
+ # All top-level names as { name:, roles: }. `name` reproduces the `mods` gem's
181
210
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
182
- # display output is preserved). `role` prefers the type="text" roleTerm,
183
- # falling back to the raw code (NOT MARC-relator-translated -- see README).
211
+ # display output is preserved). MODS repeats `role` on one name, and a
212
+ # cataloguer who records that a person both wrote and edited a work means
213
+ # both. Each term prefers the type="text" roleTerm, falling back to the raw
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.
184
222
  def names
185
- 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
186
227
  end
187
228
 
188
229
  # Editable (depositor-managed) creators: the plain names (no authority
@@ -200,7 +241,7 @@ module NEU
200
241
 
201
242
  # Names the editable form does NOT manage (authority-bearing or non-Creator)
202
243
  # -- for read-only display ("these exist; edit via the XML tab"). Composed
203
- # display string + role, like #names but filtered to the preserved set.
244
+ # display string + roles, like #names but filtered to the preserved set.
204
245
  def preserved_names
205
246
  doc.xpath("/mods:mods/mods:name", NAMESPACE)
206
247
  .reject { |node| editable_creator_name?(node) }
@@ -239,7 +280,25 @@ module NEU
239
280
  # back by nothing.
240
281
  def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
241
282
  def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
242
- 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
+
243
302
  def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
244
303
 
245
304
  # Serials. The @authority a record puts on a frequency is not projected:
@@ -251,6 +310,13 @@ module NEU
251
310
  def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
252
311
  def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
253
312
 
313
+ # A note about the object rather than about the work -- "Scanned at 600
314
+ # dpi" belongs beside the extent, not beside a content note. Projected as
315
+ # plain strings like its physicalDescription siblings: #notes keeps @type
316
+ # because the type changes what a top-level note means, and nothing here
317
+ # turns on it.
318
+ def physical_description_notes = texts_at("/mods:mods/mods:physicalDescription/mods:note")
319
+
254
320
  # An LCC or DDC call number. Note this is NOT the same concept as Atlas's
255
321
  # classification_ssim, which carries a FileSet content-type vocabulary --
256
322
  # the name collision is accidental and the consumer has to pick a free
@@ -301,12 +367,34 @@ module NEU
301
367
  end
302
368
 
303
369
  def related_series = related_item_titles("series")
304
- def host_collections = related_item_titles("host")
370
+
371
+ # The host's title plus THIS work's position within it. The host's own
372
+ # name, originInfo and identifier stay out: they belong to the other
373
+ # record, and a transcribed copy goes stale the moment that record is
374
+ # edited. A part is the exception, because a volume, issue and page range
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.
380
+ def host_collections
381
+ doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
382
+ entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
383
+ entry if entry.values.any?
384
+ end
385
+ end
305
386
 
306
387
  # relatedItem @type values that already have a field of their own, so the
307
388
  # catch-all below does not repeat them.
308
389
  NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
309
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
+
310
398
  # Every other relatedItem, keeping its @type. MODS also defines
311
399
  # constituent, otherFormat, original, preceding, succeeding, isReferencedBy
312
400
  # and reviewOf, and a record carrying any of them projected nothing at all.
@@ -387,9 +475,20 @@ module NEU
387
475
  # The parts are projected as separate flat fields rather than one nested
388
476
  # value, because the value half has three consumers that need a real date
389
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.
390
485
  def date_created_parts = date_parts("dateCreated")
391
486
  def date_issued_parts = date_parts("dateIssued")
392
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")
393
492
 
394
493
  def date_created = date_created_parts[:value]
395
494
  def date_created_precision = date_created_parts[:precision]
@@ -412,6 +511,34 @@ module NEU
412
511
  def copyright_date_qualifier = copyright_date_parts[:qualifier]
413
512
  def copyright_date_key_date = copyright_date_parts[:key_date]
414
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
+
415
542
  # The [value, precision] pair the precision work introduced. Retained
416
543
  # because it is the documented entry point for a caller that wants both
417
544
  # halves and nothing else.
@@ -464,8 +591,9 @@ module NEU
464
591
  edition: :many,
465
592
  issuance: :many,
466
593
  frequency: :many,
467
- # Six rows per originInfo date. Flat rather than one nested value,
468
- # 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.
469
597
  date_created: :one,
470
598
  date_created_precision: :one,
471
599
  date_created_end: :one,
@@ -484,6 +612,30 @@ module NEU
484
612
  copyright_date_end_precision: :one,
485
613
  copyright_date_qualifier: :one,
486
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,
487
639
 
488
640
  # physical description
489
641
  resource_type: :many,
@@ -492,15 +644,18 @@ module NEU
492
644
  extent: :many,
493
645
  digital_origin: :many,
494
646
  reformatting_quality: :many,
647
+ physical_description_notes: :many,
495
648
  notes: :many,
496
649
  table_of_contents: :many,
497
650
 
498
651
  # subjects
652
+ subject_headings: :many,
499
653
  topical_subjects: :many,
500
654
  geographic_subjects: :many,
501
655
  temporal_subjects: :many,
502
656
  personal_name_subjects: :many,
503
657
  corporate_name_subjects: :many,
658
+ occupation_subjects: :many,
504
659
  genre_subjects: :many,
505
660
  geographic_code_subjects: :many,
506
661
  title_subjects: :many,
@@ -576,14 +731,33 @@ module NEU
576
731
  # One originInfo date element, read by its attributes rather than by
577
732
  # position. A record is free to write point="end" first, and taking the
578
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.
579
742
  def date_parts(element)
580
743
  nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
581
744
  return EMPTY_DATE if nodes.empty?
582
745
 
583
- start = nodes.find { |n| attr_value(n, "point") == "start" } ||
584
- nodes.find { |n| attr_value(n, "point") != "end" }
585
746
  finish = nodes.find { |n| attr_value(n, "point") == "end" }
586
- 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" }
587
761
  end
588
762
 
589
763
  # The end point carries its OWN precision. "1935-06" to "1940" is legal,
@@ -639,6 +813,13 @@ module NEU
639
813
  # A variant title composed the way the access copy wants it: normalised
640
814
  # first, like #access_title_parts, so a curly quote or an invisible format
641
815
  # mark cannot reach Solr or a display template through this route either.
816
+ # One titleInfo composed the way the access copy wants it, shared by the
817
+ # subject-title axis and the assembled heading so the two cannot drift.
818
+ def composed_title_of(node)
819
+ parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
820
+ clean(Projection.compose_title(parts))
821
+ end
822
+
642
823
  def variant_titles(type)
643
824
  doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
644
825
  parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
@@ -651,10 +832,100 @@ module NEU
651
832
  .filter_map { |node| name_display_value_w_date(node) }
652
833
  end
653
834
 
835
+ def subject_heading_parts(node)
836
+ node.xpath("mods:*", NAMESPACE).flat_map { |child| subject_heading_part(child) }.compact
837
+ end
838
+
839
+ def subject_heading_part(child)
840
+ return [] if HEADING_OMITTED_CHILDREN.include?(child.name)
841
+
842
+ case child.name
843
+ when "name" then [name_display_value_w_date(child)]
844
+ when "titleInfo" then [composed_title_of(child)]
845
+ # Each level is its own part, so a hierarchical place reads as the steps
846
+ # of the heading rather than as one run-together string.
847
+ when "hierarchicalGeographic" then child.xpath("mods:*", NAMESPACE).map { |level| clean(level.text) }
848
+ else split_heading_text(clean(child.text))
849
+ end
850
+ end
851
+
852
+ # A cataloguer who typed a whole heading into one element as "A--B--C"
853
+ # made the same statement as one who structured it into siblings, so both
854
+ # arrive here as the same parts.
855
+ def split_heading_text(text)
856
+ return [] if text.nil?
857
+ return [text] unless text.include?("--")
858
+
859
+ text.split("--").filter_map { |part| clean(part) }
860
+ end
861
+
654
862
  def related_item_titles(type)
655
863
  texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
656
864
  end
657
865
 
866
+ # Kept in parts rather than composed into "24(3), pp. 210-218". The
867
+ # punctuation of a citation is display policy, the same call #map_data
868
+ # makes for cartographics. MODS leaves @unit optional, so a page extent
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.
878
+ def host_part(node)
879
+ part = node.at_xpath("mods:part", NAMESPACE)
880
+ return {} if part.nil?
881
+
882
+ pages = "mods:extent[@unit='page' or not(@unit)]"
883
+ {
884
+ volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
885
+ issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
886
+ start_page: child_text(part, "#{pages}/mods:start"),
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
927
+ end
928
+
658
929
  def text_at(xpath)
659
930
  node = doc.at_xpath(xpath, NAMESPACE)
660
931
  node ? NEU::MODS.canonical_ws(node.text) : ""
@@ -781,14 +1052,16 @@ module NEU
781
1052
  .map(&:text).join(" ")
782
1053
  end
783
1054
 
784
- def name_role(node)
785
- node.xpath("mods:role", NAMESPACE).each do |role|
786
- val = role_term_value(role)
787
- return val if val
788
- end
789
- nil
1055
+ def name_roles(node)
1056
+ node.xpath("mods:role", NAMESPACE).filter_map { |role| role_term_value(role) }
790
1057
  end
791
1058
 
1059
+ # The first declared role, which is what decides whether the simple edit
1060
+ # form owns a name. Deliberately not #name_roles.include?("Creator"):
1061
+ # widening it would hand the form a name whose other roles it cannot
1062
+ # represent, and saving would drop them from the preservation XML.
1063
+ def name_role(node) = name_roles(node).first
1064
+
792
1065
  # Prefer the type="text" roleTerm; fall back to the raw type="code" term
793
1066
  # (NOT MARC-relator-translated -- see README). nil if neither is present.
794
1067
  def role_term_value(role)
@@ -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.9.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-04 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