neu-mods 0.12.0 → 0.13.1

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