neu-mods 0.11.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: f29586facbe4e40077b0875aa896886a812b231058e97c12822875fd6203edef
4
- data.tar.gz: 0fc6d10f7b3a50ff8659497a2de526f0d9b394751c4b4acd09c1676338dfdb87
3
+ metadata.gz: 04a0acbc467e71ac94fb6b48eb7dbcaca01ccaa5f3c6894268f1cb689967a505
4
+ data.tar.gz: 6b799da10a7a33c6f8a27d5746317a866a856978c27b6fdca9d2b1a0b4170a62
5
5
  SHA512:
6
- metadata.gz: 5801c6878c83a3ae03eef2af7970b1d492a215705d7105002eadb12740f5af35f383e490dc4bbc02f59a723c56c99e9b9afb2399a35b0f785613abe1ff11a922
7
- data.tar.gz: 92ffead82d106913bb14ad273096397bb319290b9ce04db46c068412ce3da3cd7dc4670526c50b63413a4f70b10a145bfeef7feecc854abca0480ea3aed7c112
6
+ metadata.gz: 0edb1eeac6b75e5f94fb3eaf2ae15ada5e37d7ca245c2ed5836d502d9f27043ad81a7a7ca3f85639a25233828fae2bc89eaa629dbae0537e776a24ad34bea0f2
7
+ data.tar.gz: c944c560bd9ec49eb4b352809df9af3a1dab42d8faa2589d56b0a01c7b5bd69cecceb1ef78a86f6bfdc89b3a97ee4a94ad1ba42577231573fd4b87ebaf90db3b
data/.version CHANGED
@@ -1 +1 @@
1
- 0.11.0
1
+ 0.13.0
data/README.md CHANGED
@@ -27,16 +27,20 @@ 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
34
- doc.languages # => ["English"] a code-only <languageTerm>eng</> included
34
+ doc.languages # => [{ term: "English", object_part: nil, script: nil }, ...]
35
+ # a code-only <languageTerm>eng</> is read through the
36
+ # ISO 639 registry. @objectPart rides along because
37
+ # objectPart="subtitles" says the SUBTITLES are Spanish,
38
+ # not the resource
35
39
  doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
36
40
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
37
41
  doc.date_created_parts
38
42
  # => { value:, precision:, end_value:, end_precision:,
39
- # qualifier:, key_date: } everything the record
43
+ # qualifier:, key_date:, text: } everything the record
40
44
  # declared about one date. w3cdtf YYYY, YYYY-MM and
41
45
  # YYYY-MM-DD all parse, and the precision says which
42
46
  # shape it gave, so display cannot invent a month or a
@@ -45,24 +49,66 @@ doc.date_created_parts
45
49
  # A keyDate="yes" node chooses the value, ahead of
46
50
  # @point and document order; one date per type is the
47
51
  # rule, so an unflagged repeat is discarded.
52
+ # A value that is not a w3cdtf date projects NO date
53
+ # and keeps its literal in :text -- "19uu", "ca. 1920"
54
+ # and "undated" are statements a cataloguer made, and
55
+ # guessing a date for them is worse than either losing
56
+ # them or showing them as written.
48
57
  # Same for the other six originInfo dates --
49
58
  # date_issued, copyright_date, date_captured,
50
59
  # date_valid, date_other and date_modified. Each part is
51
60
  # also a reader of its own, e.g.
52
61
  # doc.date_created_qualifier.
53
62
  doc.place_of_publication
54
- # => ["Boston"] the type="text" placeTerm wins, so a
55
- # marccountry code does not reach a places facet as a
56
- # place name; a code-only place still projects its code
63
+ # => [{ 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).
57
94
  doc.host_collections
58
95
  # => [{ title:, volume:, issue:, start_page:, end_page:,
59
96
  # date:, text:, details: [...], extents: [...] }, ...]
60
97
  # this work's position in its host. The entry survives on
61
98
  # its part alone, so a host with no titleInfo is kept
62
- doc.notes # => [{ type: "funding", value: "..." }, ...]
63
- doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
99
+ doc.identifiers # => [{ type: "isbn", value: "...", invalid: false,
100
+ # display_label:, href: }, ...]
101
+ # @invalid means cancelled or superseded, so it travels
102
+ doc.table_of_contents
103
+ # => [{ value: "Ch 1\nCh 2", ... }]
104
+ # line breaks kept: in a contents list
105
+ # the break is the structure, not stray formatting
106
+ doc.notes # => [{ type: "funding", value: "...", display_label:, href: }, ...]
107
+ doc.related_items # => [{ type: "otherFormat", title: "...",
108
+ # display_label:, href: }, ...]
64
109
  # every relatedItem that is not a series or a host
65
- doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
110
+ doc.location # => [{ physical_location:, shelf_location:, url:,
111
+ # display_label:, href: }, ...]
66
112
  doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
67
113
  doc.title_subjects # => ["The Great Gatsby"] composed like the main title
68
114
  doc.hierarchical_geographic_subjects
@@ -83,7 +129,7 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
83
129
  # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
84
130
  NEU::MODS.compose_title(non_sort: "", title: "What's New",
85
131
  part_name: "How We Respond to Disaster", part_number: "Episode 1")
86
- # => "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)
87
133
  # The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
88
134
  # cataloguing convention, and titleInfo is an unordered choice in the schema.
89
135
 
@@ -18,6 +18,15 @@ module NEU
18
18
  str.to_s.tr(NBSP, " ").gsub(/\s+/, " ").strip
19
19
  end
20
20
 
21
+ # canonical_ws per line, keeping the line breaks. For a field where a
22
+ # newline is structure rather than formatting -- tableOfContents, where
23
+ # the break separates one entry from the next. Blank lines drop, so a
24
+ # double-spaced list does not project empty entries.
25
+ def canonical_lines(str)
26
+ str.to_s.tr(NBSP, " ").split("\n").map { |line| line.gsub(/\s+/, " ").strip }
27
+ .reject(&:empty?).join("\n")
28
+ end
29
+
21
30
  # Treat values differing only by insignificant whitespace (NBSP vs space,
22
31
  # collapsible runs, leading/trailing) as equal.
23
32
  def whitespace_equivalent?(current, incoming)
@@ -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,12 +128,36 @@ 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
+
149
+ # An open-string @type reduced to its letters and digits, so casing, word
150
+ # separators and camelCasing cannot decide whether a field matches.
151
+ def self.fold_type(str)
152
+ NEU::MODS.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
153
+ end
154
+
116
155
  # --- Subjects ------------------------------------------------------------
117
156
 
118
157
  # The editable free-text keyword set (Cerberus simple form): topics under the
119
158
  # attribute-free keyword subjects only.
120
159
  def keywords
121
- 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") }
122
161
  end
123
162
 
124
163
  # Neither child carries heading text: cartographics is a structured
@@ -137,7 +176,7 @@ module NEU
137
176
  def subject_headings
138
177
  doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
139
178
  parts = subject_heading_parts(node)
140
- { parts: parts } unless parts.empty?
179
+ { parts: parts, **qualifiers_of(node) } unless parts.empty?
141
180
  end
142
181
  end
143
182
 
@@ -202,10 +241,28 @@ module NEU
202
241
  {
203
242
  name: name_display_value_w_date(node),
204
243
  roles: name_roles(node),
205
- 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)
206
251
  }
207
252
  end
208
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
+
209
266
  # All top-level names as { name:, roles: }. `name` reproduces the `mods` gem's
210
267
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
211
268
  # display output is preserved). MODS repeats `role` on one name, and a
@@ -250,78 +307,139 @@ module NEU
250
307
 
251
308
  # --- Scalars / simple arrays --------------------------------------------
252
309
 
253
- # Prefer the type="text" term, and translate a code-only one through the
254
- # ISO 639 registry. A record saying `eng` projects "English", so the
255
- # display and the Solr language facet read the same value rather than the
256
- # facet showing codes. An unrecognised code survives as itself.
310
+ # { term:, object_part:, script: } per language element. Prefer the
311
+ # type="text" term, and translate a code-only one through the ISO 639
312
+ # registry. A record saying `eng` projects "English", so the display and
313
+ # the Solr language facet read the same value rather than the facet
314
+ # showing codes. An unrecognised code survives as itself.
315
+ #
316
+ # An entry rather than a bare string because @objectPart changes what the
317
+ # record is claiming. `<language objectPart="subtitles">spa` says the
318
+ # subtitles are Spanish, and projected flat it said the resource was --
319
+ # which is the case a captioned video hits every time. The script rides
320
+ # along for the same reason a name's role does: a consumer cannot
321
+ # recover it from the term.
257
322
  def languages
258
323
  doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
259
- text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
260
- next clean(text.text) if text
324
+ term = language_term(lang)
325
+ next unless term
261
326
 
262
- code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
263
- code && LanguageCodes.term(code)
327
+ { term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang),
328
+ **qualifiers_of(lang) }
264
329
  end
265
330
  end
266
331
 
267
332
  # MODS repeats typeOfResource, and repeats physicalDescription (and form and
268
333
  # extent within one), so all four are :many. A record that is both text and
269
334
  # a still image used to project as text alone.
270
- def resource_type = texts_at("/mods:mods/mods:typeOfResource")
271
- def format = texts_at("/mods:mods/mods:physicalDescription/mods:form")
272
- def extent = texts_at("/mods:mods/mods:physicalDescription/mods:extent")
273
- 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")
274
346
 
275
- 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")
276
350
 
277
351
  # originInfo repeats, and so do publisher and edition within one. Cerberus's
278
352
  # IPTC ingest writes the publisher from the IPTC Source field on every batch,
279
353
  # so this element was being written into the preservation XML and then read
280
354
  # back by nothing.
281
- def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
282
- 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")
283
360
 
284
361
  # Prefer the type="text" term per place, falling back to a coded one --
285
- # the pattern #role_term_value and #languages already use. Unfiltered, a
286
- # marccountry code reached the display and the Solr places facet as a
287
- # place name, so "mau" sat in the list beside Boston. A code still
288
- # projects when it is all the record gives, because dropping it would
289
- # lose the only statement the record made.
362
+ # the pattern #role_term_value and #languages already use.
363
+ #
364
+ # A bare marccountry code is the exception, and it drops. "mau" is not a
365
+ # place name, and unfiltered it reached the display and the Solr places
366
+ # facet as one, sitting in the list beside Boston. That is the call
367
+ # #geographic_code_subjects already makes for a MARC GAC code. A code
368
+ # under any other authority survives, because there the code may be the
369
+ # only statement the record made and nothing here can say it is not text.
290
370
  #
291
371
  # TODO: expand a marccountry code through a registry, as LanguageCodes
292
- # does for eng -> English. That needs a vendored code list.
372
+ # does for eng -> English. That needs a vendored code list, and would let
373
+ # this project "Massachusetts" instead of dropping the element.
374
+ MARC_COUNTRY_AUTHORITY = "marccountry"
375
+
293
376
  def place_of_publication
294
377
  doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
295
- text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
296
- next text if text
378
+ value = place_term_value(place)
379
+ next unless value
380
+
381
+ { value: value, **origin_qualifiers_of(place.parent),
382
+ date_elements: origin_date_elements(place.parent) }
383
+ end
384
+ end
297
385
 
298
- clean(place.at_xpath("mods:placeTerm", NAMESPACE)&.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]
299
395
  end
300
396
  end
301
397
 
302
- 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")
303
409
 
304
410
  # Serials. The @authority a record puts on a frequency is not projected:
305
411
  # authority handling is a question the gem defers everywhere else -- for
306
412
  # genre, subject and name -- and answering it for one field would be
307
413
  # inconsistent.
308
- def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
414
+ def frequency = origin_texts_at("mods:frequency")
415
+
416
+ # Read with its line breaks intact. A legacy contents list separates its
417
+ # entries by newline, and the whitespace collapse every other field wants
418
+ # ran the entries together into one line -- there the break IS the
419
+ # structure, not stray formatting. A "--"-separated list is unaffected.
420
+ def table_of_contents
421
+ doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
422
+ lines = NEU::MODS.canonical_lines(node.text)
423
+ labeled(lines, node) unless lines.empty?
424
+ end
425
+ end
309
426
 
310
- def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
311
- 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
312
430
 
313
431
  # A note about the object rather than about the work -- "Scanned at 600
314
432
  # dpi" belongs beside the extent, not beside a content note. Projected as
315
433
  # plain strings like its physicalDescription siblings: #notes keeps @type
316
434
  # because the type changes what a top-level note means, and nothing here
317
435
  # turns on it.
318
- 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: "..")
319
437
 
320
438
  # An LCC or DDC call number. Note this is NOT the same concept as Atlas's
321
439
  # classification_ssim, which carries a FileSet content-type vocabulary --
322
440
  # the name collision is accidental and the consumer has to pick a free
323
441
  # Solr field.
324
- def classification = texts_at("/mods:mods/mods:classification")
442
+ def classification = labeled_texts_at("/mods:mods/mods:classification")
325
443
 
326
444
  # Every top-level note, keeping its @type. The type carries meaning -- a
327
445
  # "statement of responsibility" is not a "funding" note -- so flattening
@@ -329,7 +447,7 @@ module NEU
329
447
  def notes
330
448
  doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
331
449
  value = clean(node.text)
332
- { type: clean(node["type"]), value: value } if value
450
+ { type: clean(node["type"]), value: value, **qualifiers_of(node) } if value
333
451
  end
334
452
  end
335
453
 
@@ -347,7 +465,7 @@ module NEU
347
465
  shelf_location: child_text(node, "mods:shelfLocator"),
348
466
  url: child_text(node, "mods:url")
349
467
  }
350
- entry if entry.values.any?
468
+ entry.merge(qualifiers_of(node)) if entry.values.any?
351
469
  end
352
470
  end
353
471
 
@@ -362,7 +480,8 @@ module NEU
362
480
  projection: child_text(node, "mods:projection"),
363
481
  coordinates: child_text(node, "mods:coordinates")
364
482
  }
365
- 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?
366
485
  end
367
486
  end
368
487
 
@@ -380,7 +499,7 @@ module NEU
380
499
  def host_collections
381
500
  doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
382
501
  entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
383
- entry if entry.values.any?
502
+ entry.merge(qualifiers_of(node)) if entry.values.any?
384
503
  end
385
504
  end
386
505
 
@@ -407,7 +526,7 @@ module NEU
407
526
  next if NAMED_RELATED_ITEM_TYPES.include?(type)
408
527
 
409
528
  title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
410
- { type: type, title: title } if title
529
+ { type: type, title: title, **qualifiers_of(node) } if title
411
530
  end
412
531
  end
413
532
 
@@ -417,10 +536,16 @@ module NEU
417
536
  # is a DOI, and a display cannot decide to linkify it. The same argument
418
537
  # #notes already makes for its @type, and #permanent_url already proves
419
538
  # the attribute is load-bearing by special-casing @type='hdl'.
539
+ # @invalid rides along because in MODS it means the identifier is
540
+ # cancelled, superseded or simply wrong. Projected flat, a dead ISBN read
541
+ # exactly like a live one and invited a reader to use it.
420
542
  def identifiers
421
543
  doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
422
544
  value = clean(node.text)
423
- { type: clean(node["type"]), value: value } if value
545
+ if value
546
+ { type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes",
547
+ **qualifiers_of(node) }
548
+ end
424
549
  end
425
550
  end
426
551
 
@@ -429,10 +554,14 @@ module NEU
429
554
  node && clean(node.text)
430
555
  end
431
556
 
432
- # The three w3cdtf date shapes a dateCreated may stop at: year, year-month,
433
- # or a full date. Matching the shape explicitly, rather than widening
434
- # DateTime.parse, is what lets the declared precision fall out of the parse
435
- # instead of being guessed after it.
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
+
436
565
  # The eleven children the XSD allows under hierarchicalGeographic, in the
437
566
  # order MODS lists them -- broadest first, which is also the order a
438
567
  # consumer composing a place string wants to reverse.
@@ -453,15 +582,39 @@ module NEU
453
582
  language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
454
583
  }.freeze
455
584
 
456
- W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
585
+ # The w3cdtf date shapes a date element may stop at: year, year-month, a
586
+ # full date, or a full date with a time. Matching the shape explicitly,
587
+ # rather than widening DateTime.parse, is what lets the declared precision
588
+ # fall out of the parse instead of being guessed after it.
589
+ #
590
+ # A value outside these shapes is NOT a date, and #parse_w3cdtf says so
591
+ # rather than guessing. Ruby's DateTime.parse fills the components it
592
+ # cannot find from the CURRENT date, so "19uu" -- standard MARC 008 fill,
593
+ # which the v1 corpus carries at scale -- asserted today's date at "day"
594
+ # precision, and the assertion changed daily. What the record actually
595
+ # wrote survives in the matching *_text field instead.
596
+ W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
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"
457
608
 
458
609
  # What #date_parts returns when the element is absent entirely, so an
459
610
  # absent date is distinguishable from one present and unparseable.
460
- EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
461
- end_precision: nil, qualifier: nil, key_date: nil }.freeze
611
+ EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
612
+ qualifier: nil, key_date: nil, text: nil,
613
+ display_label: nil, event_type: nil }.freeze
462
614
 
463
615
  # Everything a record declared about one originInfo date, as
464
- # { value:, precision:, end_value:, end_precision:, qualifier:, key_date: }.
616
+ # { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
617
+ # text: }.
465
618
  #
466
619
  # A date is not a scalar. Precision established that: a year-only date
467
620
  # parses to January 1st, and no consumer downstream can tell that month
@@ -474,7 +627,9 @@ module NEU
474
627
  #
475
628
  # The parts are projected as separate flat fields rather than one nested
476
629
  # value, because the value half has three consumers that need a real date
477
- # object -- a Solr sort key, a citation year and an OAI date.
630
+ # object -- a Solr sort key, a citation year and an OAI date. Those three
631
+ # are also why the literal gets its own field rather than sharing the
632
+ # value: a sort key cannot hold "ca. 1920", and a display can.
478
633
  # MODS puts seven date elements under originInfo and this reads all of
479
634
  # them. dateCaptured is when the object was digitised and dateModified is
480
635
  # when the resource changed -- preservation and cataloguing provenance,
@@ -482,6 +637,11 @@ module NEU
482
637
  # else. dateValid is the period the content holds for, and dateOther is
483
638
  # where a date fitting no other element lands, which is where a quantity
484
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
+
485
645
  def date_created_parts = date_parts("dateCreated")
486
646
  def date_issued_parts = date_parts("dateIssued")
487
647
  def copyright_date_parts = date_parts("copyrightDate")
@@ -496,6 +656,9 @@ module NEU
496
656
  def date_created_end_precision = date_created_parts[:end_precision]
497
657
  def date_created_qualifier = date_created_parts[:qualifier]
498
658
  def date_created_key_date = date_created_parts[:key_date]
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]
499
662
 
500
663
  def date_issued = date_issued_parts[:value]
501
664
  def date_issued_precision = date_issued_parts[:precision]
@@ -503,6 +666,9 @@ module NEU
503
666
  def date_issued_end_precision = date_issued_parts[:end_precision]
504
667
  def date_issued_qualifier = date_issued_parts[:qualifier]
505
668
  def date_issued_key_date = date_issued_parts[:key_date]
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]
506
672
 
507
673
  def copyright_date = copyright_date_parts[:value]
508
674
  def copyright_date_precision = copyright_date_parts[:precision]
@@ -510,6 +676,9 @@ module NEU
510
676
  def copyright_date_end_precision = copyright_date_parts[:end_precision]
511
677
  def copyright_date_qualifier = copyright_date_parts[:qualifier]
512
678
  def copyright_date_key_date = copyright_date_parts[:key_date]
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]
513
682
 
514
683
  def date_captured = date_captured_parts[:value]
515
684
  def date_captured_precision = date_captured_parts[:precision]
@@ -517,6 +686,9 @@ module NEU
517
686
  def date_captured_end_precision = date_captured_parts[:end_precision]
518
687
  def date_captured_qualifier = date_captured_parts[:qualifier]
519
688
  def date_captured_key_date = date_captured_parts[:key_date]
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]
520
692
 
521
693
  def date_valid = date_valid_parts[:value]
522
694
  def date_valid_precision = date_valid_parts[:precision]
@@ -524,6 +696,9 @@ module NEU
524
696
  def date_valid_end_precision = date_valid_parts[:end_precision]
525
697
  def date_valid_qualifier = date_valid_parts[:qualifier]
526
698
  def date_valid_key_date = date_valid_parts[:key_date]
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]
527
702
 
528
703
  def date_other = date_other_parts[:value]
529
704
  def date_other_precision = date_other_parts[:precision]
@@ -531,6 +706,9 @@ module NEU
531
706
  def date_other_end_precision = date_other_parts[:end_precision]
532
707
  def date_other_qualifier = date_other_parts[:qualifier]
533
708
  def date_other_key_date = date_other_parts[:key_date]
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]
534
712
 
535
713
  def date_modified = date_modified_parts[:value]
536
714
  def date_modified_precision = date_modified_parts[:precision]
@@ -538,6 +716,9 @@ module NEU
538
716
  def date_modified_end_precision = date_modified_parts[:end_precision]
539
717
  def date_modified_qualifier = date_modified_parts[:qualifier]
540
718
  def date_modified_key_date = date_modified_parts[:key_date]
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]
541
722
 
542
723
  # The [value, precision] pair the precision work introduced. Retained
543
724
  # because it is the documented entry point for a caller that wants both
@@ -576,6 +757,7 @@ module NEU
576
757
  FIELDS = {
577
758
  # titles
578
759
  main_title: :one,
760
+ main_title_display_label: :one,
579
761
  alternative_title: :many,
580
762
  uniform_title: :many,
581
763
  translated_title: :many,
@@ -584,14 +766,17 @@ module NEU
584
766
  names: :many,
585
767
  languages: :many,
586
768
  abstract: :one,
769
+ abstract_display_label: :one,
770
+ abstract_href: :one,
587
771
 
588
772
  # origin
589
773
  publication_information: :many,
590
774
  place_of_publication: :many,
775
+ origin_agents: :many,
591
776
  edition: :many,
592
777
  issuance: :many,
593
778
  frequency: :many,
594
- # Six rows per originInfo date, for each of the seven MODS defines.
779
+ # Seven rows per originInfo date, for each of the seven MODS defines.
595
780
  # Flat rather than one nested value, because the value half has
596
781
  # consumers that need a real date object.
597
782
  date_created: :one,
@@ -600,45 +785,67 @@ module NEU
600
785
  date_created_end_precision: :one,
601
786
  date_created_qualifier: :one,
602
787
  date_created_key_date: :one,
788
+ date_created_text: :one,
789
+ date_created_display_label: :one,
790
+ date_created_event_type: :one,
603
791
  date_issued: :one,
604
792
  date_issued_precision: :one,
605
793
  date_issued_end: :one,
606
794
  date_issued_end_precision: :one,
607
795
  date_issued_qualifier: :one,
608
796
  date_issued_key_date: :one,
797
+ date_issued_text: :one,
798
+ date_issued_display_label: :one,
799
+ date_issued_event_type: :one,
609
800
  copyright_date: :one,
610
801
  copyright_date_precision: :one,
611
802
  copyright_date_end: :one,
612
803
  copyright_date_end_precision: :one,
613
804
  copyright_date_qualifier: :one,
614
805
  copyright_date_key_date: :one,
806
+ copyright_date_text: :one,
807
+ copyright_date_display_label: :one,
808
+ copyright_date_event_type: :one,
615
809
  date_captured: :one,
616
810
  date_captured_precision: :one,
617
811
  date_captured_end: :one,
618
812
  date_captured_end_precision: :one,
619
813
  date_captured_qualifier: :one,
620
814
  date_captured_key_date: :one,
815
+ date_captured_text: :one,
816
+ date_captured_display_label: :one,
817
+ date_captured_event_type: :one,
621
818
  date_valid: :one,
622
819
  date_valid_precision: :one,
623
820
  date_valid_end: :one,
624
821
  date_valid_end_precision: :one,
625
822
  date_valid_qualifier: :one,
626
823
  date_valid_key_date: :one,
824
+ date_valid_text: :one,
825
+ date_valid_display_label: :one,
826
+ date_valid_event_type: :one,
627
827
  date_other: :one,
628
828
  date_other_precision: :one,
629
829
  date_other_end: :one,
630
830
  date_other_end_precision: :one,
631
831
  date_other_qualifier: :one,
632
832
  date_other_key_date: :one,
833
+ date_other_text: :one,
834
+ date_other_display_label: :one,
835
+ date_other_event_type: :one,
633
836
  date_modified: :one,
634
837
  date_modified_precision: :one,
635
838
  date_modified_end: :one,
636
839
  date_modified_end_precision: :one,
637
840
  date_modified_qualifier: :one,
638
841
  date_modified_key_date: :one,
842
+ date_modified_text: :one,
843
+ date_modified_display_label: :one,
844
+ date_modified_event_type: :one,
639
845
 
640
846
  # physical description
641
847
  resource_type: :many,
848
+ target_audience: :many,
642
849
  genres: :many,
643
850
  format: :many,
644
851
  extent: :many,
@@ -671,13 +878,20 @@ module NEU
671
878
  identifiers: :many,
672
879
  classification: :many,
673
880
  permanent_url: :one,
881
+ permanent_url_display_label: :one,
674
882
  record_info: :one,
675
883
  location: :many,
676
884
 
677
885
  # access
678
886
  access_condition: :one,
887
+ access_condition_display_label: :one,
888
+ access_condition_href: :one,
679
889
  use_and_reproduction: :one,
680
- 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
681
895
  }.freeze
682
896
 
683
897
  # The complete read projection, keyed to Atlas's Metadata::MODS attribute
@@ -704,28 +918,52 @@ module NEU
704
918
  # returns. Kept as an alias rather than a rename so both read well.
705
919
  def main_title = access_title_parts
706
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
+
707
926
  private
708
927
 
709
928
  # --- helpers -------------------------------------------------------------
710
929
 
711
- # A shape-matched but impossible date (2026-13, 2026-02-30) reaches DateTime
712
- # and raises; it falls to the "" sentinel like any other unparseable value.
713
- # Anything outside the three shapes keeps the old permissive parse, so a
714
- # timestamp still projects as a full date.
715
- def parse_w3cdtf(str)
716
- m = W3CDTF_DATE.match(str)
717
- return [DateTime.parse(str), "day"] unless m
718
-
719
- precision = if m[3]
720
- "day"
721
- elsif m[2]
722
- "month"
723
- else
724
- "year"
725
- end
726
- [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
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.
934
+ #
935
+ # A full timestamp goes through DateTime.parse rather than being rebuilt,
936
+ # so the time of day a dateModified declares survives. Its precision is
937
+ # "day" because that is the finest granularity a consumer renders.
938
+ def parse_shaped_date(regexp, str)
939
+ m = regexp.match(str)
940
+ return nil unless m
941
+ return [DateTime.parse(str), "day"] if m[4]
942
+
943
+ [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), declared_precision(m)]
727
944
  rescue Date::Error
728
- ["", nil]
945
+ nil
946
+ end
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
+
961
+ # The granularity the record stopped at, which is the whole point of
962
+ # matching the shape rather than widening the parse.
963
+ def declared_precision(match)
964
+ return "day" if match[3]
965
+
966
+ match[2] ? "month" : "year"
729
967
  end
730
968
 
731
969
  # One originInfo date element, read by its attributes rather than by
@@ -770,25 +1008,42 @@ module NEU
770
1008
  # itself: MODS enumerates approximate, inferred and questionable, but the
771
1009
  # record still said something.
772
1010
  def date_entry(start, finish, nodes)
773
- value, precision = node_date(start)
1011
+ value, precision, text = node_date(start)
774
1012
  end_value, end_precision = node_date(finish)
1013
+ origin = (start || finish)&.parent
775
1014
  {
776
1015
  value: value,
777
1016
  precision: precision,
778
1017
  end_value: end_value,
779
1018
  end_precision: end_precision,
780
1019
  qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
781
- key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
1020
+ key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
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")
782
1027
  }
783
1028
  end
784
1029
 
1030
+ # [value, precision, text]. A node whose text is not a w3cdtf date yields
1031
+ # no value and keeps its literal instead: "ca. 1920", "19th century" and
1032
+ # "1918-1921" in one element are all real statements a cataloguer made,
1033
+ # and a preservation repository must neither invent a date for them nor
1034
+ # delete them. Only the start node's literal is kept -- the observed
1035
+ # corpus writes an unreadable date as one element, and an end point that
1036
+ # needs its own literal has never been seen.
785
1037
  def node_date(node)
786
- return [nil, nil] unless node
1038
+ return [nil, nil, nil] unless node
787
1039
 
788
1040
  str = NEU::MODS.canonical_ws(node.text)
789
- return [nil, nil] if str.empty?
1041
+ return [nil, nil, nil] if str.empty?
1042
+
1043
+ parsed = parse_declared_date(str, attr_value(node, "encoding"))
1044
+ return [parsed[0], parsed[1], nil] if parsed
790
1045
 
791
- parse_w3cdtf(str)
1046
+ [nil, nil, str]
792
1047
  end
793
1048
 
794
1049
  def attr_value(node, name)
@@ -798,6 +1053,108 @@ module NEU
798
1053
  value.empty? ? nil : value
799
1054
  end
800
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
+
801
1158
  # Byte-faithful title parts off any titleInfo node, shared by #title_parts
802
1159
  # (which Cerberus pre-fills its edit forms from) and the variant titles.
803
1160
  def title_parts_of(node)
@@ -823,7 +1180,8 @@ module NEU
823
1180
  def variant_titles(type)
824
1181
  doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
825
1182
  parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
826
- clean(Projection.compose_title(parts))
1183
+ value = clean(Projection.compose_title(parts))
1184
+ labeled(value, node) if value
827
1185
  end
828
1186
  end
829
1187
 
@@ -860,7 +1218,7 @@ module NEU
860
1218
  end
861
1219
 
862
1220
  def related_item_titles(type)
863
- 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: "../..")
864
1222
  end
865
1223
 
866
1224
  # Kept in parts rather than composed into "24(3), pp. 210-218". The
@@ -953,6 +1311,26 @@ module NEU
953
1311
  doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
954
1312
  end
955
1313
 
1314
+ # The language of one element: the text term, or a code read through the
1315
+ # ISO 639 registry.
1316
+ def language_term(lang)
1317
+ text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
1318
+ return clean(text.text) if text
1319
+
1320
+ code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
1321
+ code && LanguageCodes.term(code)
1322
+ end
1323
+
1324
+ # A scriptTerm, text form preferred. No registry expands a code here: the
1325
+ # ISO 15924 list is not vendored, and inventing a half-translation would
1326
+ # be worse than handing the consumer what the record wrote.
1327
+ def script_term(lang)
1328
+ text = lang.at_xpath("mods:scriptTerm[@type='text']", NAMESPACE)
1329
+ return clean(text.text) if text
1330
+
1331
+ clean(lang.at_xpath("mods:scriptTerm", NAMESPACE)&.text)
1332
+ end
1333
+
956
1334
  def child_text(parent, xpath)
957
1335
  return nil unless parent
958
1336
 
@@ -977,13 +1355,18 @@ module NEU
977
1355
  NEU::MODS.canonical_ws(str)
978
1356
  end
979
1357
 
980
- # The schema leaves accessCondition/@type an open string, so match on the
981
- # canonicalised, case-folded value rather than in the XPath: real records
982
- # carry "Use and Reproduction" as readily as the MODS-recommended casing.
1358
+ # The schema leaves accessCondition/@type an open string, so match on a
1359
+ # folded key rather than in the XPath. Real records carry "Use and
1360
+ # Reproduction", "useAndReproduction" and "restriction-on-access" as
1361
+ # readily as the MODS-recommended casing, and an unmatched
1362
+ # restrictionOnAccess fell through to the generic #access_condition --
1363
+ # which is the same defect the two typed fields exist to prevent, reached
1364
+ # by a different route: a restriction presented to a reader as a licence.
1365
+ #
1366
+ # A genuinely unrecognised type still falls through, which is what
1367
+ # #access_condition is for.
983
1368
  def access_conditions_of_type(type)
984
- nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
985
- .select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
986
- join_paragraphs(nodes)
1369
+ join_paragraphs(access_condition_nodes(type))
987
1370
  end
988
1371
 
989
1372
  def join_paragraphs(nodes)
@@ -992,25 +1375,26 @@ module NEU
992
1375
 
993
1376
  # --- name display (faithful port of mods gem display_value_w_date) -------
994
1377
 
995
- def name_display_value_w_date(node)
996
- 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)
997
1385
  node.xpath("mods:namePart[@type='date']", NAMESPACE).each do |np|
998
- d = np.text
1386
+ d = part_text(np)
999
1387
  dv += ", #{d}" unless d.empty? || dv.end_with?(d)
1000
1388
  end
1001
1389
  dv = dv.sub(/\A, /, "")
1002
1390
  dv.strip.empty? ? nil : dv.strip
1003
1391
  end
1004
1392
 
1005
- def name_display_value(node)
1006
- display_form = node.at_xpath("mods:displayForm", NAMESPACE)
1007
- 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?
1008
1396
 
1009
- if node["type"] == "personal"
1010
- personal_display_value(node)
1011
- else
1012
- non_date_parts_joined(node)
1013
- end
1397
+ type == "personal" ? personal_display_value(node) : non_date_parts_joined(node)
1014
1398
  end
1015
1399
 
1016
1400
  def personal_display_value(node)
@@ -1031,9 +1415,10 @@ module NEU
1031
1415
  def append_terms_of_address(node, dv)
1032
1416
  first = true
1033
1417
  node.xpath("mods:namePart[@type='termsOfAddress']", NAMESPACE).each do |np|
1034
- next if np.text.empty?
1418
+ term = part_text(np)
1419
+ next if term.empty?
1035
1420
 
1036
- dv += first ? " #{np.text}" : ", #{np.text}"
1421
+ dv += first ? " #{term}" : ", #{term}"
1037
1422
  first = false
1038
1423
  end
1039
1424
  dv
@@ -1043,13 +1428,23 @@ module NEU
1043
1428
  # NodeSet#text (no separator) -- e.g. two `given` parts become "A.(B)". We
1044
1429
  # reproduce that (quirk included) to stay behavior-preserving.
1045
1430
  def joined_parts(node, type)
1046
- node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map(&:text).join
1431
+ node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map { |np| part_text(np) }.join
1047
1432
  end
1048
1433
 
1049
1434
  def non_date_parts_joined(node)
1050
1435
  node.xpath("mods:namePart", NAMESPACE)
1051
- .reject { |np| np["type"] == "date" || np.text.empty? }
1052
- .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) : ""
1053
1448
  end
1054
1449
 
1055
1450
  def name_roles(node)
@@ -1066,9 +1461,8 @@ module NEU
1066
1461
  # (NOT MARC-relator-translated -- see README). nil if neither is present.
1067
1462
  def role_term_value(role)
1068
1463
  %w[text code].each do |type|
1069
- term = role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE)
1070
- text = term&.text.to_s.strip
1071
- return text unless text.empty?
1464
+ term = part_text(role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE))
1465
+ return term unless term.empty?
1072
1466
  end
1073
1467
  nil
1074
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
@@ -35,6 +40,7 @@ module NEU
35
40
 
36
41
  # Whitespace no-op guard (see Canonicalize).
37
42
  def canonical_ws(str) = Canonicalize.canonical_ws(str)
43
+ def canonical_lines(str) = Canonicalize.canonical_lines(str)
38
44
  def whitespace_equivalent?(current, incoming) = Canonicalize.whitespace_equivalent?(current, incoming)
39
45
 
40
46
  # Curator-freetext normalization for the access copy (see TextNormalizer).
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.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-07 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