neu-mods 0.10.0 → 0.12.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 +37 -6
- data/lib/neu/mods/canonicalize.rb +9 -0
- data/lib/neu/mods/projection.rb +334 -55
- data/lib/neu/mods/selectors.rb +9 -0
- data/lib/neu-mods.rb +1 -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: 66a8dc19c0e19ccd43f170c117414ddb1df82c3cec889a83c27c6416f778173b
|
|
4
|
+
data.tar.gz: 445176fc7abc0748d47eddae0ee99137321c743b53a9de3432bb5e45a418ea68
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f453251ae9fd5c98737370fcd26e69d85cc8cf615d4e16bf1fd2b0e0f6cb80a23a9b6819fb1f364044161725a9f828c8df3776b760b5b59f24ac0589ed956b3
|
|
7
|
+
data.tar.gz: 7a04ed96b243039b19457a7df660966f9cd73ec1de92585403fa02a2c0711a80455029d1107cb4d7f3efac9d39b3acf0069bd1ce00031856fff2ce0b350558e6
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.12.0
|
data/README.md
CHANGED
|
@@ -27,24 +27,53 @@ 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 - How We Respond to Disaster
|
|
30
|
+
doc.plain_title # => "What's New, Episode 1 - How We Respond to Disaster"
|
|
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
|
|
43
47
|
# day. The points are read by @point, not by document
|
|
44
48
|
# order, and the end carries its OWN precision.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
49
|
+
# A keyDate="yes" node chooses the value, ahead of
|
|
50
|
+
# @point and document order; one date per type is the
|
|
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.
|
|
57
|
+
# Same for the other six originInfo dates --
|
|
58
|
+
# date_issued, copyright_date, date_captured,
|
|
59
|
+
# date_valid, date_other and date_modified. Each part is
|
|
60
|
+
# also a reader of its own, e.g.
|
|
47
61
|
# doc.date_created_qualifier.
|
|
62
|
+
doc.place_of_publication
|
|
63
|
+
# => ["Boston"] the type="text" placeTerm wins, and a
|
|
64
|
+
# bare marccountry code drops rather than reaching a
|
|
65
|
+
# places facet as a place name. A bare code under any
|
|
66
|
+
# other authority still projects
|
|
67
|
+
doc.host_collections
|
|
68
|
+
# => [{ title:, volume:, issue:, start_page:, end_page:,
|
|
69
|
+
# date:, text:, details: [...], extents: [...] }, ...]
|
|
70
|
+
# this work's position in its host. The entry survives on
|
|
71
|
+
# its part alone, so a host with no titleInfo is kept
|
|
72
|
+
doc.identifiers # => [{ type: "isbn", value: "...", invalid: false }, ...]
|
|
73
|
+
# @invalid means cancelled or superseded, so it travels
|
|
74
|
+
doc.table_of_contents
|
|
75
|
+
# => ["Ch 1\nCh 2"] line breaks kept: in a contents list
|
|
76
|
+
# the break is the structure, not stray formatting
|
|
48
77
|
doc.notes # => [{ type: "funding", value: "..." }, ...]
|
|
49
78
|
doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
|
|
50
79
|
# every relatedItem that is not a series or a host
|
|
@@ -69,7 +98,9 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
|
|
|
69
98
|
# the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
|
|
70
99
|
NEU::MODS.compose_title(non_sort: "", title: "What's New",
|
|
71
100
|
part_name: "How We Respond to Disaster", part_number: "Episode 1")
|
|
72
|
-
# => "What's New - How We Respond to Disaster
|
|
101
|
+
# => "What's New, Episode 1 - How We Respond to Disaster" (== doc.plain_title)
|
|
102
|
+
# The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
|
|
103
|
+
# cataloguing convention, and titleInfo is an unordered choice in the schema.
|
|
73
104
|
|
|
74
105
|
# Selectors (live nodes — for editing)
|
|
75
106
|
node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
|
|
@@ -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
|
@@ -52,10 +52,17 @@ module NEU
|
|
|
52
52
|
# (reaching for Nokogiri in a decorator is the smell this avoids). Keys:
|
|
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
|
+
#
|
|
56
|
+
# The part NUMBER precedes the part NAME. titleInfo is an unordered choice
|
|
57
|
+
# in the schema, so no document order is available to follow, and a fixed
|
|
58
|
+
# order that put the name first read the number as a trailing qualifier on
|
|
59
|
+
# the section rather than as the section it numbers. Cataloguing practice
|
|
60
|
+
# is "Part 2. The Marshes". The separator travels with the part, not with
|
|
61
|
+
# the position, so the swap moves the comma with the number.
|
|
55
62
|
def self.compose_title(parts)
|
|
56
63
|
return "" if parts[:title].to_s.strip.empty?
|
|
57
64
|
|
|
58
|
-
optional = { ": " => parts[:subtitle], "
|
|
65
|
+
optional = { ": " => parts[:subtitle], ", " => parts[:part_number], " - " => parts[:part_name] }
|
|
59
66
|
suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
|
|
60
67
|
"#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
|
|
61
68
|
end
|
|
@@ -106,6 +113,12 @@ module NEU
|
|
|
106
113
|
def use_and_reproduction = access_conditions_of_type("use and reproduction")
|
|
107
114
|
def restriction_on_access = access_conditions_of_type("restriction on access")
|
|
108
115
|
|
|
116
|
+
# An open-string @type reduced to its letters and digits, so casing, word
|
|
117
|
+
# separators and camelCasing cannot decide whether a field matches.
|
|
118
|
+
def self.fold_type(str)
|
|
119
|
+
NEU::MODS.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
|
|
120
|
+
end
|
|
121
|
+
|
|
109
122
|
# --- Subjects ------------------------------------------------------------
|
|
110
123
|
|
|
111
124
|
# The editable free-text keyword set (Cerberus simple form): topics under the
|
|
@@ -205,8 +218,18 @@ module NEU
|
|
|
205
218
|
# cataloguer who records that a person both wrote and edited a work means
|
|
206
219
|
# both. Each term prefers the type="text" roleTerm, falling back to the raw
|
|
207
220
|
# code (NOT MARC-relator-translated -- see README).
|
|
221
|
+
#
|
|
222
|
+
# A name with no name text drops out. A mods:name carrying only a role
|
|
223
|
+
# projected { name: nil, roles: ["edt"] }, which a display renders as a
|
|
224
|
+
# labelled empty row and which every consumer had to guard against with
|
|
225
|
+
# its own compact_blank. #preserved_names deliberately keeps it: that list
|
|
226
|
+
# tells a curator what the XML holds, so an element they need to fix has
|
|
227
|
+
# to stay visible there.
|
|
208
228
|
def names
|
|
209
|
-
doc.xpath("/mods:mods/mods:name", NAMESPACE).
|
|
229
|
+
doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
|
|
230
|
+
entry = name_entry(node)
|
|
231
|
+
entry if entry[:name]
|
|
232
|
+
end
|
|
210
233
|
end
|
|
211
234
|
|
|
212
235
|
# Editable (depositor-managed) creators: the plain names (no authority
|
|
@@ -233,17 +256,24 @@ module NEU
|
|
|
233
256
|
|
|
234
257
|
# --- Scalars / simple arrays --------------------------------------------
|
|
235
258
|
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
# facet
|
|
259
|
+
# { term:, object_part:, script: } per language element. Prefer the
|
|
260
|
+
# type="text" term, and translate a code-only one through the ISO 639
|
|
261
|
+
# registry. A record saying `eng` projects "English", so the display and
|
|
262
|
+
# the Solr language facet read the same value rather than the facet
|
|
263
|
+
# showing codes. An unrecognised code survives as itself.
|
|
264
|
+
#
|
|
265
|
+
# An entry rather than a bare string because @objectPart changes what the
|
|
266
|
+
# record is claiming. `<language objectPart="subtitles">spa` says the
|
|
267
|
+
# subtitles are Spanish, and projected flat it said the resource was --
|
|
268
|
+
# which is the case a captioned video hits every time. The script rides
|
|
269
|
+
# along for the same reason a name's role does: a consumer cannot
|
|
270
|
+
# recover it from the term.
|
|
240
271
|
def languages
|
|
241
272
|
doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
|
|
242
|
-
|
|
243
|
-
next
|
|
273
|
+
term = language_term(lang)
|
|
274
|
+
next unless term
|
|
244
275
|
|
|
245
|
-
|
|
246
|
-
code && LanguageCodes.term(code)
|
|
276
|
+
{ term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang) }
|
|
247
277
|
end
|
|
248
278
|
end
|
|
249
279
|
|
|
@@ -263,7 +293,34 @@ module NEU
|
|
|
263
293
|
# back by nothing.
|
|
264
294
|
def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
|
|
265
295
|
def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
|
|
266
|
-
|
|
296
|
+
|
|
297
|
+
# Prefer the type="text" term per place, falling back to a coded one --
|
|
298
|
+
# the pattern #role_term_value and #languages already use.
|
|
299
|
+
#
|
|
300
|
+
# A bare marccountry code is the exception, and it drops. "mau" is not a
|
|
301
|
+
# place name, and unfiltered it reached the display and the Solr places
|
|
302
|
+
# facet as one, sitting in the list beside Boston. That is the call
|
|
303
|
+
# #geographic_code_subjects already makes for a MARC GAC code. A code
|
|
304
|
+
# under any other authority survives, because there the code may be the
|
|
305
|
+
# only statement the record made and nothing here can say it is not text.
|
|
306
|
+
#
|
|
307
|
+
# TODO: expand a marccountry code through a registry, as LanguageCodes
|
|
308
|
+
# does for eng -> English. That needs a vendored code list, and would let
|
|
309
|
+
# this project "Massachusetts" instead of dropping the element.
|
|
310
|
+
MARC_COUNTRY_AUTHORITY = "marccountry"
|
|
311
|
+
|
|
312
|
+
def place_of_publication
|
|
313
|
+
doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
|
|
314
|
+
text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
|
|
315
|
+
next text if text
|
|
316
|
+
|
|
317
|
+
code = place.at_xpath("mods:placeTerm", NAMESPACE)
|
|
318
|
+
next if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
|
|
319
|
+
|
|
320
|
+
clean(code&.text)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
267
324
|
def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
|
|
268
325
|
|
|
269
326
|
# Serials. The @authority a record puts on a frequency is not projected:
|
|
@@ -272,7 +329,17 @@ module NEU
|
|
|
272
329
|
# inconsistent.
|
|
273
330
|
def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
|
|
274
331
|
|
|
275
|
-
|
|
332
|
+
# Read with its line breaks intact. A legacy contents list separates its
|
|
333
|
+
# entries by newline, and the whitespace collapse every other field wants
|
|
334
|
+
# ran the entries together into one line -- there the break IS the
|
|
335
|
+
# structure, not stray formatting. A "--"-separated list is unaffected.
|
|
336
|
+
def table_of_contents
|
|
337
|
+
doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
|
|
338
|
+
lines = NEU::MODS.canonical_lines(node.text)
|
|
339
|
+
lines unless lines.empty?
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
276
343
|
def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
|
|
277
344
|
|
|
278
345
|
# A note about the object rather than about the work -- "Scanned at 600
|
|
@@ -338,10 +405,14 @@ module NEU
|
|
|
338
405
|
# record, and a transcribed copy goes stale the moment that record is
|
|
339
406
|
# edited. A part is the exception, because a volume, issue and page range
|
|
340
407
|
# describe this article and no other record holds that fact.
|
|
408
|
+
#
|
|
409
|
+
# An entry survives on its part alone. Requiring a title discarded the
|
|
410
|
+
# one piece of the block that was ours along with the metadata that never
|
|
411
|
+
# was, and how to render a titleless host is the consumer's call.
|
|
341
412
|
def host_collections
|
|
342
413
|
doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
|
|
343
|
-
|
|
344
|
-
|
|
414
|
+
entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
|
|
415
|
+
entry if entry.values.any?
|
|
345
416
|
end
|
|
346
417
|
end
|
|
347
418
|
|
|
@@ -349,6 +420,13 @@ module NEU
|
|
|
349
420
|
# catch-all below does not repeat them.
|
|
350
421
|
NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
|
|
351
422
|
|
|
423
|
+
# The part detail types volume and issue already have named keys on a
|
|
424
|
+
# host entry, so #host_details does not repeat them -- the same split
|
|
425
|
+
# NAMED_RELATED_ITEM_TYPES makes for relatedItem. A caption is kept
|
|
426
|
+
# because it is the label a cataloguer wrote for the number ("chap."
|
|
427
|
+
# before "3"), which no consumer can reconstruct from an open @type.
|
|
428
|
+
NAMED_HOST_DETAIL_TYPES = %w[volume issue].freeze
|
|
429
|
+
|
|
352
430
|
# Every other relatedItem, keeping its @type. MODS also defines
|
|
353
431
|
# constituent, otherFormat, original, preceding, succeeding, isReferencedBy
|
|
354
432
|
# and reviewOf, and a record carrying any of them projected nothing at all.
|
|
@@ -371,10 +449,13 @@ module NEU
|
|
|
371
449
|
# is a DOI, and a display cannot decide to linkify it. The same argument
|
|
372
450
|
# #notes already makes for its @type, and #permanent_url already proves
|
|
373
451
|
# the attribute is load-bearing by special-casing @type='hdl'.
|
|
452
|
+
# @invalid rides along because in MODS it means the identifier is
|
|
453
|
+
# cancelled, superseded or simply wrong. Projected flat, a dead ISBN read
|
|
454
|
+
# exactly like a live one and invited a reader to use it.
|
|
374
455
|
def identifiers
|
|
375
456
|
doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
|
|
376
457
|
value = clean(node.text)
|
|
377
|
-
{ type: clean(node["type"]), value: value } if value
|
|
458
|
+
{ type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes" } if value
|
|
378
459
|
end
|
|
379
460
|
end
|
|
380
461
|
|
|
@@ -383,10 +464,6 @@ module NEU
|
|
|
383
464
|
node && clean(node.text)
|
|
384
465
|
end
|
|
385
466
|
|
|
386
|
-
# The three w3cdtf date shapes a dateCreated may stop at: year, year-month,
|
|
387
|
-
# or a full date. Matching the shape explicitly, rather than widening
|
|
388
|
-
# DateTime.parse, is what lets the declared precision fall out of the parse
|
|
389
|
-
# instead of being guessed after it.
|
|
390
467
|
# The eleven children the XSD allows under hierarchicalGeographic, in the
|
|
391
468
|
# order MODS lists them -- broadest first, which is also the order a
|
|
392
469
|
# consumer composing a place string wants to reverse.
|
|
@@ -407,15 +484,27 @@ module NEU
|
|
|
407
484
|
language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
|
|
408
485
|
}.freeze
|
|
409
486
|
|
|
410
|
-
|
|
487
|
+
# The w3cdtf date shapes a date element may stop at: year, year-month, a
|
|
488
|
+
# full date, or a full date with a time. Matching the shape explicitly,
|
|
489
|
+
# rather than widening DateTime.parse, is what lets the declared precision
|
|
490
|
+
# fall out of the parse instead of being guessed after it.
|
|
491
|
+
#
|
|
492
|
+
# A value outside these shapes is NOT a date, and #parse_w3cdtf says so
|
|
493
|
+
# rather than guessing. Ruby's DateTime.parse fills the components it
|
|
494
|
+
# cannot find from the CURRENT date, so "19uu" -- standard MARC 008 fill,
|
|
495
|
+
# which the v1 corpus carries at scale -- asserted today's date at "day"
|
|
496
|
+
# precision, and the assertion changed daily. What the record actually
|
|
497
|
+
# wrote survives in the matching *_text field instead.
|
|
498
|
+
W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
|
|
411
499
|
|
|
412
500
|
# What #date_parts returns when the element is absent entirely, so an
|
|
413
501
|
# absent date is distinguishable from one present and unparseable.
|
|
414
|
-
EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
|
|
415
|
-
|
|
502
|
+
EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
|
|
503
|
+
qualifier: nil, key_date: nil, text: nil }.freeze
|
|
416
504
|
|
|
417
505
|
# Everything a record declared about one originInfo date, as
|
|
418
|
-
# { value:, precision:, end_value:, end_precision:, qualifier:, key_date
|
|
506
|
+
# { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
|
|
507
|
+
# text: }.
|
|
419
508
|
#
|
|
420
509
|
# A date is not a scalar. Precision established that: a year-only date
|
|
421
510
|
# parses to January 1st, and no consumer downstream can tell that month
|
|
@@ -428,10 +517,23 @@ module NEU
|
|
|
428
517
|
#
|
|
429
518
|
# The parts are projected as separate flat fields rather than one nested
|
|
430
519
|
# value, because the value half has three consumers that need a real date
|
|
431
|
-
# object -- a Solr sort key, a citation year and an OAI date.
|
|
520
|
+
# object -- a Solr sort key, a citation year and an OAI date. Those three
|
|
521
|
+
# are also why the literal gets its own field rather than sharing the
|
|
522
|
+
# value: a sort key cannot hold "ca. 1920", and a display can.
|
|
523
|
+
# MODS puts seven date elements under originInfo and this reads all of
|
|
524
|
+
# them. dateCaptured is when the object was digitised and dateModified is
|
|
525
|
+
# when the resource changed -- preservation and cataloguing provenance,
|
|
526
|
+
# which a consumer may keep off a page but cannot recover from anywhere
|
|
527
|
+
# else. dateValid is the period the content holds for, and dateOther is
|
|
528
|
+
# where a date fitting no other element lands, which is where a quantity
|
|
529
|
+
# of migrated v1 date data goes.
|
|
432
530
|
def date_created_parts = date_parts("dateCreated")
|
|
433
531
|
def date_issued_parts = date_parts("dateIssued")
|
|
434
532
|
def copyright_date_parts = date_parts("copyrightDate")
|
|
533
|
+
def date_captured_parts = date_parts("dateCaptured")
|
|
534
|
+
def date_valid_parts = date_parts("dateValid")
|
|
535
|
+
def date_other_parts = date_parts("dateOther")
|
|
536
|
+
def date_modified_parts = date_parts("dateModified")
|
|
435
537
|
|
|
436
538
|
def date_created = date_created_parts[:value]
|
|
437
539
|
def date_created_precision = date_created_parts[:precision]
|
|
@@ -439,6 +541,7 @@ module NEU
|
|
|
439
541
|
def date_created_end_precision = date_created_parts[:end_precision]
|
|
440
542
|
def date_created_qualifier = date_created_parts[:qualifier]
|
|
441
543
|
def date_created_key_date = date_created_parts[:key_date]
|
|
544
|
+
def date_created_text = date_created_parts[:text]
|
|
442
545
|
|
|
443
546
|
def date_issued = date_issued_parts[:value]
|
|
444
547
|
def date_issued_precision = date_issued_parts[:precision]
|
|
@@ -446,6 +549,7 @@ module NEU
|
|
|
446
549
|
def date_issued_end_precision = date_issued_parts[:end_precision]
|
|
447
550
|
def date_issued_qualifier = date_issued_parts[:qualifier]
|
|
448
551
|
def date_issued_key_date = date_issued_parts[:key_date]
|
|
552
|
+
def date_issued_text = date_issued_parts[:text]
|
|
449
553
|
|
|
450
554
|
def copyright_date = copyright_date_parts[:value]
|
|
451
555
|
def copyright_date_precision = copyright_date_parts[:precision]
|
|
@@ -453,6 +557,39 @@ module NEU
|
|
|
453
557
|
def copyright_date_end_precision = copyright_date_parts[:end_precision]
|
|
454
558
|
def copyright_date_qualifier = copyright_date_parts[:qualifier]
|
|
455
559
|
def copyright_date_key_date = copyright_date_parts[:key_date]
|
|
560
|
+
def copyright_date_text = copyright_date_parts[:text]
|
|
561
|
+
|
|
562
|
+
def date_captured = date_captured_parts[:value]
|
|
563
|
+
def date_captured_precision = date_captured_parts[:precision]
|
|
564
|
+
def date_captured_end = date_captured_parts[:end_value]
|
|
565
|
+
def date_captured_end_precision = date_captured_parts[:end_precision]
|
|
566
|
+
def date_captured_qualifier = date_captured_parts[:qualifier]
|
|
567
|
+
def date_captured_key_date = date_captured_parts[:key_date]
|
|
568
|
+
def date_captured_text = date_captured_parts[:text]
|
|
569
|
+
|
|
570
|
+
def date_valid = date_valid_parts[:value]
|
|
571
|
+
def date_valid_precision = date_valid_parts[:precision]
|
|
572
|
+
def date_valid_end = date_valid_parts[:end_value]
|
|
573
|
+
def date_valid_end_precision = date_valid_parts[:end_precision]
|
|
574
|
+
def date_valid_qualifier = date_valid_parts[:qualifier]
|
|
575
|
+
def date_valid_key_date = date_valid_parts[:key_date]
|
|
576
|
+
def date_valid_text = date_valid_parts[:text]
|
|
577
|
+
|
|
578
|
+
def date_other = date_other_parts[:value]
|
|
579
|
+
def date_other_precision = date_other_parts[:precision]
|
|
580
|
+
def date_other_end = date_other_parts[:end_value]
|
|
581
|
+
def date_other_end_precision = date_other_parts[:end_precision]
|
|
582
|
+
def date_other_qualifier = date_other_parts[:qualifier]
|
|
583
|
+
def date_other_key_date = date_other_parts[:key_date]
|
|
584
|
+
def date_other_text = date_other_parts[:text]
|
|
585
|
+
|
|
586
|
+
def date_modified = date_modified_parts[:value]
|
|
587
|
+
def date_modified_precision = date_modified_parts[:precision]
|
|
588
|
+
def date_modified_end = date_modified_parts[:end_value]
|
|
589
|
+
def date_modified_end_precision = date_modified_parts[:end_precision]
|
|
590
|
+
def date_modified_qualifier = date_modified_parts[:qualifier]
|
|
591
|
+
def date_modified_key_date = date_modified_parts[:key_date]
|
|
592
|
+
def date_modified_text = date_modified_parts[:text]
|
|
456
593
|
|
|
457
594
|
# The [value, precision] pair the precision work introduced. Retained
|
|
458
595
|
# because it is the documented entry point for a caller that wants both
|
|
@@ -506,26 +643,58 @@ module NEU
|
|
|
506
643
|
edition: :many,
|
|
507
644
|
issuance: :many,
|
|
508
645
|
frequency: :many,
|
|
509
|
-
#
|
|
510
|
-
#
|
|
646
|
+
# Seven rows per originInfo date, for each of the seven MODS defines.
|
|
647
|
+
# Flat rather than one nested value, because the value half has
|
|
648
|
+
# consumers that need a real date object.
|
|
511
649
|
date_created: :one,
|
|
512
650
|
date_created_precision: :one,
|
|
513
651
|
date_created_end: :one,
|
|
514
652
|
date_created_end_precision: :one,
|
|
515
653
|
date_created_qualifier: :one,
|
|
516
654
|
date_created_key_date: :one,
|
|
655
|
+
date_created_text: :one,
|
|
517
656
|
date_issued: :one,
|
|
518
657
|
date_issued_precision: :one,
|
|
519
658
|
date_issued_end: :one,
|
|
520
659
|
date_issued_end_precision: :one,
|
|
521
660
|
date_issued_qualifier: :one,
|
|
522
661
|
date_issued_key_date: :one,
|
|
662
|
+
date_issued_text: :one,
|
|
523
663
|
copyright_date: :one,
|
|
524
664
|
copyright_date_precision: :one,
|
|
525
665
|
copyright_date_end: :one,
|
|
526
666
|
copyright_date_end_precision: :one,
|
|
527
667
|
copyright_date_qualifier: :one,
|
|
528
668
|
copyright_date_key_date: :one,
|
|
669
|
+
copyright_date_text: :one,
|
|
670
|
+
date_captured: :one,
|
|
671
|
+
date_captured_precision: :one,
|
|
672
|
+
date_captured_end: :one,
|
|
673
|
+
date_captured_end_precision: :one,
|
|
674
|
+
date_captured_qualifier: :one,
|
|
675
|
+
date_captured_key_date: :one,
|
|
676
|
+
date_captured_text: :one,
|
|
677
|
+
date_valid: :one,
|
|
678
|
+
date_valid_precision: :one,
|
|
679
|
+
date_valid_end: :one,
|
|
680
|
+
date_valid_end_precision: :one,
|
|
681
|
+
date_valid_qualifier: :one,
|
|
682
|
+
date_valid_key_date: :one,
|
|
683
|
+
date_valid_text: :one,
|
|
684
|
+
date_other: :one,
|
|
685
|
+
date_other_precision: :one,
|
|
686
|
+
date_other_end: :one,
|
|
687
|
+
date_other_end_precision: :one,
|
|
688
|
+
date_other_qualifier: :one,
|
|
689
|
+
date_other_key_date: :one,
|
|
690
|
+
date_other_text: :one,
|
|
691
|
+
date_modified: :one,
|
|
692
|
+
date_modified_precision: :one,
|
|
693
|
+
date_modified_end: :one,
|
|
694
|
+
date_modified_end_precision: :one,
|
|
695
|
+
date_modified_qualifier: :one,
|
|
696
|
+
date_modified_key_date: :one,
|
|
697
|
+
date_modified_text: :one,
|
|
529
698
|
|
|
530
699
|
# physical description
|
|
531
700
|
resource_type: :many,
|
|
@@ -598,37 +767,62 @@ module NEU
|
|
|
598
767
|
|
|
599
768
|
# --- helpers -------------------------------------------------------------
|
|
600
769
|
|
|
601
|
-
#
|
|
602
|
-
#
|
|
603
|
-
#
|
|
604
|
-
#
|
|
770
|
+
# [DateTime, precision] for a w3cdtf date, or nil for a string that is not
|
|
771
|
+
# one. A shape-matched but impossible date (2026-13, 2026-02-30) reaches
|
|
772
|
+
# DateTime, raises, and is nil like any other unreadable value; the caller
|
|
773
|
+
# keeps its literal text.
|
|
774
|
+
#
|
|
775
|
+
# A full timestamp goes through DateTime.parse rather than being rebuilt,
|
|
776
|
+
# so the time of day a dateModified declares survives. Its precision is
|
|
777
|
+
# "day" because that is the finest granularity a consumer renders.
|
|
605
778
|
def parse_w3cdtf(str)
|
|
606
779
|
m = W3CDTF_DATE.match(str)
|
|
607
|
-
return
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
elsif m[2]
|
|
612
|
-
"month"
|
|
613
|
-
else
|
|
614
|
-
"year"
|
|
615
|
-
end
|
|
616
|
-
[DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
|
|
780
|
+
return nil unless m
|
|
781
|
+
return [DateTime.parse(str), "day"] if m[4]
|
|
782
|
+
|
|
783
|
+
[DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), w3cdtf_precision(m)]
|
|
617
784
|
rescue Date::Error
|
|
618
|
-
|
|
785
|
+
nil
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
# The granularity the record stopped at, which is the whole point of
|
|
789
|
+
# matching the shape rather than widening the parse.
|
|
790
|
+
def w3cdtf_precision(match)
|
|
791
|
+
return "day" if match[3]
|
|
792
|
+
|
|
793
|
+
match[2] ? "month" : "year"
|
|
619
794
|
end
|
|
620
795
|
|
|
621
796
|
# One originInfo date element, read by its attributes rather than by
|
|
622
797
|
# position. A record is free to write point="end" first, and taking the
|
|
623
798
|
# first node would then invert the range.
|
|
799
|
+
#
|
|
800
|
+
# ONE DATE PER TYPE is the rule, and a repeated, non-ranged, unflagged
|
|
801
|
+
# date of the same type is discarded. A range is one date with two ends,
|
|
802
|
+
# which @point already models, and every consumer of the value -- a sort
|
|
803
|
+
# key, a citation year, an OAI date -- holds exactly one. Note the
|
|
804
|
+
# contrast with a repeated publisher, which survives because
|
|
805
|
+
# publication_information is :many; dates differ because the value is
|
|
806
|
+
# singular downstream, not because repetition went unnoticed.
|
|
624
807
|
def date_parts(element)
|
|
625
808
|
nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
|
|
626
809
|
return EMPTY_DATE if nodes.empty?
|
|
627
810
|
|
|
628
|
-
start = nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
629
|
-
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
630
811
|
finish = nodes.find { |n| attr_value(n, "point") == "end" }
|
|
631
|
-
date_entry(
|
|
812
|
+
date_entry(start_date_node(nodes), finish, nodes)
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# The node the value comes from: the flagged one, then the declared
|
|
816
|
+
# start, then document order.
|
|
817
|
+
#
|
|
818
|
+
# keyDate leads because the flag is the record nominating its own
|
|
819
|
+
# principal date. Projecting the flag while reading the value from a
|
|
820
|
+
# different node made the two contradict each other -- a consumer was told
|
|
821
|
+
# the record chose this date and then handed the one before it.
|
|
822
|
+
def start_date_node(nodes)
|
|
823
|
+
nodes.find { |n| attr_value(n, "keyDate") == "yes" && attr_value(n, "point") != "end" } ||
|
|
824
|
+
nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
825
|
+
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
632
826
|
end
|
|
633
827
|
|
|
634
828
|
# The end point carries its OWN precision. "1935-06" to "1940" is legal,
|
|
@@ -641,7 +835,7 @@ module NEU
|
|
|
641
835
|
# itself: MODS enumerates approximate, inferred and questionable, but the
|
|
642
836
|
# record still said something.
|
|
643
837
|
def date_entry(start, finish, nodes)
|
|
644
|
-
value, precision = node_date(start)
|
|
838
|
+
value, precision, text = node_date(start)
|
|
645
839
|
end_value, end_precision = node_date(finish)
|
|
646
840
|
{
|
|
647
841
|
value: value,
|
|
@@ -649,17 +843,28 @@ module NEU
|
|
|
649
843
|
end_value: end_value,
|
|
650
844
|
end_precision: end_precision,
|
|
651
845
|
qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
|
|
652
|
-
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
|
|
846
|
+
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
|
|
847
|
+
text: text
|
|
653
848
|
}
|
|
654
849
|
end
|
|
655
850
|
|
|
851
|
+
# [value, precision, text]. A node whose text is not a w3cdtf date yields
|
|
852
|
+
# no value and keeps its literal instead: "ca. 1920", "19th century" and
|
|
853
|
+
# "1918-1921" in one element are all real statements a cataloguer made,
|
|
854
|
+
# and a preservation repository must neither invent a date for them nor
|
|
855
|
+
# delete them. Only the start node's literal is kept -- the observed
|
|
856
|
+
# corpus writes an unreadable date as one element, and an end point that
|
|
857
|
+
# needs its own literal has never been seen.
|
|
656
858
|
def node_date(node)
|
|
657
|
-
return [nil, nil] unless node
|
|
859
|
+
return [nil, nil, nil] unless node
|
|
658
860
|
|
|
659
861
|
str = NEU::MODS.canonical_ws(node.text)
|
|
660
|
-
return [nil, nil] if str.empty?
|
|
862
|
+
return [nil, nil, nil] if str.empty?
|
|
863
|
+
|
|
864
|
+
parsed = parse_w3cdtf(str)
|
|
865
|
+
return [parsed[0], parsed[1], nil] if parsed
|
|
661
866
|
|
|
662
|
-
|
|
867
|
+
[nil, nil, str]
|
|
663
868
|
end
|
|
664
869
|
|
|
665
870
|
def attr_value(node, name)
|
|
@@ -738,6 +943,14 @@ module NEU
|
|
|
738
943
|
# punctuation of a citation is display policy, the same call #map_data
|
|
739
944
|
# makes for cartographics. MODS leaves @unit optional, so a page extent
|
|
740
945
|
# without one is read rather than dropped.
|
|
946
|
+
#
|
|
947
|
+
# Volume, issue and the page range keep named keys because they are the
|
|
948
|
+
# citation and a consumer asks for them by name. Everything else the
|
|
949
|
+
# schema allows under part arrives structured, because detail/@type and
|
|
950
|
+
# extent/@unit are open strings -- a fixed key per type cannot cover a
|
|
951
|
+
# vocabulary the schema does not close. #date is the article's year within
|
|
952
|
+
# the host, which after the title is the most-cited element of a journal
|
|
953
|
+
# citation and was reaching no consumer at all.
|
|
741
954
|
def host_part(node)
|
|
742
955
|
part = node.at_xpath("mods:part", NAMESPACE)
|
|
743
956
|
return {} if part.nil?
|
|
@@ -747,8 +960,46 @@ module NEU
|
|
|
747
960
|
volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
|
|
748
961
|
issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
|
|
749
962
|
start_page: child_text(part, "#{pages}/mods:start"),
|
|
750
|
-
end_page: child_text(part, "#{pages}/mods:end")
|
|
751
|
-
|
|
963
|
+
end_page: child_text(part, "#{pages}/mods:end"),
|
|
964
|
+
date: child_text(part, "mods:date"),
|
|
965
|
+
text: child_text(part, "mods:text"),
|
|
966
|
+
details: host_details(part),
|
|
967
|
+
extents: host_extents(part)
|
|
968
|
+
}.reject { |_, value| value.nil? || value == [] }
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
def host_details(part)
|
|
972
|
+
part.xpath("mods:detail", NAMESPACE).filter_map do |node|
|
|
973
|
+
type = clean(node["type"])
|
|
974
|
+
next if NAMED_HOST_DETAIL_TYPES.include?(type)
|
|
975
|
+
|
|
976
|
+
entry = {
|
|
977
|
+
type: type,
|
|
978
|
+
number: child_text(node, "mods:number"),
|
|
979
|
+
caption: child_text(node, "mods:caption"),
|
|
980
|
+
title: child_text(node, "mods:title")
|
|
981
|
+
}
|
|
982
|
+
entry if entry.except(:type).values.any?
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# Every extent EXCEPT the page range, which start_page and end_page hold.
|
|
987
|
+
# A unit other than page -- the minutes of a recording, the columns of a
|
|
988
|
+
# newspaper -- means nothing without its unit, so the unit travels with
|
|
989
|
+
# the numbers rather than being flattened away.
|
|
990
|
+
def host_extents(part)
|
|
991
|
+
part.xpath("mods:extent", NAMESPACE).filter_map do |node|
|
|
992
|
+
unit = clean(node["unit"])
|
|
993
|
+
next if unit.nil? || unit == "page"
|
|
994
|
+
|
|
995
|
+
{
|
|
996
|
+
unit: unit,
|
|
997
|
+
start: child_text(node, "mods:start"),
|
|
998
|
+
end: child_text(node, "mods:end"),
|
|
999
|
+
total: child_text(node, "mods:total"),
|
|
1000
|
+
list: child_text(node, "mods:list")
|
|
1001
|
+
}
|
|
1002
|
+
end
|
|
752
1003
|
end
|
|
753
1004
|
|
|
754
1005
|
def text_at(xpath)
|
|
@@ -778,6 +1029,26 @@ module NEU
|
|
|
778
1029
|
doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
|
|
779
1030
|
end
|
|
780
1031
|
|
|
1032
|
+
# The language of one element: the text term, or a code read through the
|
|
1033
|
+
# ISO 639 registry.
|
|
1034
|
+
def language_term(lang)
|
|
1035
|
+
text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
|
|
1036
|
+
return clean(text.text) if text
|
|
1037
|
+
|
|
1038
|
+
code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
|
|
1039
|
+
code && LanguageCodes.term(code)
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
# A scriptTerm, text form preferred. No registry expands a code here: the
|
|
1043
|
+
# ISO 15924 list is not vendored, and inventing a half-translation would
|
|
1044
|
+
# be worse than handing the consumer what the record wrote.
|
|
1045
|
+
def script_term(lang)
|
|
1046
|
+
text = lang.at_xpath("mods:scriptTerm[@type='text']", NAMESPACE)
|
|
1047
|
+
return clean(text.text) if text
|
|
1048
|
+
|
|
1049
|
+
clean(lang.at_xpath("mods:scriptTerm", NAMESPACE)&.text)
|
|
1050
|
+
end
|
|
1051
|
+
|
|
781
1052
|
def child_text(parent, xpath)
|
|
782
1053
|
return nil unless parent
|
|
783
1054
|
|
|
@@ -802,12 +1073,20 @@ module NEU
|
|
|
802
1073
|
NEU::MODS.canonical_ws(str)
|
|
803
1074
|
end
|
|
804
1075
|
|
|
805
|
-
# The schema leaves accessCondition/@type an open string, so match on
|
|
806
|
-
#
|
|
807
|
-
#
|
|
1076
|
+
# The schema leaves accessCondition/@type an open string, so match on a
|
|
1077
|
+
# folded key rather than in the XPath. Real records carry "Use and
|
|
1078
|
+
# Reproduction", "useAndReproduction" and "restriction-on-access" as
|
|
1079
|
+
# readily as the MODS-recommended casing, and an unmatched
|
|
1080
|
+
# restrictionOnAccess fell through to the generic #access_condition --
|
|
1081
|
+
# which is the same defect the two typed fields exist to prevent, reached
|
|
1082
|
+
# by a different route: a restriction presented to a reader as a licence.
|
|
1083
|
+
#
|
|
1084
|
+
# A genuinely unrecognised type still falls through, which is what
|
|
1085
|
+
# #access_condition is for.
|
|
808
1086
|
def access_conditions_of_type(type)
|
|
1087
|
+
wanted = Projection.fold_type(type)
|
|
809
1088
|
nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
|
|
810
|
-
.select { |node|
|
|
1089
|
+
.select { |node| Projection.fold_type(node["type"]) == wanted }
|
|
811
1090
|
join_paragraphs(nodes)
|
|
812
1091
|
end
|
|
813
1092
|
|
data/lib/neu/mods/selectors.rb
CHANGED
|
@@ -19,6 +19,15 @@ module NEU
|
|
|
19
19
|
# next title edit, leaving the record with no primary title at all. nil is
|
|
20
20
|
# the right answer instead -- MODSMerge creates a proper primary titleInfo
|
|
21
21
|
# from nil, and each variant is projected under its own field.
|
|
22
|
+
#
|
|
23
|
+
# Where a record carries two UNTYPED titleInfo and marks neither, .first
|
|
24
|
+
# decides and the second reaches no field. That is the schema's answer
|
|
25
|
+
# rather than a shortfall: @usage is fixed="primary" and exists precisely
|
|
26
|
+
# to nominate the principal title, @type is a closed enumeration of the
|
|
27
|
+
# four variants, and MODS 3.5 gives a legitimate second untyped title an
|
|
28
|
+
# @altRepGroup (one title in two scripts) or an @otherType. An unmarked
|
|
29
|
+
# duplicate carries none of those, so MODS gives it no meaning to
|
|
30
|
+
# preserve, and it stays in the preservation XML with no projected field.
|
|
22
31
|
def primary_title_info
|
|
23
32
|
doc.at_xpath("/mods:mods/mods:titleInfo[@usage='primary']", NAMESPACE) ||
|
|
24
33
|
doc.xpath("/mods:mods/mods:titleInfo", NAMESPACE).reject { |ti| variant_title?(ti) }.first
|
data/lib/neu-mods.rb
CHANGED
|
@@ -35,6 +35,7 @@ module NEU
|
|
|
35
35
|
|
|
36
36
|
# Whitespace no-op guard (see Canonicalize).
|
|
37
37
|
def canonical_ws(str) = Canonicalize.canonical_ws(str)
|
|
38
|
+
def canonical_lines(str) = Canonicalize.canonical_lines(str)
|
|
38
39
|
def whitespace_equivalent?(current, incoming) = Canonicalize.whitespace_equivalent?(current, incoming)
|
|
39
40
|
|
|
40
41
|
# Curator-freetext normalization for the access copy (see TextNormalizer).
|
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.12.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-08 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
|
-
-
|
|
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.0.9
|
|
98
98
|
signing_key:
|
|
99
99
|
specification_version: 4
|
|
100
100
|
summary: Northeastern-flavored MODS XML projection + selection for the DRS.
|