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