neu-mods 0.12.0 → 0.13.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: 66a8dc19c0e19ccd43f170c117414ddb1df82c3cec889a83c27c6416f778173b
4
- data.tar.gz: 445176fc7abc0748d47eddae0ee99137321c743b53a9de3432bb5e45a418ea68
3
+ metadata.gz: 04a0acbc467e71ac94fb6b48eb7dbcaca01ccaa5f3c6894268f1cb689967a505
4
+ data.tar.gz: 6b799da10a7a33c6f8a27d5746317a866a856978c27b6fdca9d2b1a0b4170a62
5
5
  SHA512:
6
- metadata.gz: 1f453251ae9fd5c98737370fcd26e69d85cc8cf615d4e16bf1fd2b0e0f6cb80a23a9b6819fb1f364044161725a9f828c8df3776b760b5b59f24ac0589ed956b3
7
- data.tar.gz: 7a04ed96b243039b19457a7df660966f9cd73ec1de92585403fa02a2c0711a80455029d1107cb4d7f3efac9d39b3acf0069bd1ce00031856fff2ce0b350558e6
6
+ metadata.gz: 0edb1eeac6b75e5f94fb3eaf2ae15ada5e37d7ca245c2ed5836d502d9f27043ad81a7a7ca3f85639a25233828fae2bc89eaa629dbae0537e776a24ad34bea0f2
7
+ data.tar.gz: c944c560bd9ec49eb4b352809df9af3a1dab42d8faa2589d56b0a01c7b5bd69cecceb1ef78a86f6bfdc89b3a97ee4a94ad1ba42577231573fd4b87ebaf90db3b
data/.version CHANGED
@@ -1 +1 @@
1
- 0.12.0
1
+ 0.13.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, Episode 1 - How We Respond to Disaster"
30
+ doc.plain_title # => "What's New. How We Respond to Disaster. Episode 1"
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
@@ -60,24 +60,55 @@ doc.date_created_parts
60
60
  # also a reader of its own, e.g.
61
61
  # doc.date_created_qualifier.
62
62
  doc.place_of_publication
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
63
+ # => [{ value: "Boston", display_label:, href: }, ...]
64
+ # the type="text" placeTerm wins, and a bare marccountry
65
+ # code drops rather than reaching a places facet as a
66
+ # place name. A bare code under any other authority
67
+ # still projects
68
+
69
+ # A name entry also carries @usage (fixed="primary" in the schema, so a record
70
+ # that sets it has said which name leads) and :alternative_names, the MODS 3.7
71
+ # alternativeName composed with the ENCLOSING name's @type.
72
+
73
+ doc.origin_agents # => [{ name:, roles:, affiliation:, display_label:, href:,
74
+ # event_type: }, ...]
75
+ # originInfo/agent, new in MODS 3.8: who performed the
76
+ # event the block records
77
+
78
+ # An originInfo child also carries its block's @eventType, and a place carries
79
+ # the NAMES of the date elements beside it (:date_elements) -- "Creation place"
80
+ # and "Publication place" are the same element under a different date, and the
81
+ # place says nothing about the event itself. Each of the seven dates gains
82
+ # <date>_display_label and <date>_event_type from the same block.
83
+
84
+ # Every DISPLAYED projection carries the @displayLabel and xlink:href of the
85
+ # element its header comes from, as { value:, display_label:, href: } -- or as
86
+ # two extra keys where the entry already had a shape of its own. MODS puts the
87
+ # pair on originInfo and physicalDescription rather than on the publisher,
88
+ # place, extent or digitalOrigin inside them, so those children read it off
89
+ # their parent. The two attribute sets overlap rather than match (26 elements
90
+ # take @displayLabel, 14 take xlink:href); an element the schema gives neither
91
+ # projects nil. The four fields that JOIN several elements into one string --
92
+ # abstract and the three accessCondition fields -- take companion scalars
93
+ # instead (doc.abstract_display_label, doc.abstract_href).
67
94
  doc.host_collections
68
95
  # => [{ title:, volume:, issue:, start_page:, end_page:,
69
96
  # date:, text:, details: [...], extents: [...] }, ...]
70
97
  # this work's position in its host. The entry survives on
71
98
  # its part alone, so a host with no titleInfo is kept
72
- doc.identifiers # => [{ type: "isbn", value: "...", invalid: false }, ...]
99
+ doc.identifiers # => [{ type: "isbn", value: "...", invalid: false,
100
+ # display_label:, href: }, ...]
73
101
  # @invalid means cancelled or superseded, so it travels
74
102
  doc.table_of_contents
75
- # => ["Ch 1\nCh 2"] line breaks kept: in a contents list
103
+ # => [{ value: "Ch 1\nCh 2", ... }]
104
+ # line breaks kept: in a contents list
76
105
  # the break is the structure, not stray formatting
77
- doc.notes # => [{ type: "funding", value: "..." }, ...]
78
- doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
106
+ doc.notes # => [{ type: "funding", value: "...", display_label:, href: }, ...]
107
+ doc.related_items # => [{ type: "otherFormat", title: "...",
108
+ # display_label:, href: }, ...]
79
109
  # every relatedItem that is not a series or a host
80
- doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
110
+ doc.location # => [{ physical_location:, shelf_location:, url:,
111
+ # display_label:, href: }, ...]
81
112
  doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
82
113
  doc.title_subjects # => ["The Great Gatsby"] composed like the main title
83
114
  doc.hierarchical_geographic_subjects
@@ -98,7 +129,7 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
98
129
  # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
99
130
  NEU::MODS.compose_title(non_sort: "", title: "What's New",
100
131
  part_name: "How We Respond to Disaster", part_number: "Episode 1")
101
- # => "What's New, Episode 1 - How We Respond to Disaster" (== doc.plain_title)
132
+ # => "What's New. How We Respond to Disaster. Episode 1" (== doc.plain_title)
102
133
  # The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
103
134
  # cataloguing convention, and titleInfo is an unordered choice in the schema.
104
135
 
@@ -53,17 +53,25 @@ module NEU
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
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.
56
+ # nonSort, title, subtitle, partName, partNumber -- the order the
57
+ # librarians settled on 2026-09-14. titleInfo is an unordered choice in
58
+ # the schema, so no document order is available to follow and the
59
+ # composer has to fix one.
60
+ #
61
+ # A period separates the title or subtitle from the parts, and one part
62
+ # from the next. The separator travels with its part rather than with the
63
+ # position, so a record giving only a partNumber still gets the period.
64
+ # Nothing is appended after the last part: a title is a value, not a
65
+ # sentence, and a trailing period reads as part of the title everywhere
66
+ # the value is re-used.
67
+ TITLE_SEPARATORS = [[": ", :subtitle], [". ", :part_name], [". ", :part_number]].freeze
68
+
62
69
  def self.compose_title(parts)
63
70
  return "" if parts[:title].to_s.strip.empty?
64
71
 
65
- optional = { ": " => parts[:subtitle], ", " => parts[:part_number], " - " => parts[:part_name] }
66
- suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
72
+ suffix = TITLE_SEPARATORS.filter_map do |separator, key|
73
+ "#{separator}#{parts[key]}" unless parts[key].to_s.strip.empty?
74
+ end.join
67
75
  "#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
68
76
  end
69
77
 
@@ -98,6 +106,13 @@ module NEU
98
106
  join_paragraphs(abstract_nodes)
99
107
  end
100
108
 
109
+ # The header and the link a record attached to its abstract. Companion
110
+ # scalars rather than an entry, because #abstract joins every abstract
111
+ # element into one value and three consumers -- the OAI dc:description,
112
+ # the citation and description_tsim -- hold that value as a string.
113
+ def abstract_display_label = first_attr(abstract_nodes, "displayLabel")
114
+ def abstract_href = first_href(abstract_nodes)
115
+
101
116
  # Every top-level accessCondition joined, regardless of @type. Retained
102
117
  # because it is the only projection that carries an untyped or
103
118
  # unrecognised accessCondition, which the two typed fields below cannot
@@ -113,6 +128,24 @@ module NEU
113
128
  def use_and_reproduction = access_conditions_of_type("use and reproduction")
114
129
  def restriction_on_access = access_conditions_of_type("restriction on access")
115
130
 
131
+ # Companion scalars for the same reason the abstract's are: each of the
132
+ # three fields joins several elements into one value, and a licence URI
133
+ # belongs beside the licence text a reader is given.
134
+ def access_condition_display_label = first_attr(access_condition_nodes, "displayLabel")
135
+ def access_condition_href = first_href(access_condition_nodes)
136
+
137
+ def use_and_reproduction_display_label
138
+ first_attr(access_condition_nodes("use and reproduction"), "displayLabel")
139
+ end
140
+
141
+ def use_and_reproduction_href = first_href(access_condition_nodes("use and reproduction"))
142
+
143
+ def restriction_on_access_display_label
144
+ first_attr(access_condition_nodes("restriction on access"), "displayLabel")
145
+ end
146
+
147
+ def restriction_on_access_href = first_href(access_condition_nodes("restriction on access"))
148
+
116
149
  # An open-string @type reduced to its letters and digits, so casing, word
117
150
  # separators and camelCasing cannot decide whether a field matches.
118
151
  def self.fold_type(str)
@@ -124,7 +157,7 @@ module NEU
124
157
  # The editable free-text keyword set (Cerberus simple form): topics under the
125
158
  # attribute-free keyword subjects only.
126
159
  def keywords
127
- keyword_subjects.flat_map { |s| s.xpath("mods:topic", NAMESPACE).map { |t| t.text.strip } }
160
+ keyword_subjects.flat_map { |s| texts_under(s, "mods:topic") }
128
161
  end
129
162
 
130
163
  # Neither child carries heading text: cartographics is a structured
@@ -143,7 +176,7 @@ module NEU
143
176
  def subject_headings
144
177
  doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
145
178
  parts = subject_heading_parts(node)
146
- { parts: parts } unless parts.empty?
179
+ { parts: parts, **qualifiers_of(node) } unless parts.empty?
147
180
  end
148
181
  end
149
182
 
@@ -208,10 +241,28 @@ module NEU
208
241
  {
209
242
  name: name_display_value_w_date(node),
210
243
  roles: name_roles(node),
211
- affiliation: texts_under(node, "mods:affiliation")
244
+ affiliation: texts_under(node, "mods:affiliation"),
245
+ # @usage is fixed="primary" in the schema and exists to nominate the
246
+ # principal name. A record that sets it has said which name leads,
247
+ # and without it a consumer grouping role-less names can only guess.
248
+ usage: attr_value(node, "usage"),
249
+ alternative_names: alternative_names(node),
250
+ **qualifiers_of(node)
212
251
  }
213
252
  end
214
253
 
254
+ # mods:alternativeName, new in MODS 3.7: a second form of the same name,
255
+ # not a second name. Composed with the ENCLOSING name's @type, because
256
+ # alternativeName carries @altType rather than @type and an alternative
257
+ # for a personal name is still a personal name -- read from its own
258
+ # attributes it would compose "Doe Jane" where the name above it
259
+ # composes "Doe, Jane".
260
+ def alternative_names(node)
261
+ node.xpath("mods:alternativeName", NAMESPACE).filter_map do |alt|
262
+ name_display_value_w_date(alt, attr_value(node, "type"))
263
+ end
264
+ end
265
+
215
266
  # All top-level names as { name:, roles: }. `name` reproduces the `mods` gem's
216
267
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
217
268
  # display output is preserved). MODS repeats `role` on one name, and a
@@ -273,26 +324,39 @@ module NEU
273
324
  term = language_term(lang)
274
325
  next unless term
275
326
 
276
- { term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang) }
327
+ { term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang),
328
+ **qualifiers_of(lang) }
277
329
  end
278
330
  end
279
331
 
280
332
  # MODS repeats typeOfResource, and repeats physicalDescription (and form and
281
333
  # extent within one), so all four are :many. A record that is both text and
282
334
  # a still image used to project as text alone.
283
- def resource_type = texts_at("/mods:mods/mods:typeOfResource")
284
- def format = texts_at("/mods:mods/mods:physicalDescription/mods:form")
285
- def extent = texts_at("/mods:mods/mods:physicalDescription/mods:extent")
286
- def digital_origin = texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
335
+ def resource_type = labeled_texts_at("/mods:mods/mods:typeOfResource")
336
+
337
+ # MODS puts @displayLabel on physicalDescription, not on the form, extent,
338
+ # digitalOrigin, reformattingQuality or note inside it -- so these four
339
+ # take the label off their parent. `from: ".."` says which element the
340
+ # header comes from; the value still comes from the element itself.
341
+ def format = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:form", from: "..")
342
+ def extent = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:extent", from: "..")
343
+ def digital_origin = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin", from: "..")
344
+
345
+ def genres = labeled_texts_at("/mods:mods/mods:genre")
287
346
 
288
- def genres = texts_at("/mods:mods/mods:genre")
347
+ # Who the resource is for. The last displayed top-level element with no
348
+ # projection at all: a record naming its audience said so to nobody.
349
+ def target_audience = labeled_texts_at("/mods:mods/mods:targetAudience")
289
350
 
290
351
  # originInfo repeats, and so do publisher and edition within one. Cerberus's
291
352
  # IPTC ingest writes the publisher from the IPTC Source field on every batch,
292
353
  # so this element was being written into the preservation XML and then read
293
354
  # back by nothing.
294
- def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
295
- def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
355
+ # @displayLabel and @eventType sit on originInfo, not on the publisher,
356
+ # place, edition, issuance or frequency inside it, so each of these takes
357
+ # its header off the parent block.
358
+ def publication_information = origin_texts_at("mods:publisher")
359
+ def edition = origin_texts_at("mods:edition")
296
360
 
297
361
  # Prefer the type="text" term per place, falling back to a coded one --
298
362
  # the pattern #role_term_value and #languages already use.
@@ -311,23 +375,43 @@ module NEU
311
375
 
312
376
  def place_of_publication
313
377
  doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
314
- text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
315
- next text if text
378
+ value = place_term_value(place)
379
+ next unless value
316
380
 
317
- code = place.at_xpath("mods:placeTerm", NAMESPACE)
318
- next if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
381
+ { value: value, **origin_qualifiers_of(place.parent),
382
+ date_elements: origin_date_elements(place.parent) }
383
+ end
384
+ end
319
385
 
320
- clean(code&.text)
386
+ # originInfo/agent, new in MODS 3.8: who performed the event the block
387
+ # records. Read through the same port as a top-level name, so a publisher
388
+ # recorded as an agent composes the way a creator does and carries its
389
+ # roles -- which is what a consumer heads the row with when the block
390
+ # states no displayLabel or eventType.
391
+ def origin_agents
392
+ doc.xpath("/mods:mods/mods:originInfo/mods:agent", NAMESPACE).filter_map do |node|
393
+ entry = name_entry(node)
394
+ entry.merge(event_type: attr_value(node.parent, "eventType")) if entry[:name]
321
395
  end
322
396
  end
323
397
 
324
- def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
398
+ def place_term_value(place)
399
+ text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
400
+ return text if text
401
+
402
+ code = place.at_xpath("mods:placeTerm", NAMESPACE)
403
+ return nil if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
404
+
405
+ clean(code&.text)
406
+ end
407
+
408
+ def issuance = origin_texts_at("mods:issuance")
325
409
 
326
410
  # Serials. The @authority a record puts on a frequency is not projected:
327
411
  # authority handling is a question the gem defers everywhere else -- for
328
412
  # genre, subject and name -- and answering it for one field would be
329
413
  # inconsistent.
330
- def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
414
+ def frequency = origin_texts_at("mods:frequency")
331
415
 
332
416
  # Read with its line breaks intact. A legacy contents list separates its
333
417
  # entries by newline, and the whitespace collapse every other field wants
@@ -336,24 +420,26 @@ module NEU
336
420
  def table_of_contents
337
421
  doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
338
422
  lines = NEU::MODS.canonical_lines(node.text)
339
- lines unless lines.empty?
423
+ labeled(lines, node) unless lines.empty?
340
424
  end
341
425
  end
342
426
 
343
- def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
427
+ def reformatting_quality
428
+ labeled_texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality", from: "..")
429
+ end
344
430
 
345
431
  # A note about the object rather than about the work -- "Scanned at 600
346
432
  # dpi" belongs beside the extent, not beside a content note. Projected as
347
433
  # plain strings like its physicalDescription siblings: #notes keeps @type
348
434
  # because the type changes what a top-level note means, and nothing here
349
435
  # turns on it.
350
- def physical_description_notes = texts_at("/mods:mods/mods:physicalDescription/mods:note")
436
+ def physical_description_notes = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:note", from: "..")
351
437
 
352
438
  # An LCC or DDC call number. Note this is NOT the same concept as Atlas's
353
439
  # classification_ssim, which carries a FileSet content-type vocabulary --
354
440
  # the name collision is accidental and the consumer has to pick a free
355
441
  # Solr field.
356
- def classification = texts_at("/mods:mods/mods:classification")
442
+ def classification = labeled_texts_at("/mods:mods/mods:classification")
357
443
 
358
444
  # Every top-level note, keeping its @type. The type carries meaning -- a
359
445
  # "statement of responsibility" is not a "funding" note -- so flattening
@@ -361,7 +447,7 @@ module NEU
361
447
  def notes
362
448
  doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
363
449
  value = clean(node.text)
364
- { type: clean(node["type"]), value: value } if value
450
+ { type: clean(node["type"]), value: value, **qualifiers_of(node) } if value
365
451
  end
366
452
  end
367
453
 
@@ -379,7 +465,7 @@ module NEU
379
465
  shelf_location: child_text(node, "mods:shelfLocator"),
380
466
  url: child_text(node, "mods:url")
381
467
  }
382
- entry if entry.values.any?
468
+ entry.merge(qualifiers_of(node)) if entry.values.any?
383
469
  end
384
470
  end
385
471
 
@@ -394,7 +480,8 @@ module NEU
394
480
  projection: child_text(node, "mods:projection"),
395
481
  coordinates: child_text(node, "mods:coordinates")
396
482
  }
397
- entry if entry.values.any?
483
+ # cartographics carries neither attribute; the enclosing subject does.
484
+ entry.merge(qualifiers_of(node.parent)) if entry.values.any?
398
485
  end
399
486
  end
400
487
 
@@ -412,7 +499,7 @@ module NEU
412
499
  def host_collections
413
500
  doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
414
501
  entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
415
- entry if entry.values.any?
502
+ entry.merge(qualifiers_of(node)) if entry.values.any?
416
503
  end
417
504
  end
418
505
 
@@ -439,7 +526,7 @@ module NEU
439
526
  next if NAMED_RELATED_ITEM_TYPES.include?(type)
440
527
 
441
528
  title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
442
- { type: type, title: title } if title
529
+ { type: type, title: title, **qualifiers_of(node) } if title
443
530
  end
444
531
  end
445
532
 
@@ -455,7 +542,10 @@ module NEU
455
542
  def identifiers
456
543
  doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
457
544
  value = clean(node.text)
458
- { type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes" } if value
545
+ if value
546
+ { type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes",
547
+ **qualifiers_of(node) }
548
+ end
459
549
  end
460
550
  end
461
551
 
@@ -464,6 +554,14 @@ module NEU
464
554
  node && clean(node.text)
465
555
  end
466
556
 
557
+ # The handle identifier carries @displayLabel="Permanent URL" in Atlas's
558
+ # own MODS template, so the header a reader sees is one the record states
559
+ # rather than one a decorator invents. No href companion: the value is the
560
+ # URL.
561
+ def permanent_url_display_label
562
+ attr_value(doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE), "displayLabel")
563
+ end
564
+
467
565
  # The eleven children the XSD allows under hierarchicalGeographic, in the
468
566
  # order MODS lists them -- broadest first, which is also the order a
469
567
  # consumer composing a place string wants to reverse.
@@ -497,10 +595,22 @@ module NEU
497
595
  # wrote survives in the matching *_text field instead.
498
596
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
499
597
 
598
+ # ISO 8601 basic format: the same year, month and day written without the
599
+ # hyphens. Accepted ONLY where the record declares @encoding="iso8601",
600
+ # because eight bare digits are a date only because the encoding says so
601
+ # -- an accession number is eight digits too, and guessing is the mistake
602
+ # dropping the DateTime.parse fallback exists to prevent.
603
+ ISO8601_BASIC_DATE = /\A(\d{4})(?:(\d{2})(?:(\d{2})(T\S+)?)?)?\z/
604
+
605
+ # @encoding, folded. MODS leaves the attribute an open string and records
606
+ # write "iso8601" and "ISO-8601" alike.
607
+ ISO8601_ENCODING = "iso8601"
608
+
500
609
  # What #date_parts returns when the element is absent entirely, so an
501
610
  # absent date is distinguishable from one present and unparseable.
502
611
  EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
503
- qualifier: nil, key_date: nil, text: nil }.freeze
612
+ qualifier: nil, key_date: nil, text: nil,
613
+ display_label: nil, event_type: nil }.freeze
504
614
 
505
615
  # Everything a record declared about one originInfo date, as
506
616
  # { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
@@ -527,6 +637,11 @@ module NEU
527
637
  # else. dateValid is the period the content holds for, and dateOther is
528
638
  # where a date fitting no other element lands, which is where a quantity
529
639
  # of migrated v1 date data goes.
640
+ # The seven date elements MODS puts under originInfo, in the order a
641
+ # consumer deciding a place header reads them.
642
+ DATE_ELEMENTS = %w[dateIssued dateCreated copyrightDate dateCaptured
643
+ dateValid dateOther dateModified].freeze
644
+
530
645
  def date_created_parts = date_parts("dateCreated")
531
646
  def date_issued_parts = date_parts("dateIssued")
532
647
  def copyright_date_parts = date_parts("copyrightDate")
@@ -542,6 +657,8 @@ module NEU
542
657
  def date_created_qualifier = date_created_parts[:qualifier]
543
658
  def date_created_key_date = date_created_parts[:key_date]
544
659
  def date_created_text = date_created_parts[:text]
660
+ def date_created_display_label = date_created_parts[:display_label]
661
+ def date_created_event_type = date_created_parts[:event_type]
545
662
 
546
663
  def date_issued = date_issued_parts[:value]
547
664
  def date_issued_precision = date_issued_parts[:precision]
@@ -550,6 +667,8 @@ module NEU
550
667
  def date_issued_qualifier = date_issued_parts[:qualifier]
551
668
  def date_issued_key_date = date_issued_parts[:key_date]
552
669
  def date_issued_text = date_issued_parts[:text]
670
+ def date_issued_display_label = date_issued_parts[:display_label]
671
+ def date_issued_event_type = date_issued_parts[:event_type]
553
672
 
554
673
  def copyright_date = copyright_date_parts[:value]
555
674
  def copyright_date_precision = copyright_date_parts[:precision]
@@ -558,6 +677,8 @@ module NEU
558
677
  def copyright_date_qualifier = copyright_date_parts[:qualifier]
559
678
  def copyright_date_key_date = copyright_date_parts[:key_date]
560
679
  def copyright_date_text = copyright_date_parts[:text]
680
+ def copyright_date_display_label = copyright_date_parts[:display_label]
681
+ def copyright_date_event_type = copyright_date_parts[:event_type]
561
682
 
562
683
  def date_captured = date_captured_parts[:value]
563
684
  def date_captured_precision = date_captured_parts[:precision]
@@ -566,6 +687,8 @@ module NEU
566
687
  def date_captured_qualifier = date_captured_parts[:qualifier]
567
688
  def date_captured_key_date = date_captured_parts[:key_date]
568
689
  def date_captured_text = date_captured_parts[:text]
690
+ def date_captured_display_label = date_captured_parts[:display_label]
691
+ def date_captured_event_type = date_captured_parts[:event_type]
569
692
 
570
693
  def date_valid = date_valid_parts[:value]
571
694
  def date_valid_precision = date_valid_parts[:precision]
@@ -574,6 +697,8 @@ module NEU
574
697
  def date_valid_qualifier = date_valid_parts[:qualifier]
575
698
  def date_valid_key_date = date_valid_parts[:key_date]
576
699
  def date_valid_text = date_valid_parts[:text]
700
+ def date_valid_display_label = date_valid_parts[:display_label]
701
+ def date_valid_event_type = date_valid_parts[:event_type]
577
702
 
578
703
  def date_other = date_other_parts[:value]
579
704
  def date_other_precision = date_other_parts[:precision]
@@ -582,6 +707,8 @@ module NEU
582
707
  def date_other_qualifier = date_other_parts[:qualifier]
583
708
  def date_other_key_date = date_other_parts[:key_date]
584
709
  def date_other_text = date_other_parts[:text]
710
+ def date_other_display_label = date_other_parts[:display_label]
711
+ def date_other_event_type = date_other_parts[:event_type]
585
712
 
586
713
  def date_modified = date_modified_parts[:value]
587
714
  def date_modified_precision = date_modified_parts[:precision]
@@ -590,6 +717,8 @@ module NEU
590
717
  def date_modified_qualifier = date_modified_parts[:qualifier]
591
718
  def date_modified_key_date = date_modified_parts[:key_date]
592
719
  def date_modified_text = date_modified_parts[:text]
720
+ def date_modified_display_label = date_modified_parts[:display_label]
721
+ def date_modified_event_type = date_modified_parts[:event_type]
593
722
 
594
723
  # The [value, precision] pair the precision work introduced. Retained
595
724
  # because it is the documented entry point for a caller that wants both
@@ -628,6 +757,7 @@ module NEU
628
757
  FIELDS = {
629
758
  # titles
630
759
  main_title: :one,
760
+ main_title_display_label: :one,
631
761
  alternative_title: :many,
632
762
  uniform_title: :many,
633
763
  translated_title: :many,
@@ -636,10 +766,13 @@ module NEU
636
766
  names: :many,
637
767
  languages: :many,
638
768
  abstract: :one,
769
+ abstract_display_label: :one,
770
+ abstract_href: :one,
639
771
 
640
772
  # origin
641
773
  publication_information: :many,
642
774
  place_of_publication: :many,
775
+ origin_agents: :many,
643
776
  edition: :many,
644
777
  issuance: :many,
645
778
  frequency: :many,
@@ -653,6 +786,8 @@ module NEU
653
786
  date_created_qualifier: :one,
654
787
  date_created_key_date: :one,
655
788
  date_created_text: :one,
789
+ date_created_display_label: :one,
790
+ date_created_event_type: :one,
656
791
  date_issued: :one,
657
792
  date_issued_precision: :one,
658
793
  date_issued_end: :one,
@@ -660,6 +795,8 @@ module NEU
660
795
  date_issued_qualifier: :one,
661
796
  date_issued_key_date: :one,
662
797
  date_issued_text: :one,
798
+ date_issued_display_label: :one,
799
+ date_issued_event_type: :one,
663
800
  copyright_date: :one,
664
801
  copyright_date_precision: :one,
665
802
  copyright_date_end: :one,
@@ -667,6 +804,8 @@ module NEU
667
804
  copyright_date_qualifier: :one,
668
805
  copyright_date_key_date: :one,
669
806
  copyright_date_text: :one,
807
+ copyright_date_display_label: :one,
808
+ copyright_date_event_type: :one,
670
809
  date_captured: :one,
671
810
  date_captured_precision: :one,
672
811
  date_captured_end: :one,
@@ -674,6 +813,8 @@ module NEU
674
813
  date_captured_qualifier: :one,
675
814
  date_captured_key_date: :one,
676
815
  date_captured_text: :one,
816
+ date_captured_display_label: :one,
817
+ date_captured_event_type: :one,
677
818
  date_valid: :one,
678
819
  date_valid_precision: :one,
679
820
  date_valid_end: :one,
@@ -681,6 +822,8 @@ module NEU
681
822
  date_valid_qualifier: :one,
682
823
  date_valid_key_date: :one,
683
824
  date_valid_text: :one,
825
+ date_valid_display_label: :one,
826
+ date_valid_event_type: :one,
684
827
  date_other: :one,
685
828
  date_other_precision: :one,
686
829
  date_other_end: :one,
@@ -688,6 +831,8 @@ module NEU
688
831
  date_other_qualifier: :one,
689
832
  date_other_key_date: :one,
690
833
  date_other_text: :one,
834
+ date_other_display_label: :one,
835
+ date_other_event_type: :one,
691
836
  date_modified: :one,
692
837
  date_modified_precision: :one,
693
838
  date_modified_end: :one,
@@ -695,9 +840,12 @@ module NEU
695
840
  date_modified_qualifier: :one,
696
841
  date_modified_key_date: :one,
697
842
  date_modified_text: :one,
843
+ date_modified_display_label: :one,
844
+ date_modified_event_type: :one,
698
845
 
699
846
  # physical description
700
847
  resource_type: :many,
848
+ target_audience: :many,
701
849
  genres: :many,
702
850
  format: :many,
703
851
  extent: :many,
@@ -730,13 +878,20 @@ module NEU
730
878
  identifiers: :many,
731
879
  classification: :many,
732
880
  permanent_url: :one,
881
+ permanent_url_display_label: :one,
733
882
  record_info: :one,
734
883
  location: :many,
735
884
 
736
885
  # access
737
886
  access_condition: :one,
887
+ access_condition_display_label: :one,
888
+ access_condition_href: :one,
738
889
  use_and_reproduction: :one,
739
- restriction_on_access: :one
890
+ use_and_reproduction_display_label: :one,
891
+ use_and_reproduction_href: :one,
892
+ restriction_on_access: :one,
893
+ restriction_on_access_display_label: :one,
894
+ restriction_on_access_href: :one
740
895
  }.freeze
741
896
 
742
897
  # The complete read projection, keyed to Atlas's Metadata::MODS attribute
@@ -763,31 +918,49 @@ module NEU
763
918
  # returns. Kept as an alias rather than a rename so both read well.
764
919
  def main_title = access_title_parts
765
920
 
921
+ # What the record wants the title row headed, which is almost never set --
922
+ # but a record that does set it means it, and "Title" is the one header a
923
+ # display would otherwise never let a curator change.
924
+ def main_title_display_label = attr_value(primary_title_info, "displayLabel")
925
+
766
926
  private
767
927
 
768
928
  # --- helpers -------------------------------------------------------------
769
929
 
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.
930
+ # [DateTime, precision] for a string matching one date shape, or nil for a
931
+ # string that matches none. A shape-matched but impossible date (2026-13,
932
+ # 2026-02-30) reaches DateTime, raises, and is nil like any other
933
+ # unreadable value; the caller keeps its literal text.
774
934
  #
775
935
  # A full timestamp goes through DateTime.parse rather than being rebuilt,
776
936
  # so the time of day a dateModified declares survives. Its precision is
777
937
  # "day" because that is the finest granularity a consumer renders.
778
- def parse_w3cdtf(str)
779
- m = W3CDTF_DATE.match(str)
938
+ def parse_shaped_date(regexp, str)
939
+ m = regexp.match(str)
780
940
  return nil unless m
781
941
  return [DateTime.parse(str), "day"] if m[4]
782
942
 
783
- [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), w3cdtf_precision(m)]
943
+ [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), declared_precision(m)]
784
944
  rescue Date::Error
785
945
  nil
786
946
  end
787
947
 
948
+ # [DateTime, precision], reading the shape the record's own @encoding
949
+ # declares. w3cdtf is tried first and unconditionally, because it is what
950
+ # the corpus and Atlas's own MODS template write; the basic ISO form is
951
+ # tried only for a record that asked for it.
952
+ def parse_declared_date(str, encoding)
953
+ parse_shaped_date(W3CDTF_DATE, str) ||
954
+ (iso8601?(encoding) ? parse_shaped_date(ISO8601_BASIC_DATE, str) : nil)
955
+ end
956
+
957
+ def iso8601?(encoding)
958
+ encoding.to_s.downcase.delete("-") == ISO8601_ENCODING
959
+ end
960
+
788
961
  # The granularity the record stopped at, which is the whole point of
789
962
  # matching the shape rather than widening the parse.
790
- def w3cdtf_precision(match)
963
+ def declared_precision(match)
791
964
  return "day" if match[3]
792
965
 
793
966
  match[2] ? "month" : "year"
@@ -837,6 +1010,7 @@ module NEU
837
1010
  def date_entry(start, finish, nodes)
838
1011
  value, precision, text = node_date(start)
839
1012
  end_value, end_precision = node_date(finish)
1013
+ origin = (start || finish)&.parent
840
1014
  {
841
1015
  value: value,
842
1016
  precision: precision,
@@ -844,7 +1018,12 @@ module NEU
844
1018
  end_precision: end_precision,
845
1019
  qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
846
1020
  key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
847
- text: text
1021
+ text: text,
1022
+ # Off the enclosing originInfo, because that is where MODS puts both.
1023
+ # A date row is headed by its element ("Date created"), and these are
1024
+ # the two things a record can say to override that.
1025
+ display_label: attr_value(origin, "displayLabel"),
1026
+ event_type: attr_value(origin, "eventType")
848
1027
  }
849
1028
  end
850
1029
 
@@ -861,7 +1040,7 @@ module NEU
861
1040
  str = NEU::MODS.canonical_ws(node.text)
862
1041
  return [nil, nil, nil] if str.empty?
863
1042
 
864
- parsed = parse_w3cdtf(str)
1043
+ parsed = parse_declared_date(str, attr_value(node, "encoding"))
865
1044
  return [parsed[0], parsed[1], nil] if parsed
866
1045
 
867
1046
  [nil, nil, str]
@@ -874,6 +1053,108 @@ module NEU
874
1053
  value.empty? ? nil : value
875
1054
  end
876
1055
 
1056
+ # The two attributes a display reads off an element rather than out of its
1057
+ # text: the header the record asked for, and the link the record attached.
1058
+ # They travel together as one pair rather than as two parallel
1059
+ # projections a consumer has to zip.
1060
+ #
1061
+ # The two sets overlap rather than match. MODS 3.8 puts @displayLabel on
1062
+ # 26 elements and xlink:href on 14 -- titleInfo, name, alternativeName,
1063
+ # agent, subject, abstract, tableOfContents, note, relatedItem,
1064
+ # accessCondition, physicalLocation and three more. Reading both off
1065
+ # every element costs nothing: an element the schema does not let carry
1066
+ # one simply projects nil for it, and a consumer asking the pair of any
1067
+ # entry does not have to hold the two lists.
1068
+ #
1069
+ # An href with no text displays nothing. Every caller drops a value-less
1070
+ # element already, which is also what the librarians asked for: a link
1071
+ # needs something to hang on.
1072
+ def qualifiers_of(node)
1073
+ { display_label: attr_value(node, "displayLabel"), href: xlink_href(node) }
1074
+ end
1075
+
1076
+ # xlink:href by namespace rather than by prefix. A document is free to
1077
+ # bind the XLink namespace to any prefix, or to none, and node["xlink:href"]
1078
+ # matches the literal prefix alone.
1079
+ def xlink_href(node)
1080
+ return nil unless node
1081
+
1082
+ attribute = node.attribute_with_ns("href", XLINK_NAMESPACE)
1083
+ attribute && clean(attribute.value)
1084
+ end
1085
+
1086
+ # A displayed value plus the qualifiers of the element a display takes its
1087
+ # header from. That is not always the element holding the text: MODS puts
1088
+ # @displayLabel on originInfo and physicalDescription, never on the
1089
+ # publisher, place, extent or digitalOrigin inside them.
1090
+ def labeled(value, label_node)
1091
+ { value: value, **qualifiers_of(label_node) }
1092
+ end
1093
+
1094
+ # The qualifiers of an originInfo block. @eventType says what the block
1095
+ # records -- a publication, a production, a distribution -- and the
1096
+ # librarians asked that its value head the block when no displayLabel
1097
+ # does. MODS puts it on originInfo alone, so it is not part of the
1098
+ # general pair.
1099
+ def origin_qualifiers_of(node)
1100
+ qualifiers_of(node).merge(event_type: attr_value(node, "eventType"))
1101
+ end
1102
+
1103
+ # An originInfo child, carrying the block's header attributes. `xpath` is
1104
+ # relative to the originInfo, which is the element the qualifiers come
1105
+ # from -- MODS puts neither attribute on the children.
1106
+ def origin_texts_at(xpath)
1107
+ doc.xpath("/mods:mods/mods:originInfo", NAMESPACE).flat_map do |origin|
1108
+ origin.xpath(xpath, NAMESPACE).filter_map do |node|
1109
+ value = clean(node.text)
1110
+ { value: value, **origin_qualifiers_of(origin) } if value
1111
+ end
1112
+ end
1113
+ end
1114
+
1115
+ # The date elements the enclosing originInfo carries. A place is headed
1116
+ # "Creation place" or "Publication place" depending on which date sits
1117
+ # beside it, and the place element itself says nothing about the event.
1118
+ # Which dates are present is data; the header text is display policy and
1119
+ # stays with the consumer.
1120
+ def origin_date_elements(origin)
1121
+ return [] unless origin
1122
+
1123
+ DATE_ELEMENTS.select { |name| origin.at_xpath("mods:#{name}", NAMESPACE) }
1124
+ end
1125
+
1126
+ # #texts_at, with each value carrying the qualifiers of its element.
1127
+ # `from:` is an XPath relative to the text-bearing node, naming the
1128
+ # ancestor the header comes from instead.
1129
+ def labeled_texts_at(xpath, from: nil)
1130
+ doc.xpath(xpath, NAMESPACE).filter_map do |node|
1131
+ value = clean(node.text)
1132
+ labeled(value, from ? node.at_xpath(from, NAMESPACE) : node) if value
1133
+ end
1134
+ end
1135
+
1136
+ # The first of a node set to state the attribute. A field joining several
1137
+ # elements into one value has one header, and a record that labels only
1138
+ # its second abstract still meant the label.
1139
+ def first_attr(nodes, name)
1140
+ nodes.filter_map { |node| attr_value(node, name) }.first
1141
+ end
1142
+
1143
+ def first_href(nodes)
1144
+ nodes.filter_map { |node| xlink_href(node) }.first
1145
+ end
1146
+
1147
+ # Every top-level accessCondition, or those of one folded @type. Shared by
1148
+ # the joined text projections and by the qualifier companions, so a header
1149
+ # cannot come from a different element than the value it heads.
1150
+ def access_condition_nodes(type = nil)
1151
+ nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
1152
+ return nodes if type.nil?
1153
+
1154
+ wanted = Projection.fold_type(type)
1155
+ nodes.select { |node| Projection.fold_type(node["type"]) == wanted }
1156
+ end
1157
+
877
1158
  # Byte-faithful title parts off any titleInfo node, shared by #title_parts
878
1159
  # (which Cerberus pre-fills its edit forms from) and the variant titles.
879
1160
  def title_parts_of(node)
@@ -899,7 +1180,8 @@ module NEU
899
1180
  def variant_titles(type)
900
1181
  doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
901
1182
  parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
902
- clean(Projection.compose_title(parts))
1183
+ value = clean(Projection.compose_title(parts))
1184
+ labeled(value, node) if value
903
1185
  end
904
1186
  end
905
1187
 
@@ -936,7 +1218,7 @@ module NEU
936
1218
  end
937
1219
 
938
1220
  def related_item_titles(type)
939
- texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
1221
+ labeled_texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title", from: "../..")
940
1222
  end
941
1223
 
942
1224
  # Kept in parts rather than composed into "24(3), pp. 210-218". The
@@ -1084,10 +1366,7 @@ module NEU
1084
1366
  # A genuinely unrecognised type still falls through, which is what
1085
1367
  # #access_condition is for.
1086
1368
  def access_conditions_of_type(type)
1087
- wanted = Projection.fold_type(type)
1088
- nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
1089
- .select { |node| Projection.fold_type(node["type"]) == wanted }
1090
- join_paragraphs(nodes)
1369
+ join_paragraphs(access_condition_nodes(type))
1091
1370
  end
1092
1371
 
1093
1372
  def join_paragraphs(nodes)
@@ -1096,25 +1375,26 @@ module NEU
1096
1375
 
1097
1376
  # --- name display (faithful port of mods gem display_value_w_date) -------
1098
1377
 
1099
- def name_display_value_w_date(node)
1100
- dv = name_display_value(node)
1378
+ # Every namePart is read through #part_text, so the separators this method
1379
+ # composes are the only whitespace in the result. Read raw, an indented
1380
+ # `<namePart>\n Doe\n</namePart>` -- what a pretty-printer writes and what
1381
+ # a curator pasting from a form leaves behind -- composed as "Doe , John",
1382
+ # and the outer strip could not reach the spaces around the comma.
1383
+ def name_display_value_w_date(node, type = attr_value(node, "type"))
1384
+ dv = name_display_value(node, type)
1101
1385
  node.xpath("mods:namePart[@type='date']", NAMESPACE).each do |np|
1102
- d = np.text
1386
+ d = part_text(np)
1103
1387
  dv += ", #{d}" unless d.empty? || dv.end_with?(d)
1104
1388
  end
1105
1389
  dv = dv.sub(/\A, /, "")
1106
1390
  dv.strip.empty? ? nil : dv.strip
1107
1391
  end
1108
1392
 
1109
- def name_display_value(node)
1110
- display_form = node.at_xpath("mods:displayForm", NAMESPACE)
1111
- return display_form.text if display_form && !display_form.text.empty?
1393
+ def name_display_value(node, type)
1394
+ display_form = part_text(node.at_xpath("mods:displayForm", NAMESPACE))
1395
+ return display_form unless display_form.empty?
1112
1396
 
1113
- if node["type"] == "personal"
1114
- personal_display_value(node)
1115
- else
1116
- non_date_parts_joined(node)
1117
- end
1397
+ type == "personal" ? personal_display_value(node) : non_date_parts_joined(node)
1118
1398
  end
1119
1399
 
1120
1400
  def personal_display_value(node)
@@ -1135,9 +1415,10 @@ module NEU
1135
1415
  def append_terms_of_address(node, dv)
1136
1416
  first = true
1137
1417
  node.xpath("mods:namePart[@type='termsOfAddress']", NAMESPACE).each do |np|
1138
- next if np.text.empty?
1418
+ term = part_text(np)
1419
+ next if term.empty?
1139
1420
 
1140
- dv += first ? " #{np.text}" : ", #{np.text}"
1421
+ dv += first ? " #{term}" : ", #{term}"
1141
1422
  first = false
1142
1423
  end
1143
1424
  dv
@@ -1147,13 +1428,23 @@ module NEU
1147
1428
  # NodeSet#text (no separator) -- e.g. two `given` parts become "A.(B)". We
1148
1429
  # reproduce that (quirk included) to stay behavior-preserving.
1149
1430
  def joined_parts(node, type)
1150
- node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map(&:text).join
1431
+ node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map { |np| part_text(np) }.join
1151
1432
  end
1152
1433
 
1153
1434
  def non_date_parts_joined(node)
1154
1435
  node.xpath("mods:namePart", NAMESPACE)
1155
- .reject { |np| np["type"] == "date" || np.text.empty? }
1156
- .map(&:text).join(" ")
1436
+ .reject { |np| np["type"] == "date" }
1437
+ .map { |np| part_text(np) }
1438
+ .reject(&:empty?)
1439
+ .join(" ")
1440
+ end
1441
+
1442
+ # One name element's text, whitespace-canonicalized, "" when absent. The
1443
+ # name composition joins its parts with separators of its own, so a part
1444
+ # has to arrive without the insignificant whitespace an XML document is
1445
+ # free to carry around element content.
1446
+ def part_text(node)
1447
+ node ? NEU::MODS.canonical_ws(node.text) : ""
1157
1448
  end
1158
1449
 
1159
1450
  def name_roles(node)
@@ -1170,9 +1461,8 @@ module NEU
1170
1461
  # (NOT MARC-relator-translated -- see README). nil if neither is present.
1171
1462
  def role_term_value(role)
1172
1463
  %w[text code].each do |type|
1173
- term = role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE)
1174
- text = term&.text.to_s.strip
1175
- return text unless text.empty?
1464
+ term = part_text(role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE))
1465
+ return term unless term.empty?
1176
1466
  end
1177
1467
  nil
1178
1468
  end
data/lib/neu-mods.rb CHANGED
@@ -25,6 +25,11 @@ module NEU
25
25
  # The MODS v3 namespace, as a Nokogiri xpath namespace hash.
26
26
  NAMESPACE = { "mods" => "http://www.loc.gov/mods/v3" }.freeze
27
27
 
28
+ # XLink, which MODS uses for the @xlink:href a display hyperlinks an element
29
+ # to. Held as a bare URI rather than a prefix map because the attribute is
30
+ # read by namespace: a document may bind XLink to any prefix it likes.
31
+ XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"
32
+
28
33
  # The projected field set and its cardinality (see Projection::FIELDS),
29
34
  # surfaced here so a consumer deriving its own schema from it -- Atlas's
30
35
  # Metadata::MODS attr_json set -- reads the shared contract off the top-level
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.12.0
4
+ version: 0.13.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-08 00:00:00.000000000 Z
11
+ date: 2026-09-14 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.0.9
97
+ rubygems_version: 3.2.33
98
98
  signing_key:
99
99
  specification_version: 4
100
100
  summary: Northeastern-flavored MODS XML projection + selection for the DRS.