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