neu-mods 0.11.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 +20 -5
- data/lib/neu/mods/canonicalize.rb +9 -0
- data/lib/neu/mods/projection.rb +155 -51
- 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
|
@@ -31,12 +31,16 @@ 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
|
|
@@ -45,20 +49,31 @@ doc.date_created_parts
|
|
|
45
49
|
# A keyDate="yes" node chooses the value, ahead of
|
|
46
50
|
# @point and document order; one date per type is the
|
|
47
51
|
# rule, so an unflagged repeat is discarded.
|
|
52
|
+
# A value that is not a w3cdtf date projects NO date
|
|
53
|
+
# and keeps its literal in :text -- "19uu", "ca. 1920"
|
|
54
|
+
# and "undated" are statements a cataloguer made, and
|
|
55
|
+
# guessing a date for them is worse than either losing
|
|
56
|
+
# them or showing them as written.
|
|
48
57
|
# Same for the other six originInfo dates --
|
|
49
58
|
# date_issued, copyright_date, date_captured,
|
|
50
59
|
# date_valid, date_other and date_modified. Each part is
|
|
51
60
|
# also a reader of its own, e.g.
|
|
52
61
|
# doc.date_created_qualifier.
|
|
53
62
|
doc.place_of_publication
|
|
54
|
-
# => ["Boston"] the type="text" placeTerm wins,
|
|
55
|
-
# marccountry code
|
|
56
|
-
#
|
|
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
|
|
57
67
|
doc.host_collections
|
|
58
68
|
# => [{ title:, volume:, issue:, start_page:, end_page:,
|
|
59
69
|
# date:, text:, details: [...], extents: [...] }, ...]
|
|
60
70
|
# this work's position in its host. The entry survives on
|
|
61
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
|
|
62
77
|
doc.notes # => [{ type: "funding", value: "..." }, ...]
|
|
63
78
|
doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
|
|
64
79
|
# every relatedItem that is not a series or a host
|
|
@@ -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
|
@@ -113,6 +113,12 @@ module NEU
|
|
|
113
113
|
def use_and_reproduction = access_conditions_of_type("use and reproduction")
|
|
114
114
|
def restriction_on_access = access_conditions_of_type("restriction on access")
|
|
115
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
|
+
|
|
116
122
|
# --- Subjects ------------------------------------------------------------
|
|
117
123
|
|
|
118
124
|
# The editable free-text keyword set (Cerberus simple form): topics under the
|
|
@@ -250,17 +256,24 @@ module NEU
|
|
|
250
256
|
|
|
251
257
|
# --- Scalars / simple arrays --------------------------------------------
|
|
252
258
|
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
# 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.
|
|
257
271
|
def languages
|
|
258
272
|
doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
|
|
259
|
-
|
|
260
|
-
next
|
|
273
|
+
term = language_term(lang)
|
|
274
|
+
next unless term
|
|
261
275
|
|
|
262
|
-
|
|
263
|
-
code && LanguageCodes.term(code)
|
|
276
|
+
{ term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang) }
|
|
264
277
|
end
|
|
265
278
|
end
|
|
266
279
|
|
|
@@ -282,20 +295,29 @@ module NEU
|
|
|
282
295
|
def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
|
|
283
296
|
|
|
284
297
|
# 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
|
-
#
|
|
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.
|
|
290
306
|
#
|
|
291
307
|
# TODO: expand a marccountry code through a registry, as LanguageCodes
|
|
292
|
-
# does for eng -> English. That needs a vendored code list
|
|
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
|
+
|
|
293
312
|
def place_of_publication
|
|
294
313
|
doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
|
|
295
314
|
text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
|
|
296
315
|
next text if text
|
|
297
316
|
|
|
298
|
-
|
|
317
|
+
code = place.at_xpath("mods:placeTerm", NAMESPACE)
|
|
318
|
+
next if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
|
|
319
|
+
|
|
320
|
+
clean(code&.text)
|
|
299
321
|
end
|
|
300
322
|
end
|
|
301
323
|
|
|
@@ -307,7 +329,17 @@ module NEU
|
|
|
307
329
|
# inconsistent.
|
|
308
330
|
def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
|
|
309
331
|
|
|
310
|
-
|
|
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
|
+
|
|
311
343
|
def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
|
|
312
344
|
|
|
313
345
|
# A note about the object rather than about the work -- "Scanned at 600
|
|
@@ -417,10 +449,13 @@ module NEU
|
|
|
417
449
|
# is a DOI, and a display cannot decide to linkify it. The same argument
|
|
418
450
|
# #notes already makes for its @type, and #permanent_url already proves
|
|
419
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.
|
|
420
455
|
def identifiers
|
|
421
456
|
doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
|
|
422
457
|
value = clean(node.text)
|
|
423
|
-
{ type: clean(node["type"]), value: value } if value
|
|
458
|
+
{ type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes" } if value
|
|
424
459
|
end
|
|
425
460
|
end
|
|
426
461
|
|
|
@@ -429,10 +464,6 @@ module NEU
|
|
|
429
464
|
node && clean(node.text)
|
|
430
465
|
end
|
|
431
466
|
|
|
432
|
-
# The three w3cdtf date shapes a dateCreated may stop at: year, year-month,
|
|
433
|
-
# or a full date. Matching the shape explicitly, rather than widening
|
|
434
|
-
# DateTime.parse, is what lets the declared precision fall out of the parse
|
|
435
|
-
# instead of being guessed after it.
|
|
436
467
|
# The eleven children the XSD allows under hierarchicalGeographic, in the
|
|
437
468
|
# order MODS lists them -- broadest first, which is also the order a
|
|
438
469
|
# consumer composing a place string wants to reverse.
|
|
@@ -453,15 +484,27 @@ module NEU
|
|
|
453
484
|
language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
|
|
454
485
|
}.freeze
|
|
455
486
|
|
|
456
|
-
|
|
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/
|
|
457
499
|
|
|
458
500
|
# What #date_parts returns when the element is absent entirely, so an
|
|
459
501
|
# absent date is distinguishable from one present and unparseable.
|
|
460
|
-
EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
|
|
461
|
-
|
|
502
|
+
EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
|
|
503
|
+
qualifier: nil, key_date: nil, text: nil }.freeze
|
|
462
504
|
|
|
463
505
|
# Everything a record declared about one originInfo date, as
|
|
464
|
-
# { value:, precision:, end_value:, end_precision:, qualifier:, key_date
|
|
506
|
+
# { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
|
|
507
|
+
# text: }.
|
|
465
508
|
#
|
|
466
509
|
# A date is not a scalar. Precision established that: a year-only date
|
|
467
510
|
# parses to January 1st, and no consumer downstream can tell that month
|
|
@@ -474,7 +517,9 @@ module NEU
|
|
|
474
517
|
#
|
|
475
518
|
# The parts are projected as separate flat fields rather than one nested
|
|
476
519
|
# 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.
|
|
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.
|
|
478
523
|
# MODS puts seven date elements under originInfo and this reads all of
|
|
479
524
|
# them. dateCaptured is when the object was digitised and dateModified is
|
|
480
525
|
# when the resource changed -- preservation and cataloguing provenance,
|
|
@@ -496,6 +541,7 @@ module NEU
|
|
|
496
541
|
def date_created_end_precision = date_created_parts[:end_precision]
|
|
497
542
|
def date_created_qualifier = date_created_parts[:qualifier]
|
|
498
543
|
def date_created_key_date = date_created_parts[:key_date]
|
|
544
|
+
def date_created_text = date_created_parts[:text]
|
|
499
545
|
|
|
500
546
|
def date_issued = date_issued_parts[:value]
|
|
501
547
|
def date_issued_precision = date_issued_parts[:precision]
|
|
@@ -503,6 +549,7 @@ module NEU
|
|
|
503
549
|
def date_issued_end_precision = date_issued_parts[:end_precision]
|
|
504
550
|
def date_issued_qualifier = date_issued_parts[:qualifier]
|
|
505
551
|
def date_issued_key_date = date_issued_parts[:key_date]
|
|
552
|
+
def date_issued_text = date_issued_parts[:text]
|
|
506
553
|
|
|
507
554
|
def copyright_date = copyright_date_parts[:value]
|
|
508
555
|
def copyright_date_precision = copyright_date_parts[:precision]
|
|
@@ -510,6 +557,7 @@ module NEU
|
|
|
510
557
|
def copyright_date_end_precision = copyright_date_parts[:end_precision]
|
|
511
558
|
def copyright_date_qualifier = copyright_date_parts[:qualifier]
|
|
512
559
|
def copyright_date_key_date = copyright_date_parts[:key_date]
|
|
560
|
+
def copyright_date_text = copyright_date_parts[:text]
|
|
513
561
|
|
|
514
562
|
def date_captured = date_captured_parts[:value]
|
|
515
563
|
def date_captured_precision = date_captured_parts[:precision]
|
|
@@ -517,6 +565,7 @@ module NEU
|
|
|
517
565
|
def date_captured_end_precision = date_captured_parts[:end_precision]
|
|
518
566
|
def date_captured_qualifier = date_captured_parts[:qualifier]
|
|
519
567
|
def date_captured_key_date = date_captured_parts[:key_date]
|
|
568
|
+
def date_captured_text = date_captured_parts[:text]
|
|
520
569
|
|
|
521
570
|
def date_valid = date_valid_parts[:value]
|
|
522
571
|
def date_valid_precision = date_valid_parts[:precision]
|
|
@@ -524,6 +573,7 @@ module NEU
|
|
|
524
573
|
def date_valid_end_precision = date_valid_parts[:end_precision]
|
|
525
574
|
def date_valid_qualifier = date_valid_parts[:qualifier]
|
|
526
575
|
def date_valid_key_date = date_valid_parts[:key_date]
|
|
576
|
+
def date_valid_text = date_valid_parts[:text]
|
|
527
577
|
|
|
528
578
|
def date_other = date_other_parts[:value]
|
|
529
579
|
def date_other_precision = date_other_parts[:precision]
|
|
@@ -531,6 +581,7 @@ module NEU
|
|
|
531
581
|
def date_other_end_precision = date_other_parts[:end_precision]
|
|
532
582
|
def date_other_qualifier = date_other_parts[:qualifier]
|
|
533
583
|
def date_other_key_date = date_other_parts[:key_date]
|
|
584
|
+
def date_other_text = date_other_parts[:text]
|
|
534
585
|
|
|
535
586
|
def date_modified = date_modified_parts[:value]
|
|
536
587
|
def date_modified_precision = date_modified_parts[:precision]
|
|
@@ -538,6 +589,7 @@ module NEU
|
|
|
538
589
|
def date_modified_end_precision = date_modified_parts[:end_precision]
|
|
539
590
|
def date_modified_qualifier = date_modified_parts[:qualifier]
|
|
540
591
|
def date_modified_key_date = date_modified_parts[:key_date]
|
|
592
|
+
def date_modified_text = date_modified_parts[:text]
|
|
541
593
|
|
|
542
594
|
# The [value, precision] pair the precision work introduced. Retained
|
|
543
595
|
# because it is the documented entry point for a caller that wants both
|
|
@@ -591,7 +643,7 @@ module NEU
|
|
|
591
643
|
edition: :many,
|
|
592
644
|
issuance: :many,
|
|
593
645
|
frequency: :many,
|
|
594
|
-
#
|
|
646
|
+
# Seven rows per originInfo date, for each of the seven MODS defines.
|
|
595
647
|
# Flat rather than one nested value, because the value half has
|
|
596
648
|
# consumers that need a real date object.
|
|
597
649
|
date_created: :one,
|
|
@@ -600,42 +652,49 @@ module NEU
|
|
|
600
652
|
date_created_end_precision: :one,
|
|
601
653
|
date_created_qualifier: :one,
|
|
602
654
|
date_created_key_date: :one,
|
|
655
|
+
date_created_text: :one,
|
|
603
656
|
date_issued: :one,
|
|
604
657
|
date_issued_precision: :one,
|
|
605
658
|
date_issued_end: :one,
|
|
606
659
|
date_issued_end_precision: :one,
|
|
607
660
|
date_issued_qualifier: :one,
|
|
608
661
|
date_issued_key_date: :one,
|
|
662
|
+
date_issued_text: :one,
|
|
609
663
|
copyright_date: :one,
|
|
610
664
|
copyright_date_precision: :one,
|
|
611
665
|
copyright_date_end: :one,
|
|
612
666
|
copyright_date_end_precision: :one,
|
|
613
667
|
copyright_date_qualifier: :one,
|
|
614
668
|
copyright_date_key_date: :one,
|
|
669
|
+
copyright_date_text: :one,
|
|
615
670
|
date_captured: :one,
|
|
616
671
|
date_captured_precision: :one,
|
|
617
672
|
date_captured_end: :one,
|
|
618
673
|
date_captured_end_precision: :one,
|
|
619
674
|
date_captured_qualifier: :one,
|
|
620
675
|
date_captured_key_date: :one,
|
|
676
|
+
date_captured_text: :one,
|
|
621
677
|
date_valid: :one,
|
|
622
678
|
date_valid_precision: :one,
|
|
623
679
|
date_valid_end: :one,
|
|
624
680
|
date_valid_end_precision: :one,
|
|
625
681
|
date_valid_qualifier: :one,
|
|
626
682
|
date_valid_key_date: :one,
|
|
683
|
+
date_valid_text: :one,
|
|
627
684
|
date_other: :one,
|
|
628
685
|
date_other_precision: :one,
|
|
629
686
|
date_other_end: :one,
|
|
630
687
|
date_other_end_precision: :one,
|
|
631
688
|
date_other_qualifier: :one,
|
|
632
689
|
date_other_key_date: :one,
|
|
690
|
+
date_other_text: :one,
|
|
633
691
|
date_modified: :one,
|
|
634
692
|
date_modified_precision: :one,
|
|
635
693
|
date_modified_end: :one,
|
|
636
694
|
date_modified_end_precision: :one,
|
|
637
695
|
date_modified_qualifier: :one,
|
|
638
696
|
date_modified_key_date: :one,
|
|
697
|
+
date_modified_text: :one,
|
|
639
698
|
|
|
640
699
|
# physical description
|
|
641
700
|
resource_type: :many,
|
|
@@ -708,24 +767,30 @@ module NEU
|
|
|
708
767
|
|
|
709
768
|
# --- helpers -------------------------------------------------------------
|
|
710
769
|
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
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.
|
|
715
778
|
def parse_w3cdtf(str)
|
|
716
779
|
m = W3CDTF_DATE.match(str)
|
|
717
|
-
return
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
elsif m[2]
|
|
722
|
-
"month"
|
|
723
|
-
else
|
|
724
|
-
"year"
|
|
725
|
-
end
|
|
726
|
-
[DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
|
|
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)]
|
|
727
784
|
rescue Date::Error
|
|
728
|
-
|
|
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"
|
|
729
794
|
end
|
|
730
795
|
|
|
731
796
|
# One originInfo date element, read by its attributes rather than by
|
|
@@ -770,7 +835,7 @@ module NEU
|
|
|
770
835
|
# itself: MODS enumerates approximate, inferred and questionable, but the
|
|
771
836
|
# record still said something.
|
|
772
837
|
def date_entry(start, finish, nodes)
|
|
773
|
-
value, precision = node_date(start)
|
|
838
|
+
value, precision, text = node_date(start)
|
|
774
839
|
end_value, end_precision = node_date(finish)
|
|
775
840
|
{
|
|
776
841
|
value: value,
|
|
@@ -778,17 +843,28 @@ module NEU
|
|
|
778
843
|
end_value: end_value,
|
|
779
844
|
end_precision: end_precision,
|
|
780
845
|
qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
|
|
781
|
-
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
|
|
846
|
+
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
|
|
847
|
+
text: text
|
|
782
848
|
}
|
|
783
849
|
end
|
|
784
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.
|
|
785
858
|
def node_date(node)
|
|
786
|
-
return [nil, nil] unless node
|
|
859
|
+
return [nil, nil, nil] unless node
|
|
787
860
|
|
|
788
861
|
str = NEU::MODS.canonical_ws(node.text)
|
|
789
|
-
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
|
|
790
866
|
|
|
791
|
-
|
|
867
|
+
[nil, nil, str]
|
|
792
868
|
end
|
|
793
869
|
|
|
794
870
|
def attr_value(node, name)
|
|
@@ -953,6 +1029,26 @@ module NEU
|
|
|
953
1029
|
doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
|
|
954
1030
|
end
|
|
955
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
|
+
|
|
956
1052
|
def child_text(parent, xpath)
|
|
957
1053
|
return nil unless parent
|
|
958
1054
|
|
|
@@ -977,12 +1073,20 @@ module NEU
|
|
|
977
1073
|
NEU::MODS.canonical_ws(str)
|
|
978
1074
|
end
|
|
979
1075
|
|
|
980
|
-
# The schema leaves accessCondition/@type an open string, so match on
|
|
981
|
-
#
|
|
982
|
-
#
|
|
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.
|
|
983
1086
|
def access_conditions_of_type(type)
|
|
1087
|
+
wanted = Projection.fold_type(type)
|
|
984
1088
|
nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
|
|
985
|
-
.select { |node|
|
|
1089
|
+
.select { |node| Projection.fold_type(node["type"]) == wanted }
|
|
986
1090
|
join_paragraphs(nodes)
|
|
987
1091
|
end
|
|
988
1092
|
|
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.
|