neu-mods 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/README.md +56 -10
- data/lib/neu/mods/canonicalize.rb +9 -0
- data/lib/neu/mods/projection.rb +501 -107
- data/lib/neu-mods.rb +6 -0
- metadata +2 -2
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,16 +27,20 @@ require "neu-mods"
|
|
|
27
27
|
doc = NEU::MODS::Document.parse(xml_string)
|
|
28
28
|
|
|
29
29
|
# Projection (plain data)
|
|
30
|
-
doc.plain_title # => "What's New
|
|
30
|
+
doc.plain_title # => "What's New. How We Respond to Disaster. Episode 1"
|
|
31
31
|
doc.title_parts # => { non_sort:, subtitle:, title:, part_name:, part_number: }
|
|
32
32
|
# byte-faithful -- the edit forms pre-fill from these
|
|
33
33
|
doc.abstract # => normalized, paragraph-joined String
|
|
34
|
-
doc.languages # => ["English"
|
|
34
|
+
doc.languages # => [{ term: "English", object_part: nil, script: nil }, ...]
|
|
35
|
+
# a code-only <languageTerm>eng</> is read through the
|
|
36
|
+
# ISO 639 registry. @objectPart rides along because
|
|
37
|
+
# objectPart="subtitles" says the SUBTITLES are Spanish,
|
|
38
|
+
# not the resource
|
|
35
39
|
doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
|
|
36
40
|
doc.keywords # => [...] (only the editable attribute-free keyword subjects)
|
|
37
41
|
doc.date_created_parts
|
|
38
42
|
# => { value:, precision:, end_value:, end_precision:,
|
|
39
|
-
# qualifier:, key_date: } everything the record
|
|
43
|
+
# qualifier:, key_date:, text: } everything the record
|
|
40
44
|
# declared about one date. w3cdtf YYYY, YYYY-MM and
|
|
41
45
|
# YYYY-MM-DD all parse, and the precision says which
|
|
42
46
|
# shape it gave, so display cannot invent a month or a
|
|
@@ -45,24 +49,66 @@ doc.date_created_parts
|
|
|
45
49
|
# A keyDate="yes" node chooses the value, ahead of
|
|
46
50
|
# @point and document order; one date per type is the
|
|
47
51
|
# rule, so an unflagged repeat is discarded.
|
|
52
|
+
# A value that is not a w3cdtf date projects NO date
|
|
53
|
+
# and keeps its literal in :text -- "19uu", "ca. 1920"
|
|
54
|
+
# and "undated" are statements a cataloguer made, and
|
|
55
|
+
# guessing a date for them is worse than either losing
|
|
56
|
+
# them or showing them as written.
|
|
48
57
|
# Same for the other six originInfo dates --
|
|
49
58
|
# date_issued, copyright_date, date_captured,
|
|
50
59
|
# date_valid, date_other and date_modified. Each part is
|
|
51
60
|
# also a reader of its own, e.g.
|
|
52
61
|
# doc.date_created_qualifier.
|
|
53
62
|
doc.place_of_publication
|
|
54
|
-
# => ["Boston"
|
|
55
|
-
#
|
|
56
|
-
#
|
|
63
|
+
# => [{ value: "Boston", display_label:, href: }, ...]
|
|
64
|
+
# the type="text" placeTerm wins, and a bare marccountry
|
|
65
|
+
# code drops rather than reaching a places facet as a
|
|
66
|
+
# place name. A bare code under any other authority
|
|
67
|
+
# still projects
|
|
68
|
+
|
|
69
|
+
# A name entry also carries @usage (fixed="primary" in the schema, so a record
|
|
70
|
+
# that sets it has said which name leads) and :alternative_names, the MODS 3.7
|
|
71
|
+
# alternativeName composed with the ENCLOSING name's @type.
|
|
72
|
+
|
|
73
|
+
doc.origin_agents # => [{ name:, roles:, affiliation:, display_label:, href:,
|
|
74
|
+
# event_type: }, ...]
|
|
75
|
+
# originInfo/agent, new in MODS 3.8: who performed the
|
|
76
|
+
# event the block records
|
|
77
|
+
|
|
78
|
+
# An originInfo child also carries its block's @eventType, and a place carries
|
|
79
|
+
# the NAMES of the date elements beside it (:date_elements) -- "Creation place"
|
|
80
|
+
# and "Publication place" are the same element under a different date, and the
|
|
81
|
+
# place says nothing about the event itself. Each of the seven dates gains
|
|
82
|
+
# <date>_display_label and <date>_event_type from the same block.
|
|
83
|
+
|
|
84
|
+
# Every DISPLAYED projection carries the @displayLabel and xlink:href of the
|
|
85
|
+
# element its header comes from, as { value:, display_label:, href: } -- or as
|
|
86
|
+
# two extra keys where the entry already had a shape of its own. MODS puts the
|
|
87
|
+
# pair on originInfo and physicalDescription rather than on the publisher,
|
|
88
|
+
# place, extent or digitalOrigin inside them, so those children read it off
|
|
89
|
+
# their parent. The two attribute sets overlap rather than match (26 elements
|
|
90
|
+
# take @displayLabel, 14 take xlink:href); an element the schema gives neither
|
|
91
|
+
# projects nil. The four fields that JOIN several elements into one string --
|
|
92
|
+
# abstract and the three accessCondition fields -- take companion scalars
|
|
93
|
+
# instead (doc.abstract_display_label, doc.abstract_href).
|
|
57
94
|
doc.host_collections
|
|
58
95
|
# => [{ title:, volume:, issue:, start_page:, end_page:,
|
|
59
96
|
# date:, text:, details: [...], extents: [...] }, ...]
|
|
60
97
|
# this work's position in its host. The entry survives on
|
|
61
98
|
# its part alone, so a host with no titleInfo is kept
|
|
62
|
-
doc.
|
|
63
|
-
|
|
99
|
+
doc.identifiers # => [{ type: "isbn", value: "...", invalid: false,
|
|
100
|
+
# display_label:, href: }, ...]
|
|
101
|
+
# @invalid means cancelled or superseded, so it travels
|
|
102
|
+
doc.table_of_contents
|
|
103
|
+
# => [{ value: "Ch 1\nCh 2", ... }]
|
|
104
|
+
# line breaks kept: in a contents list
|
|
105
|
+
# the break is the structure, not stray formatting
|
|
106
|
+
doc.notes # => [{ type: "funding", value: "...", display_label:, href: }, ...]
|
|
107
|
+
doc.related_items # => [{ type: "otherFormat", title: "...",
|
|
108
|
+
# display_label:, href: }, ...]
|
|
64
109
|
# every relatedItem that is not a series or a host
|
|
65
|
-
doc.location # => [{ physical_location:, shelf_location:, url
|
|
110
|
+
doc.location # => [{ physical_location:, shelf_location:, url:,
|
|
111
|
+
# display_label:, href: }, ...]
|
|
66
112
|
doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
|
|
67
113
|
doc.title_subjects # => ["The Great Gatsby"] composed like the main title
|
|
68
114
|
doc.hierarchical_geographic_subjects
|
|
@@ -83,7 +129,7 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
|
|
|
83
129
|
# the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
|
|
84
130
|
NEU::MODS.compose_title(non_sort: "", title: "What's New",
|
|
85
131
|
part_name: "How We Respond to Disaster", part_number: "Episode 1")
|
|
86
|
-
# => "What's New
|
|
132
|
+
# => "What's New. How We Respond to Disaster. Episode 1" (== doc.plain_title)
|
|
87
133
|
# The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
|
|
88
134
|
# cataloguing convention, and titleInfo is an unordered choice in the schema.
|
|
89
135
|
|
|
@@ -18,6 +18,15 @@ module NEU
|
|
|
18
18
|
str.to_s.tr(NBSP, " ").gsub(/\s+/, " ").strip
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# canonical_ws per line, keeping the line breaks. For a field where a
|
|
22
|
+
# newline is structure rather than formatting -- tableOfContents, where
|
|
23
|
+
# the break separates one entry from the next. Blank lines drop, so a
|
|
24
|
+
# double-spaced list does not project empty entries.
|
|
25
|
+
def canonical_lines(str)
|
|
26
|
+
str.to_s.tr(NBSP, " ").split("\n").map { |line| line.gsub(/\s+/, " ").strip }
|
|
27
|
+
.reject(&:empty?).join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
21
30
|
# Treat values differing only by insignificant whitespace (NBSP vs space,
|
|
22
31
|
# collapsible runs, leading/trailing) as equal.
|
|
23
32
|
def whitespace_equivalent?(current, incoming)
|
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,12 +128,36 @@ module NEU
|
|
|
113
128
|
def use_and_reproduction = access_conditions_of_type("use and reproduction")
|
|
114
129
|
def restriction_on_access = access_conditions_of_type("restriction on access")
|
|
115
130
|
|
|
131
|
+
# Companion scalars for the same reason the abstract's are: each of the
|
|
132
|
+
# three fields joins several elements into one value, and a licence URI
|
|
133
|
+
# belongs beside the licence text a reader is given.
|
|
134
|
+
def access_condition_display_label = first_attr(access_condition_nodes, "displayLabel")
|
|
135
|
+
def access_condition_href = first_href(access_condition_nodes)
|
|
136
|
+
|
|
137
|
+
def use_and_reproduction_display_label
|
|
138
|
+
first_attr(access_condition_nodes("use and reproduction"), "displayLabel")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def use_and_reproduction_href = first_href(access_condition_nodes("use and reproduction"))
|
|
142
|
+
|
|
143
|
+
def restriction_on_access_display_label
|
|
144
|
+
first_attr(access_condition_nodes("restriction on access"), "displayLabel")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def restriction_on_access_href = first_href(access_condition_nodes("restriction on access"))
|
|
148
|
+
|
|
149
|
+
# An open-string @type reduced to its letters and digits, so casing, word
|
|
150
|
+
# separators and camelCasing cannot decide whether a field matches.
|
|
151
|
+
def self.fold_type(str)
|
|
152
|
+
NEU::MODS.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
|
|
153
|
+
end
|
|
154
|
+
|
|
116
155
|
# --- Subjects ------------------------------------------------------------
|
|
117
156
|
|
|
118
157
|
# The editable free-text keyword set (Cerberus simple form): topics under the
|
|
119
158
|
# attribute-free keyword subjects only.
|
|
120
159
|
def keywords
|
|
121
|
-
keyword_subjects.flat_map { |s| s
|
|
160
|
+
keyword_subjects.flat_map { |s| texts_under(s, "mods:topic") }
|
|
122
161
|
end
|
|
123
162
|
|
|
124
163
|
# Neither child carries heading text: cartographics is a structured
|
|
@@ -137,7 +176,7 @@ module NEU
|
|
|
137
176
|
def subject_headings
|
|
138
177
|
doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
|
|
139
178
|
parts = subject_heading_parts(node)
|
|
140
|
-
{ parts: parts } unless parts.empty?
|
|
179
|
+
{ parts: parts, **qualifiers_of(node) } unless parts.empty?
|
|
141
180
|
end
|
|
142
181
|
end
|
|
143
182
|
|
|
@@ -202,10 +241,28 @@ module NEU
|
|
|
202
241
|
{
|
|
203
242
|
name: name_display_value_w_date(node),
|
|
204
243
|
roles: name_roles(node),
|
|
205
|
-
affiliation: texts_under(node, "mods:affiliation")
|
|
244
|
+
affiliation: texts_under(node, "mods:affiliation"),
|
|
245
|
+
# @usage is fixed="primary" in the schema and exists to nominate the
|
|
246
|
+
# principal name. A record that sets it has said which name leads,
|
|
247
|
+
# and without it a consumer grouping role-less names can only guess.
|
|
248
|
+
usage: attr_value(node, "usage"),
|
|
249
|
+
alternative_names: alternative_names(node),
|
|
250
|
+
**qualifiers_of(node)
|
|
206
251
|
}
|
|
207
252
|
end
|
|
208
253
|
|
|
254
|
+
# mods:alternativeName, new in MODS 3.7: a second form of the same name,
|
|
255
|
+
# not a second name. Composed with the ENCLOSING name's @type, because
|
|
256
|
+
# alternativeName carries @altType rather than @type and an alternative
|
|
257
|
+
# for a personal name is still a personal name -- read from its own
|
|
258
|
+
# attributes it would compose "Doe Jane" where the name above it
|
|
259
|
+
# composes "Doe, Jane".
|
|
260
|
+
def alternative_names(node)
|
|
261
|
+
node.xpath("mods:alternativeName", NAMESPACE).filter_map do |alt|
|
|
262
|
+
name_display_value_w_date(alt, attr_value(node, "type"))
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
209
266
|
# All top-level names as { name:, roles: }. `name` reproduces the `mods` gem's
|
|
210
267
|
# display_value_w_date (including its quirks -- faithfully, so existing Solr/
|
|
211
268
|
# display output is preserved). MODS repeats `role` on one name, and a
|
|
@@ -250,78 +307,139 @@ module NEU
|
|
|
250
307
|
|
|
251
308
|
# --- Scalars / simple arrays --------------------------------------------
|
|
252
309
|
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
# facet
|
|
310
|
+
# { term:, object_part:, script: } per language element. Prefer the
|
|
311
|
+
# type="text" term, and translate a code-only one through the ISO 639
|
|
312
|
+
# registry. A record saying `eng` projects "English", so the display and
|
|
313
|
+
# the Solr language facet read the same value rather than the facet
|
|
314
|
+
# showing codes. An unrecognised code survives as itself.
|
|
315
|
+
#
|
|
316
|
+
# An entry rather than a bare string because @objectPart changes what the
|
|
317
|
+
# record is claiming. `<language objectPart="subtitles">spa` says the
|
|
318
|
+
# subtitles are Spanish, and projected flat it said the resource was --
|
|
319
|
+
# which is the case a captioned video hits every time. The script rides
|
|
320
|
+
# along for the same reason a name's role does: a consumer cannot
|
|
321
|
+
# recover it from the term.
|
|
257
322
|
def languages
|
|
258
323
|
doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
|
|
259
|
-
|
|
260
|
-
next
|
|
324
|
+
term = language_term(lang)
|
|
325
|
+
next unless term
|
|
261
326
|
|
|
262
|
-
|
|
263
|
-
|
|
327
|
+
{ term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang),
|
|
328
|
+
**qualifiers_of(lang) }
|
|
264
329
|
end
|
|
265
330
|
end
|
|
266
331
|
|
|
267
332
|
# MODS repeats typeOfResource, and repeats physicalDescription (and form and
|
|
268
333
|
# extent within one), so all four are :many. A record that is both text and
|
|
269
334
|
# a still image used to project as text alone.
|
|
270
|
-
def resource_type =
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
335
|
+
def resource_type = labeled_texts_at("/mods:mods/mods:typeOfResource")
|
|
336
|
+
|
|
337
|
+
# MODS puts @displayLabel on physicalDescription, not on the form, extent,
|
|
338
|
+
# digitalOrigin, reformattingQuality or note inside it -- so these four
|
|
339
|
+
# take the label off their parent. `from: ".."` says which element the
|
|
340
|
+
# header comes from; the value still comes from the element itself.
|
|
341
|
+
def format = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:form", from: "..")
|
|
342
|
+
def extent = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:extent", from: "..")
|
|
343
|
+
def digital_origin = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin", from: "..")
|
|
344
|
+
|
|
345
|
+
def genres = labeled_texts_at("/mods:mods/mods:genre")
|
|
274
346
|
|
|
275
|
-
|
|
347
|
+
# Who the resource is for. The last displayed top-level element with no
|
|
348
|
+
# projection at all: a record naming its audience said so to nobody.
|
|
349
|
+
def target_audience = labeled_texts_at("/mods:mods/mods:targetAudience")
|
|
276
350
|
|
|
277
351
|
# originInfo repeats, and so do publisher and edition within one. Cerberus's
|
|
278
352
|
# IPTC ingest writes the publisher from the IPTC Source field on every batch,
|
|
279
353
|
# so this element was being written into the preservation XML and then read
|
|
280
354
|
# back by nothing.
|
|
281
|
-
|
|
282
|
-
|
|
355
|
+
# @displayLabel and @eventType sit on originInfo, not on the publisher,
|
|
356
|
+
# place, edition, issuance or frequency inside it, so each of these takes
|
|
357
|
+
# its header off the parent block.
|
|
358
|
+
def publication_information = origin_texts_at("mods:publisher")
|
|
359
|
+
def edition = origin_texts_at("mods:edition")
|
|
283
360
|
|
|
284
361
|
# Prefer the type="text" term per place, falling back to a coded one --
|
|
285
|
-
# the pattern #role_term_value and #languages already use.
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
362
|
+
# the pattern #role_term_value and #languages already use.
|
|
363
|
+
#
|
|
364
|
+
# A bare marccountry code is the exception, and it drops. "mau" is not a
|
|
365
|
+
# place name, and unfiltered it reached the display and the Solr places
|
|
366
|
+
# facet as one, sitting in the list beside Boston. That is the call
|
|
367
|
+
# #geographic_code_subjects already makes for a MARC GAC code. A code
|
|
368
|
+
# under any other authority survives, because there the code may be the
|
|
369
|
+
# only statement the record made and nothing here can say it is not text.
|
|
290
370
|
#
|
|
291
371
|
# TODO: expand a marccountry code through a registry, as LanguageCodes
|
|
292
|
-
# does for eng -> English. That needs a vendored code list
|
|
372
|
+
# does for eng -> English. That needs a vendored code list, and would let
|
|
373
|
+
# this project "Massachusetts" instead of dropping the element.
|
|
374
|
+
MARC_COUNTRY_AUTHORITY = "marccountry"
|
|
375
|
+
|
|
293
376
|
def place_of_publication
|
|
294
377
|
doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
|
|
295
|
-
|
|
296
|
-
next
|
|
378
|
+
value = place_term_value(place)
|
|
379
|
+
next unless value
|
|
380
|
+
|
|
381
|
+
{ value: value, **origin_qualifiers_of(place.parent),
|
|
382
|
+
date_elements: origin_date_elements(place.parent) }
|
|
383
|
+
end
|
|
384
|
+
end
|
|
297
385
|
|
|
298
|
-
|
|
386
|
+
# originInfo/agent, new in MODS 3.8: who performed the event the block
|
|
387
|
+
# records. Read through the same port as a top-level name, so a publisher
|
|
388
|
+
# recorded as an agent composes the way a creator does and carries its
|
|
389
|
+
# roles -- which is what a consumer heads the row with when the block
|
|
390
|
+
# states no displayLabel or eventType.
|
|
391
|
+
def origin_agents
|
|
392
|
+
doc.xpath("/mods:mods/mods:originInfo/mods:agent", NAMESPACE).filter_map do |node|
|
|
393
|
+
entry = name_entry(node)
|
|
394
|
+
entry.merge(event_type: attr_value(node.parent, "eventType")) if entry[:name]
|
|
299
395
|
end
|
|
300
396
|
end
|
|
301
397
|
|
|
302
|
-
def
|
|
398
|
+
def place_term_value(place)
|
|
399
|
+
text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
|
|
400
|
+
return text if text
|
|
401
|
+
|
|
402
|
+
code = place.at_xpath("mods:placeTerm", NAMESPACE)
|
|
403
|
+
return nil if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
|
|
404
|
+
|
|
405
|
+
clean(code&.text)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def issuance = origin_texts_at("mods:issuance")
|
|
303
409
|
|
|
304
410
|
# Serials. The @authority a record puts on a frequency is not projected:
|
|
305
411
|
# authority handling is a question the gem defers everywhere else -- for
|
|
306
412
|
# genre, subject and name -- and answering it for one field would be
|
|
307
413
|
# inconsistent.
|
|
308
|
-
def frequency =
|
|
414
|
+
def frequency = origin_texts_at("mods:frequency")
|
|
415
|
+
|
|
416
|
+
# Read with its line breaks intact. A legacy contents list separates its
|
|
417
|
+
# entries by newline, and the whitespace collapse every other field wants
|
|
418
|
+
# ran the entries together into one line -- there the break IS the
|
|
419
|
+
# structure, not stray formatting. A "--"-separated list is unaffected.
|
|
420
|
+
def table_of_contents
|
|
421
|
+
doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
|
|
422
|
+
lines = NEU::MODS.canonical_lines(node.text)
|
|
423
|
+
labeled(lines, node) unless lines.empty?
|
|
424
|
+
end
|
|
425
|
+
end
|
|
309
426
|
|
|
310
|
-
def
|
|
311
|
-
|
|
427
|
+
def reformatting_quality
|
|
428
|
+
labeled_texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality", from: "..")
|
|
429
|
+
end
|
|
312
430
|
|
|
313
431
|
# A note about the object rather than about the work -- "Scanned at 600
|
|
314
432
|
# dpi" belongs beside the extent, not beside a content note. Projected as
|
|
315
433
|
# plain strings like its physicalDescription siblings: #notes keeps @type
|
|
316
434
|
# because the type changes what a top-level note means, and nothing here
|
|
317
435
|
# turns on it.
|
|
318
|
-
def physical_description_notes =
|
|
436
|
+
def physical_description_notes = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:note", from: "..")
|
|
319
437
|
|
|
320
438
|
# An LCC or DDC call number. Note this is NOT the same concept as Atlas's
|
|
321
439
|
# classification_ssim, which carries a FileSet content-type vocabulary --
|
|
322
440
|
# the name collision is accidental and the consumer has to pick a free
|
|
323
441
|
# Solr field.
|
|
324
|
-
def classification =
|
|
442
|
+
def classification = labeled_texts_at("/mods:mods/mods:classification")
|
|
325
443
|
|
|
326
444
|
# Every top-level note, keeping its @type. The type carries meaning -- a
|
|
327
445
|
# "statement of responsibility" is not a "funding" note -- so flattening
|
|
@@ -329,7 +447,7 @@ module NEU
|
|
|
329
447
|
def notes
|
|
330
448
|
doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
|
|
331
449
|
value = clean(node.text)
|
|
332
|
-
{ type: clean(node["type"]), value: value } if value
|
|
450
|
+
{ type: clean(node["type"]), value: value, **qualifiers_of(node) } if value
|
|
333
451
|
end
|
|
334
452
|
end
|
|
335
453
|
|
|
@@ -347,7 +465,7 @@ module NEU
|
|
|
347
465
|
shelf_location: child_text(node, "mods:shelfLocator"),
|
|
348
466
|
url: child_text(node, "mods:url")
|
|
349
467
|
}
|
|
350
|
-
entry if entry.values.any?
|
|
468
|
+
entry.merge(qualifiers_of(node)) if entry.values.any?
|
|
351
469
|
end
|
|
352
470
|
end
|
|
353
471
|
|
|
@@ -362,7 +480,8 @@ module NEU
|
|
|
362
480
|
projection: child_text(node, "mods:projection"),
|
|
363
481
|
coordinates: child_text(node, "mods:coordinates")
|
|
364
482
|
}
|
|
365
|
-
|
|
483
|
+
# cartographics carries neither attribute; the enclosing subject does.
|
|
484
|
+
entry.merge(qualifiers_of(node.parent)) if entry.values.any?
|
|
366
485
|
end
|
|
367
486
|
end
|
|
368
487
|
|
|
@@ -380,7 +499,7 @@ module NEU
|
|
|
380
499
|
def host_collections
|
|
381
500
|
doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
|
|
382
501
|
entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
|
|
383
|
-
entry if entry.values.any?
|
|
502
|
+
entry.merge(qualifiers_of(node)) if entry.values.any?
|
|
384
503
|
end
|
|
385
504
|
end
|
|
386
505
|
|
|
@@ -407,7 +526,7 @@ module NEU
|
|
|
407
526
|
next if NAMED_RELATED_ITEM_TYPES.include?(type)
|
|
408
527
|
|
|
409
528
|
title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
|
|
410
|
-
{ type: type, title: title } if title
|
|
529
|
+
{ type: type, title: title, **qualifiers_of(node) } if title
|
|
411
530
|
end
|
|
412
531
|
end
|
|
413
532
|
|
|
@@ -417,10 +536,16 @@ module NEU
|
|
|
417
536
|
# is a DOI, and a display cannot decide to linkify it. The same argument
|
|
418
537
|
# #notes already makes for its @type, and #permanent_url already proves
|
|
419
538
|
# the attribute is load-bearing by special-casing @type='hdl'.
|
|
539
|
+
# @invalid rides along because in MODS it means the identifier is
|
|
540
|
+
# cancelled, superseded or simply wrong. Projected flat, a dead ISBN read
|
|
541
|
+
# exactly like a live one and invited a reader to use it.
|
|
420
542
|
def identifiers
|
|
421
543
|
doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
|
|
422
544
|
value = clean(node.text)
|
|
423
|
-
|
|
545
|
+
if value
|
|
546
|
+
{ type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes",
|
|
547
|
+
**qualifiers_of(node) }
|
|
548
|
+
end
|
|
424
549
|
end
|
|
425
550
|
end
|
|
426
551
|
|
|
@@ -429,10 +554,14 @@ module NEU
|
|
|
429
554
|
node && clean(node.text)
|
|
430
555
|
end
|
|
431
556
|
|
|
432
|
-
# The
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
557
|
+
# The handle identifier carries @displayLabel="Permanent URL" in Atlas's
|
|
558
|
+
# own MODS template, so the header a reader sees is one the record states
|
|
559
|
+
# rather than one a decorator invents. No href companion: the value is the
|
|
560
|
+
# URL.
|
|
561
|
+
def permanent_url_display_label
|
|
562
|
+
attr_value(doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE), "displayLabel")
|
|
563
|
+
end
|
|
564
|
+
|
|
436
565
|
# The eleven children the XSD allows under hierarchicalGeographic, in the
|
|
437
566
|
# order MODS lists them -- broadest first, which is also the order a
|
|
438
567
|
# consumer composing a place string wants to reverse.
|
|
@@ -453,15 +582,39 @@ module NEU
|
|
|
453
582
|
language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
|
|
454
583
|
}.freeze
|
|
455
584
|
|
|
456
|
-
|
|
585
|
+
# The w3cdtf date shapes a date element may stop at: year, year-month, a
|
|
586
|
+
# full date, or a full date with a time. Matching the shape explicitly,
|
|
587
|
+
# rather than widening DateTime.parse, is what lets the declared precision
|
|
588
|
+
# fall out of the parse instead of being guessed after it.
|
|
589
|
+
#
|
|
590
|
+
# A value outside these shapes is NOT a date, and #parse_w3cdtf says so
|
|
591
|
+
# rather than guessing. Ruby's DateTime.parse fills the components it
|
|
592
|
+
# cannot find from the CURRENT date, so "19uu" -- standard MARC 008 fill,
|
|
593
|
+
# which the v1 corpus carries at scale -- asserted today's date at "day"
|
|
594
|
+
# precision, and the assertion changed daily. What the record actually
|
|
595
|
+
# wrote survives in the matching *_text field instead.
|
|
596
|
+
W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
|
|
597
|
+
|
|
598
|
+
# ISO 8601 basic format: the same year, month and day written without the
|
|
599
|
+
# hyphens. Accepted ONLY where the record declares @encoding="iso8601",
|
|
600
|
+
# because eight bare digits are a date only because the encoding says so
|
|
601
|
+
# -- an accession number is eight digits too, and guessing is the mistake
|
|
602
|
+
# dropping the DateTime.parse fallback exists to prevent.
|
|
603
|
+
ISO8601_BASIC_DATE = /\A(\d{4})(?:(\d{2})(?:(\d{2})(T\S+)?)?)?\z/
|
|
604
|
+
|
|
605
|
+
# @encoding, folded. MODS leaves the attribute an open string and records
|
|
606
|
+
# write "iso8601" and "ISO-8601" alike.
|
|
607
|
+
ISO8601_ENCODING = "iso8601"
|
|
457
608
|
|
|
458
609
|
# What #date_parts returns when the element is absent entirely, so an
|
|
459
610
|
# absent date is distinguishable from one present and unparseable.
|
|
460
|
-
EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
|
|
461
|
-
|
|
611
|
+
EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
|
|
612
|
+
qualifier: nil, key_date: nil, text: nil,
|
|
613
|
+
display_label: nil, event_type: nil }.freeze
|
|
462
614
|
|
|
463
615
|
# Everything a record declared about one originInfo date, as
|
|
464
|
-
# { value:, precision:, end_value:, end_precision:, qualifier:, key_date
|
|
616
|
+
# { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
|
|
617
|
+
# text: }.
|
|
465
618
|
#
|
|
466
619
|
# A date is not a scalar. Precision established that: a year-only date
|
|
467
620
|
# parses to January 1st, and no consumer downstream can tell that month
|
|
@@ -474,7 +627,9 @@ module NEU
|
|
|
474
627
|
#
|
|
475
628
|
# The parts are projected as separate flat fields rather than one nested
|
|
476
629
|
# value, because the value half has three consumers that need a real date
|
|
477
|
-
# object -- a Solr sort key, a citation year and an OAI date.
|
|
630
|
+
# object -- a Solr sort key, a citation year and an OAI date. Those three
|
|
631
|
+
# are also why the literal gets its own field rather than sharing the
|
|
632
|
+
# value: a sort key cannot hold "ca. 1920", and a display can.
|
|
478
633
|
# MODS puts seven date elements under originInfo and this reads all of
|
|
479
634
|
# them. dateCaptured is when the object was digitised and dateModified is
|
|
480
635
|
# when the resource changed -- preservation and cataloguing provenance,
|
|
@@ -482,6 +637,11 @@ module NEU
|
|
|
482
637
|
# else. dateValid is the period the content holds for, and dateOther is
|
|
483
638
|
# where a date fitting no other element lands, which is where a quantity
|
|
484
639
|
# of migrated v1 date data goes.
|
|
640
|
+
# The seven date elements MODS puts under originInfo, in the order a
|
|
641
|
+
# consumer deciding a place header reads them.
|
|
642
|
+
DATE_ELEMENTS = %w[dateIssued dateCreated copyrightDate dateCaptured
|
|
643
|
+
dateValid dateOther dateModified].freeze
|
|
644
|
+
|
|
485
645
|
def date_created_parts = date_parts("dateCreated")
|
|
486
646
|
def date_issued_parts = date_parts("dateIssued")
|
|
487
647
|
def copyright_date_parts = date_parts("copyrightDate")
|
|
@@ -496,6 +656,9 @@ module NEU
|
|
|
496
656
|
def date_created_end_precision = date_created_parts[:end_precision]
|
|
497
657
|
def date_created_qualifier = date_created_parts[:qualifier]
|
|
498
658
|
def date_created_key_date = date_created_parts[:key_date]
|
|
659
|
+
def date_created_text = date_created_parts[:text]
|
|
660
|
+
def date_created_display_label = date_created_parts[:display_label]
|
|
661
|
+
def date_created_event_type = date_created_parts[:event_type]
|
|
499
662
|
|
|
500
663
|
def date_issued = date_issued_parts[:value]
|
|
501
664
|
def date_issued_precision = date_issued_parts[:precision]
|
|
@@ -503,6 +666,9 @@ module NEU
|
|
|
503
666
|
def date_issued_end_precision = date_issued_parts[:end_precision]
|
|
504
667
|
def date_issued_qualifier = date_issued_parts[:qualifier]
|
|
505
668
|
def date_issued_key_date = date_issued_parts[:key_date]
|
|
669
|
+
def date_issued_text = date_issued_parts[:text]
|
|
670
|
+
def date_issued_display_label = date_issued_parts[:display_label]
|
|
671
|
+
def date_issued_event_type = date_issued_parts[:event_type]
|
|
506
672
|
|
|
507
673
|
def copyright_date = copyright_date_parts[:value]
|
|
508
674
|
def copyright_date_precision = copyright_date_parts[:precision]
|
|
@@ -510,6 +676,9 @@ module NEU
|
|
|
510
676
|
def copyright_date_end_precision = copyright_date_parts[:end_precision]
|
|
511
677
|
def copyright_date_qualifier = copyright_date_parts[:qualifier]
|
|
512
678
|
def copyright_date_key_date = copyright_date_parts[:key_date]
|
|
679
|
+
def copyright_date_text = copyright_date_parts[:text]
|
|
680
|
+
def copyright_date_display_label = copyright_date_parts[:display_label]
|
|
681
|
+
def copyright_date_event_type = copyright_date_parts[:event_type]
|
|
513
682
|
|
|
514
683
|
def date_captured = date_captured_parts[:value]
|
|
515
684
|
def date_captured_precision = date_captured_parts[:precision]
|
|
@@ -517,6 +686,9 @@ module NEU
|
|
|
517
686
|
def date_captured_end_precision = date_captured_parts[:end_precision]
|
|
518
687
|
def date_captured_qualifier = date_captured_parts[:qualifier]
|
|
519
688
|
def date_captured_key_date = date_captured_parts[:key_date]
|
|
689
|
+
def date_captured_text = date_captured_parts[:text]
|
|
690
|
+
def date_captured_display_label = date_captured_parts[:display_label]
|
|
691
|
+
def date_captured_event_type = date_captured_parts[:event_type]
|
|
520
692
|
|
|
521
693
|
def date_valid = date_valid_parts[:value]
|
|
522
694
|
def date_valid_precision = date_valid_parts[:precision]
|
|
@@ -524,6 +696,9 @@ module NEU
|
|
|
524
696
|
def date_valid_end_precision = date_valid_parts[:end_precision]
|
|
525
697
|
def date_valid_qualifier = date_valid_parts[:qualifier]
|
|
526
698
|
def date_valid_key_date = date_valid_parts[:key_date]
|
|
699
|
+
def date_valid_text = date_valid_parts[:text]
|
|
700
|
+
def date_valid_display_label = date_valid_parts[:display_label]
|
|
701
|
+
def date_valid_event_type = date_valid_parts[:event_type]
|
|
527
702
|
|
|
528
703
|
def date_other = date_other_parts[:value]
|
|
529
704
|
def date_other_precision = date_other_parts[:precision]
|
|
@@ -531,6 +706,9 @@ module NEU
|
|
|
531
706
|
def date_other_end_precision = date_other_parts[:end_precision]
|
|
532
707
|
def date_other_qualifier = date_other_parts[:qualifier]
|
|
533
708
|
def date_other_key_date = date_other_parts[:key_date]
|
|
709
|
+
def date_other_text = date_other_parts[:text]
|
|
710
|
+
def date_other_display_label = date_other_parts[:display_label]
|
|
711
|
+
def date_other_event_type = date_other_parts[:event_type]
|
|
534
712
|
|
|
535
713
|
def date_modified = date_modified_parts[:value]
|
|
536
714
|
def date_modified_precision = date_modified_parts[:precision]
|
|
@@ -538,6 +716,9 @@ module NEU
|
|
|
538
716
|
def date_modified_end_precision = date_modified_parts[:end_precision]
|
|
539
717
|
def date_modified_qualifier = date_modified_parts[:qualifier]
|
|
540
718
|
def date_modified_key_date = date_modified_parts[:key_date]
|
|
719
|
+
def date_modified_text = date_modified_parts[:text]
|
|
720
|
+
def date_modified_display_label = date_modified_parts[:display_label]
|
|
721
|
+
def date_modified_event_type = date_modified_parts[:event_type]
|
|
541
722
|
|
|
542
723
|
# The [value, precision] pair the precision work introduced. Retained
|
|
543
724
|
# because it is the documented entry point for a caller that wants both
|
|
@@ -576,6 +757,7 @@ module NEU
|
|
|
576
757
|
FIELDS = {
|
|
577
758
|
# titles
|
|
578
759
|
main_title: :one,
|
|
760
|
+
main_title_display_label: :one,
|
|
579
761
|
alternative_title: :many,
|
|
580
762
|
uniform_title: :many,
|
|
581
763
|
translated_title: :many,
|
|
@@ -584,14 +766,17 @@ module NEU
|
|
|
584
766
|
names: :many,
|
|
585
767
|
languages: :many,
|
|
586
768
|
abstract: :one,
|
|
769
|
+
abstract_display_label: :one,
|
|
770
|
+
abstract_href: :one,
|
|
587
771
|
|
|
588
772
|
# origin
|
|
589
773
|
publication_information: :many,
|
|
590
774
|
place_of_publication: :many,
|
|
775
|
+
origin_agents: :many,
|
|
591
776
|
edition: :many,
|
|
592
777
|
issuance: :many,
|
|
593
778
|
frequency: :many,
|
|
594
|
-
#
|
|
779
|
+
# Seven rows per originInfo date, for each of the seven MODS defines.
|
|
595
780
|
# Flat rather than one nested value, because the value half has
|
|
596
781
|
# consumers that need a real date object.
|
|
597
782
|
date_created: :one,
|
|
@@ -600,45 +785,67 @@ module NEU
|
|
|
600
785
|
date_created_end_precision: :one,
|
|
601
786
|
date_created_qualifier: :one,
|
|
602
787
|
date_created_key_date: :one,
|
|
788
|
+
date_created_text: :one,
|
|
789
|
+
date_created_display_label: :one,
|
|
790
|
+
date_created_event_type: :one,
|
|
603
791
|
date_issued: :one,
|
|
604
792
|
date_issued_precision: :one,
|
|
605
793
|
date_issued_end: :one,
|
|
606
794
|
date_issued_end_precision: :one,
|
|
607
795
|
date_issued_qualifier: :one,
|
|
608
796
|
date_issued_key_date: :one,
|
|
797
|
+
date_issued_text: :one,
|
|
798
|
+
date_issued_display_label: :one,
|
|
799
|
+
date_issued_event_type: :one,
|
|
609
800
|
copyright_date: :one,
|
|
610
801
|
copyright_date_precision: :one,
|
|
611
802
|
copyright_date_end: :one,
|
|
612
803
|
copyright_date_end_precision: :one,
|
|
613
804
|
copyright_date_qualifier: :one,
|
|
614
805
|
copyright_date_key_date: :one,
|
|
806
|
+
copyright_date_text: :one,
|
|
807
|
+
copyright_date_display_label: :one,
|
|
808
|
+
copyright_date_event_type: :one,
|
|
615
809
|
date_captured: :one,
|
|
616
810
|
date_captured_precision: :one,
|
|
617
811
|
date_captured_end: :one,
|
|
618
812
|
date_captured_end_precision: :one,
|
|
619
813
|
date_captured_qualifier: :one,
|
|
620
814
|
date_captured_key_date: :one,
|
|
815
|
+
date_captured_text: :one,
|
|
816
|
+
date_captured_display_label: :one,
|
|
817
|
+
date_captured_event_type: :one,
|
|
621
818
|
date_valid: :one,
|
|
622
819
|
date_valid_precision: :one,
|
|
623
820
|
date_valid_end: :one,
|
|
624
821
|
date_valid_end_precision: :one,
|
|
625
822
|
date_valid_qualifier: :one,
|
|
626
823
|
date_valid_key_date: :one,
|
|
824
|
+
date_valid_text: :one,
|
|
825
|
+
date_valid_display_label: :one,
|
|
826
|
+
date_valid_event_type: :one,
|
|
627
827
|
date_other: :one,
|
|
628
828
|
date_other_precision: :one,
|
|
629
829
|
date_other_end: :one,
|
|
630
830
|
date_other_end_precision: :one,
|
|
631
831
|
date_other_qualifier: :one,
|
|
632
832
|
date_other_key_date: :one,
|
|
833
|
+
date_other_text: :one,
|
|
834
|
+
date_other_display_label: :one,
|
|
835
|
+
date_other_event_type: :one,
|
|
633
836
|
date_modified: :one,
|
|
634
837
|
date_modified_precision: :one,
|
|
635
838
|
date_modified_end: :one,
|
|
636
839
|
date_modified_end_precision: :one,
|
|
637
840
|
date_modified_qualifier: :one,
|
|
638
841
|
date_modified_key_date: :one,
|
|
842
|
+
date_modified_text: :one,
|
|
843
|
+
date_modified_display_label: :one,
|
|
844
|
+
date_modified_event_type: :one,
|
|
639
845
|
|
|
640
846
|
# physical description
|
|
641
847
|
resource_type: :many,
|
|
848
|
+
target_audience: :many,
|
|
642
849
|
genres: :many,
|
|
643
850
|
format: :many,
|
|
644
851
|
extent: :many,
|
|
@@ -671,13 +878,20 @@ module NEU
|
|
|
671
878
|
identifiers: :many,
|
|
672
879
|
classification: :many,
|
|
673
880
|
permanent_url: :one,
|
|
881
|
+
permanent_url_display_label: :one,
|
|
674
882
|
record_info: :one,
|
|
675
883
|
location: :many,
|
|
676
884
|
|
|
677
885
|
# access
|
|
678
886
|
access_condition: :one,
|
|
887
|
+
access_condition_display_label: :one,
|
|
888
|
+
access_condition_href: :one,
|
|
679
889
|
use_and_reproduction: :one,
|
|
680
|
-
|
|
890
|
+
use_and_reproduction_display_label: :one,
|
|
891
|
+
use_and_reproduction_href: :one,
|
|
892
|
+
restriction_on_access: :one,
|
|
893
|
+
restriction_on_access_display_label: :one,
|
|
894
|
+
restriction_on_access_href: :one
|
|
681
895
|
}.freeze
|
|
682
896
|
|
|
683
897
|
# The complete read projection, keyed to Atlas's Metadata::MODS attribute
|
|
@@ -704,28 +918,52 @@ module NEU
|
|
|
704
918
|
# returns. Kept as an alias rather than a rename so both read well.
|
|
705
919
|
def main_title = access_title_parts
|
|
706
920
|
|
|
921
|
+
# What the record wants the title row headed, which is almost never set --
|
|
922
|
+
# but a record that does set it means it, and "Title" is the one header a
|
|
923
|
+
# display would otherwise never let a curator change.
|
|
924
|
+
def main_title_display_label = attr_value(primary_title_info, "displayLabel")
|
|
925
|
+
|
|
707
926
|
private
|
|
708
927
|
|
|
709
928
|
# --- helpers -------------------------------------------------------------
|
|
710
929
|
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
end
|
|
726
|
-
[DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
|
|
930
|
+
# [DateTime, precision] for a string matching one date shape, or nil for a
|
|
931
|
+
# string that matches none. A shape-matched but impossible date (2026-13,
|
|
932
|
+
# 2026-02-30) reaches DateTime, raises, and is nil like any other
|
|
933
|
+
# unreadable value; the caller keeps its literal text.
|
|
934
|
+
#
|
|
935
|
+
# A full timestamp goes through DateTime.parse rather than being rebuilt,
|
|
936
|
+
# so the time of day a dateModified declares survives. Its precision is
|
|
937
|
+
# "day" because that is the finest granularity a consumer renders.
|
|
938
|
+
def parse_shaped_date(regexp, str)
|
|
939
|
+
m = regexp.match(str)
|
|
940
|
+
return nil unless m
|
|
941
|
+
return [DateTime.parse(str), "day"] if m[4]
|
|
942
|
+
|
|
943
|
+
[DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), declared_precision(m)]
|
|
727
944
|
rescue Date::Error
|
|
728
|
-
|
|
945
|
+
nil
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# [DateTime, precision], reading the shape the record's own @encoding
|
|
949
|
+
# declares. w3cdtf is tried first and unconditionally, because it is what
|
|
950
|
+
# the corpus and Atlas's own MODS template write; the basic ISO form is
|
|
951
|
+
# tried only for a record that asked for it.
|
|
952
|
+
def parse_declared_date(str, encoding)
|
|
953
|
+
parse_shaped_date(W3CDTF_DATE, str) ||
|
|
954
|
+
(iso8601?(encoding) ? parse_shaped_date(ISO8601_BASIC_DATE, str) : nil)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def iso8601?(encoding)
|
|
958
|
+
encoding.to_s.downcase.delete("-") == ISO8601_ENCODING
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# The granularity the record stopped at, which is the whole point of
|
|
962
|
+
# matching the shape rather than widening the parse.
|
|
963
|
+
def declared_precision(match)
|
|
964
|
+
return "day" if match[3]
|
|
965
|
+
|
|
966
|
+
match[2] ? "month" : "year"
|
|
729
967
|
end
|
|
730
968
|
|
|
731
969
|
# One originInfo date element, read by its attributes rather than by
|
|
@@ -770,25 +1008,42 @@ module NEU
|
|
|
770
1008
|
# itself: MODS enumerates approximate, inferred and questionable, but the
|
|
771
1009
|
# record still said something.
|
|
772
1010
|
def date_entry(start, finish, nodes)
|
|
773
|
-
value, precision = node_date(start)
|
|
1011
|
+
value, precision, text = node_date(start)
|
|
774
1012
|
end_value, end_precision = node_date(finish)
|
|
1013
|
+
origin = (start || finish)&.parent
|
|
775
1014
|
{
|
|
776
1015
|
value: value,
|
|
777
1016
|
precision: precision,
|
|
778
1017
|
end_value: end_value,
|
|
779
1018
|
end_precision: end_precision,
|
|
780
1019
|
qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
|
|
781
|
-
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
|
|
1020
|
+
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
|
|
1021
|
+
text: text,
|
|
1022
|
+
# Off the enclosing originInfo, because that is where MODS puts both.
|
|
1023
|
+
# A date row is headed by its element ("Date created"), and these are
|
|
1024
|
+
# the two things a record can say to override that.
|
|
1025
|
+
display_label: attr_value(origin, "displayLabel"),
|
|
1026
|
+
event_type: attr_value(origin, "eventType")
|
|
782
1027
|
}
|
|
783
1028
|
end
|
|
784
1029
|
|
|
1030
|
+
# [value, precision, text]. A node whose text is not a w3cdtf date yields
|
|
1031
|
+
# no value and keeps its literal instead: "ca. 1920", "19th century" and
|
|
1032
|
+
# "1918-1921" in one element are all real statements a cataloguer made,
|
|
1033
|
+
# and a preservation repository must neither invent a date for them nor
|
|
1034
|
+
# delete them. Only the start node's literal is kept -- the observed
|
|
1035
|
+
# corpus writes an unreadable date as one element, and an end point that
|
|
1036
|
+
# needs its own literal has never been seen.
|
|
785
1037
|
def node_date(node)
|
|
786
|
-
return [nil, nil] unless node
|
|
1038
|
+
return [nil, nil, nil] unless node
|
|
787
1039
|
|
|
788
1040
|
str = NEU::MODS.canonical_ws(node.text)
|
|
789
|
-
return [nil, nil] if str.empty?
|
|
1041
|
+
return [nil, nil, nil] if str.empty?
|
|
1042
|
+
|
|
1043
|
+
parsed = parse_declared_date(str, attr_value(node, "encoding"))
|
|
1044
|
+
return [parsed[0], parsed[1], nil] if parsed
|
|
790
1045
|
|
|
791
|
-
|
|
1046
|
+
[nil, nil, str]
|
|
792
1047
|
end
|
|
793
1048
|
|
|
794
1049
|
def attr_value(node, name)
|
|
@@ -798,6 +1053,108 @@ module NEU
|
|
|
798
1053
|
value.empty? ? nil : value
|
|
799
1054
|
end
|
|
800
1055
|
|
|
1056
|
+
# The two attributes a display reads off an element rather than out of its
|
|
1057
|
+
# text: the header the record asked for, and the link the record attached.
|
|
1058
|
+
# They travel together as one pair rather than as two parallel
|
|
1059
|
+
# projections a consumer has to zip.
|
|
1060
|
+
#
|
|
1061
|
+
# The two sets overlap rather than match. MODS 3.8 puts @displayLabel on
|
|
1062
|
+
# 26 elements and xlink:href on 14 -- titleInfo, name, alternativeName,
|
|
1063
|
+
# agent, subject, abstract, tableOfContents, note, relatedItem,
|
|
1064
|
+
# accessCondition, physicalLocation and three more. Reading both off
|
|
1065
|
+
# every element costs nothing: an element the schema does not let carry
|
|
1066
|
+
# one simply projects nil for it, and a consumer asking the pair of any
|
|
1067
|
+
# entry does not have to hold the two lists.
|
|
1068
|
+
#
|
|
1069
|
+
# An href with no text displays nothing. Every caller drops a value-less
|
|
1070
|
+
# element already, which is also what the librarians asked for: a link
|
|
1071
|
+
# needs something to hang on.
|
|
1072
|
+
def qualifiers_of(node)
|
|
1073
|
+
{ display_label: attr_value(node, "displayLabel"), href: xlink_href(node) }
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
# xlink:href by namespace rather than by prefix. A document is free to
|
|
1077
|
+
# bind the XLink namespace to any prefix, or to none, and node["xlink:href"]
|
|
1078
|
+
# matches the literal prefix alone.
|
|
1079
|
+
def xlink_href(node)
|
|
1080
|
+
return nil unless node
|
|
1081
|
+
|
|
1082
|
+
attribute = node.attribute_with_ns("href", XLINK_NAMESPACE)
|
|
1083
|
+
attribute && clean(attribute.value)
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# A displayed value plus the qualifiers of the element a display takes its
|
|
1087
|
+
# header from. That is not always the element holding the text: MODS puts
|
|
1088
|
+
# @displayLabel on originInfo and physicalDescription, never on the
|
|
1089
|
+
# publisher, place, extent or digitalOrigin inside them.
|
|
1090
|
+
def labeled(value, label_node)
|
|
1091
|
+
{ value: value, **qualifiers_of(label_node) }
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
# The qualifiers of an originInfo block. @eventType says what the block
|
|
1095
|
+
# records -- a publication, a production, a distribution -- and the
|
|
1096
|
+
# librarians asked that its value head the block when no displayLabel
|
|
1097
|
+
# does. MODS puts it on originInfo alone, so it is not part of the
|
|
1098
|
+
# general pair.
|
|
1099
|
+
def origin_qualifiers_of(node)
|
|
1100
|
+
qualifiers_of(node).merge(event_type: attr_value(node, "eventType"))
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# An originInfo child, carrying the block's header attributes. `xpath` is
|
|
1104
|
+
# relative to the originInfo, which is the element the qualifiers come
|
|
1105
|
+
# from -- MODS puts neither attribute on the children.
|
|
1106
|
+
def origin_texts_at(xpath)
|
|
1107
|
+
doc.xpath("/mods:mods/mods:originInfo", NAMESPACE).flat_map do |origin|
|
|
1108
|
+
origin.xpath(xpath, NAMESPACE).filter_map do |node|
|
|
1109
|
+
value = clean(node.text)
|
|
1110
|
+
{ value: value, **origin_qualifiers_of(origin) } if value
|
|
1111
|
+
end
|
|
1112
|
+
end
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# The date elements the enclosing originInfo carries. A place is headed
|
|
1116
|
+
# "Creation place" or "Publication place" depending on which date sits
|
|
1117
|
+
# beside it, and the place element itself says nothing about the event.
|
|
1118
|
+
# Which dates are present is data; the header text is display policy and
|
|
1119
|
+
# stays with the consumer.
|
|
1120
|
+
def origin_date_elements(origin)
|
|
1121
|
+
return [] unless origin
|
|
1122
|
+
|
|
1123
|
+
DATE_ELEMENTS.select { |name| origin.at_xpath("mods:#{name}", NAMESPACE) }
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
# #texts_at, with each value carrying the qualifiers of its element.
|
|
1127
|
+
# `from:` is an XPath relative to the text-bearing node, naming the
|
|
1128
|
+
# ancestor the header comes from instead.
|
|
1129
|
+
def labeled_texts_at(xpath, from: nil)
|
|
1130
|
+
doc.xpath(xpath, NAMESPACE).filter_map do |node|
|
|
1131
|
+
value = clean(node.text)
|
|
1132
|
+
labeled(value, from ? node.at_xpath(from, NAMESPACE) : node) if value
|
|
1133
|
+
end
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
# The first of a node set to state the attribute. A field joining several
|
|
1137
|
+
# elements into one value has one header, and a record that labels only
|
|
1138
|
+
# its second abstract still meant the label.
|
|
1139
|
+
def first_attr(nodes, name)
|
|
1140
|
+
nodes.filter_map { |node| attr_value(node, name) }.first
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
def first_href(nodes)
|
|
1144
|
+
nodes.filter_map { |node| xlink_href(node) }.first
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
# Every top-level accessCondition, or those of one folded @type. Shared by
|
|
1148
|
+
# the joined text projections and by the qualifier companions, so a header
|
|
1149
|
+
# cannot come from a different element than the value it heads.
|
|
1150
|
+
def access_condition_nodes(type = nil)
|
|
1151
|
+
nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
|
|
1152
|
+
return nodes if type.nil?
|
|
1153
|
+
|
|
1154
|
+
wanted = Projection.fold_type(type)
|
|
1155
|
+
nodes.select { |node| Projection.fold_type(node["type"]) == wanted }
|
|
1156
|
+
end
|
|
1157
|
+
|
|
801
1158
|
# Byte-faithful title parts off any titleInfo node, shared by #title_parts
|
|
802
1159
|
# (which Cerberus pre-fills its edit forms from) and the variant titles.
|
|
803
1160
|
def title_parts_of(node)
|
|
@@ -823,7 +1180,8 @@ module NEU
|
|
|
823
1180
|
def variant_titles(type)
|
|
824
1181
|
doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
|
|
825
1182
|
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
826
|
-
clean(Projection.compose_title(parts))
|
|
1183
|
+
value = clean(Projection.compose_title(parts))
|
|
1184
|
+
labeled(value, node) if value
|
|
827
1185
|
end
|
|
828
1186
|
end
|
|
829
1187
|
|
|
@@ -860,7 +1218,7 @@ module NEU
|
|
|
860
1218
|
end
|
|
861
1219
|
|
|
862
1220
|
def related_item_titles(type)
|
|
863
|
-
|
|
1221
|
+
labeled_texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title", from: "../..")
|
|
864
1222
|
end
|
|
865
1223
|
|
|
866
1224
|
# Kept in parts rather than composed into "24(3), pp. 210-218". The
|
|
@@ -953,6 +1311,26 @@ module NEU
|
|
|
953
1311
|
doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
|
|
954
1312
|
end
|
|
955
1313
|
|
|
1314
|
+
# The language of one element: the text term, or a code read through the
|
|
1315
|
+
# ISO 639 registry.
|
|
1316
|
+
def language_term(lang)
|
|
1317
|
+
text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
|
|
1318
|
+
return clean(text.text) if text
|
|
1319
|
+
|
|
1320
|
+
code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
|
|
1321
|
+
code && LanguageCodes.term(code)
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
# A scriptTerm, text form preferred. No registry expands a code here: the
|
|
1325
|
+
# ISO 15924 list is not vendored, and inventing a half-translation would
|
|
1326
|
+
# be worse than handing the consumer what the record wrote.
|
|
1327
|
+
def script_term(lang)
|
|
1328
|
+
text = lang.at_xpath("mods:scriptTerm[@type='text']", NAMESPACE)
|
|
1329
|
+
return clean(text.text) if text
|
|
1330
|
+
|
|
1331
|
+
clean(lang.at_xpath("mods:scriptTerm", NAMESPACE)&.text)
|
|
1332
|
+
end
|
|
1333
|
+
|
|
956
1334
|
def child_text(parent, xpath)
|
|
957
1335
|
return nil unless parent
|
|
958
1336
|
|
|
@@ -977,13 +1355,18 @@ module NEU
|
|
|
977
1355
|
NEU::MODS.canonical_ws(str)
|
|
978
1356
|
end
|
|
979
1357
|
|
|
980
|
-
# The schema leaves accessCondition/@type an open string, so match on
|
|
981
|
-
#
|
|
982
|
-
#
|
|
1358
|
+
# The schema leaves accessCondition/@type an open string, so match on a
|
|
1359
|
+
# folded key rather than in the XPath. Real records carry "Use and
|
|
1360
|
+
# Reproduction", "useAndReproduction" and "restriction-on-access" as
|
|
1361
|
+
# readily as the MODS-recommended casing, and an unmatched
|
|
1362
|
+
# restrictionOnAccess fell through to the generic #access_condition --
|
|
1363
|
+
# which is the same defect the two typed fields exist to prevent, reached
|
|
1364
|
+
# by a different route: a restriction presented to a reader as a licence.
|
|
1365
|
+
#
|
|
1366
|
+
# A genuinely unrecognised type still falls through, which is what
|
|
1367
|
+
# #access_condition is for.
|
|
983
1368
|
def access_conditions_of_type(type)
|
|
984
|
-
|
|
985
|
-
.select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
|
|
986
|
-
join_paragraphs(nodes)
|
|
1369
|
+
join_paragraphs(access_condition_nodes(type))
|
|
987
1370
|
end
|
|
988
1371
|
|
|
989
1372
|
def join_paragraphs(nodes)
|
|
@@ -992,25 +1375,26 @@ module NEU
|
|
|
992
1375
|
|
|
993
1376
|
# --- name display (faithful port of mods gem display_value_w_date) -------
|
|
994
1377
|
|
|
995
|
-
|
|
996
|
-
|
|
1378
|
+
# Every namePart is read through #part_text, so the separators this method
|
|
1379
|
+
# composes are the only whitespace in the result. Read raw, an indented
|
|
1380
|
+
# `<namePart>\n Doe\n</namePart>` -- what a pretty-printer writes and what
|
|
1381
|
+
# a curator pasting from a form leaves behind -- composed as "Doe , John",
|
|
1382
|
+
# and the outer strip could not reach the spaces around the comma.
|
|
1383
|
+
def name_display_value_w_date(node, type = attr_value(node, "type"))
|
|
1384
|
+
dv = name_display_value(node, type)
|
|
997
1385
|
node.xpath("mods:namePart[@type='date']", NAMESPACE).each do |np|
|
|
998
|
-
d = np
|
|
1386
|
+
d = part_text(np)
|
|
999
1387
|
dv += ", #{d}" unless d.empty? || dv.end_with?(d)
|
|
1000
1388
|
end
|
|
1001
1389
|
dv = dv.sub(/\A, /, "")
|
|
1002
1390
|
dv.strip.empty? ? nil : dv.strip
|
|
1003
1391
|
end
|
|
1004
1392
|
|
|
1005
|
-
def name_display_value(node)
|
|
1006
|
-
display_form = node.at_xpath("mods:displayForm", NAMESPACE)
|
|
1007
|
-
return display_form
|
|
1393
|
+
def name_display_value(node, type)
|
|
1394
|
+
display_form = part_text(node.at_xpath("mods:displayForm", NAMESPACE))
|
|
1395
|
+
return display_form unless display_form.empty?
|
|
1008
1396
|
|
|
1009
|
-
|
|
1010
|
-
personal_display_value(node)
|
|
1011
|
-
else
|
|
1012
|
-
non_date_parts_joined(node)
|
|
1013
|
-
end
|
|
1397
|
+
type == "personal" ? personal_display_value(node) : non_date_parts_joined(node)
|
|
1014
1398
|
end
|
|
1015
1399
|
|
|
1016
1400
|
def personal_display_value(node)
|
|
@@ -1031,9 +1415,10 @@ module NEU
|
|
|
1031
1415
|
def append_terms_of_address(node, dv)
|
|
1032
1416
|
first = true
|
|
1033
1417
|
node.xpath("mods:namePart[@type='termsOfAddress']", NAMESPACE).each do |np|
|
|
1034
|
-
|
|
1418
|
+
term = part_text(np)
|
|
1419
|
+
next if term.empty?
|
|
1035
1420
|
|
|
1036
|
-
dv += first ? " #{
|
|
1421
|
+
dv += first ? " #{term}" : ", #{term}"
|
|
1037
1422
|
first = false
|
|
1038
1423
|
end
|
|
1039
1424
|
dv
|
|
@@ -1043,13 +1428,23 @@ module NEU
|
|
|
1043
1428
|
# NodeSet#text (no separator) -- e.g. two `given` parts become "A.(B)". We
|
|
1044
1429
|
# reproduce that (quirk included) to stay behavior-preserving.
|
|
1045
1430
|
def joined_parts(node, type)
|
|
1046
|
-
node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map(
|
|
1431
|
+
node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map { |np| part_text(np) }.join
|
|
1047
1432
|
end
|
|
1048
1433
|
|
|
1049
1434
|
def non_date_parts_joined(node)
|
|
1050
1435
|
node.xpath("mods:namePart", NAMESPACE)
|
|
1051
|
-
.reject { |np| np["type"] == "date"
|
|
1052
|
-
.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) : ""
|
|
1053
1448
|
end
|
|
1054
1449
|
|
|
1055
1450
|
def name_roles(node)
|
|
@@ -1066,9 +1461,8 @@ module NEU
|
|
|
1066
1461
|
# (NOT MARC-relator-translated -- see README). nil if neither is present.
|
|
1067
1462
|
def role_term_value(role)
|
|
1068
1463
|
%w[text code].each do |type|
|
|
1069
|
-
term = role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE)
|
|
1070
|
-
|
|
1071
|
-
return text unless text.empty?
|
|
1464
|
+
term = part_text(role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE))
|
|
1465
|
+
return term unless term.empty?
|
|
1072
1466
|
end
|
|
1073
1467
|
nil
|
|
1074
1468
|
end
|
data/lib/neu-mods.rb
CHANGED
|
@@ -25,6 +25,11 @@ module NEU
|
|
|
25
25
|
# The MODS v3 namespace, as a Nokogiri xpath namespace hash.
|
|
26
26
|
NAMESPACE = { "mods" => "http://www.loc.gov/mods/v3" }.freeze
|
|
27
27
|
|
|
28
|
+
# XLink, which MODS uses for the @xlink:href a display hyperlinks an element
|
|
29
|
+
# to. Held as a bare URI rather than a prefix map because the attribute is
|
|
30
|
+
# read by namespace: a document may bind XLink to any prefix it likes.
|
|
31
|
+
XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"
|
|
32
|
+
|
|
28
33
|
# The projected field set and its cardinality (see Projection::FIELDS),
|
|
29
34
|
# surfaced here so a consumer deriving its own schema from it -- Atlas's
|
|
30
35
|
# Metadata::MODS attr_json set -- reads the shared contract off the top-level
|
|
@@ -35,6 +40,7 @@ module NEU
|
|
|
35
40
|
|
|
36
41
|
# Whitespace no-op guard (see Canonicalize).
|
|
37
42
|
def canonical_ws(str) = Canonicalize.canonical_ws(str)
|
|
43
|
+
def canonical_lines(str) = Canonicalize.canonical_lines(str)
|
|
38
44
|
def whitespace_equivalent?(current, incoming) = Canonicalize.whitespace_equivalent?(current, incoming)
|
|
39
45
|
|
|
40
46
|
# Curator-freetext normalization for the access copy (see TextNormalizer).
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: neu-mods
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
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
|