neu-mods 0.10.0 → 0.11.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 -4
- data/lib/neu/mods/projection.rb +187 -12
- data/lib/neu/mods/selectors.rb +9 -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: f29586facbe4e40077b0875aa896886a812b231058e97c12822875fd6203edef
|
|
4
|
+
data.tar.gz: 0fc6d10f7b3a50ff8659497a2de526f0d9b394751c4b4acd09c1676338dfdb87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5801c6878c83a3ae03eef2af7970b1d492a215705d7105002eadb12740f5af35f383e490dc4bbc02f59a723c56c99e9b9afb2399a35b0f785613abe1ff11a922
|
|
7
|
+
data.tar.gz: 92ffead82d106913bb14ad273096397bb319290b9ce04db46c068412ce3da3cd7dc4670526c50b63413a4f70b10a145bfeef7feecc854abca0480ea3aed7c112
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.11.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ require "neu-mods"
|
|
|
27
27
|
doc = NEU::MODS::Document.parse(xml_string)
|
|
28
28
|
|
|
29
29
|
# Projection (plain data)
|
|
30
|
-
doc.plain_title # => "What's New - 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
|
|
@@ -42,9 +42,23 @@ doc.date_created_parts
|
|
|
42
42
|
# shape it gave, so display cannot invent a month or a
|
|
43
43
|
# day. The points are read by @point, not by document
|
|
44
44
|
# order, and the end carries its OWN precision.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
45
|
+
# A keyDate="yes" node chooses the value, ahead of
|
|
46
|
+
# @point and document order; one date per type is the
|
|
47
|
+
# rule, so an unflagged repeat is discarded.
|
|
48
|
+
# Same for the other six originInfo dates --
|
|
49
|
+
# date_issued, copyright_date, date_captured,
|
|
50
|
+
# date_valid, date_other and date_modified. Each part is
|
|
51
|
+
# also a reader of its own, e.g.
|
|
47
52
|
# doc.date_created_qualifier.
|
|
53
|
+
doc.place_of_publication
|
|
54
|
+
# => ["Boston"] the type="text" placeTerm wins, so a
|
|
55
|
+
# marccountry code does not reach a places facet as a
|
|
56
|
+
# place name; a code-only place still projects its code
|
|
57
|
+
doc.host_collections
|
|
58
|
+
# => [{ title:, volume:, issue:, start_page:, end_page:,
|
|
59
|
+
# date:, text:, details: [...], extents: [...] }, ...]
|
|
60
|
+
# this work's position in its host. The entry survives on
|
|
61
|
+
# its part alone, so a host with no titleInfo is kept
|
|
48
62
|
doc.notes # => [{ type: "funding", value: "..." }, ...]
|
|
49
63
|
doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
|
|
50
64
|
# every relatedItem that is not a series or a host
|
|
@@ -69,7 +83,9 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
|
|
|
69
83
|
# the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
|
|
70
84
|
NEU::MODS.compose_title(non_sort: "", title: "What's New",
|
|
71
85
|
part_name: "How We Respond to Disaster", part_number: "Episode 1")
|
|
72
|
-
# => "What's New - How We Respond to Disaster
|
|
86
|
+
# => "What's New, Episode 1 - How We Respond to Disaster" (== doc.plain_title)
|
|
87
|
+
# The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
|
|
88
|
+
# cataloguing convention, and titleInfo is an unordered choice in the schema.
|
|
73
89
|
|
|
74
90
|
# Selectors (live nodes — for editing)
|
|
75
91
|
node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
|
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
|
|
@@ -205,8 +212,18 @@ module NEU
|
|
|
205
212
|
# cataloguer who records that a person both wrote and edited a work means
|
|
206
213
|
# both. Each term prefers the type="text" roleTerm, falling back to the raw
|
|
207
214
|
# code (NOT MARC-relator-translated -- see README).
|
|
215
|
+
#
|
|
216
|
+
# A name with no name text drops out. A mods:name carrying only a role
|
|
217
|
+
# projected { name: nil, roles: ["edt"] }, which a display renders as a
|
|
218
|
+
# labelled empty row and which every consumer had to guard against with
|
|
219
|
+
# its own compact_blank. #preserved_names deliberately keeps it: that list
|
|
220
|
+
# tells a curator what the XML holds, so an element they need to fix has
|
|
221
|
+
# to stay visible there.
|
|
208
222
|
def names
|
|
209
|
-
doc.xpath("/mods:mods/mods:name", NAMESPACE).
|
|
223
|
+
doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
|
|
224
|
+
entry = name_entry(node)
|
|
225
|
+
entry if entry[:name]
|
|
226
|
+
end
|
|
210
227
|
end
|
|
211
228
|
|
|
212
229
|
# Editable (depositor-managed) creators: the plain names (no authority
|
|
@@ -263,7 +280,25 @@ module NEU
|
|
|
263
280
|
# back by nothing.
|
|
264
281
|
def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
|
|
265
282
|
def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
|
|
266
|
-
|
|
283
|
+
|
|
284
|
+
# Prefer the type="text" term per place, falling back to a coded one --
|
|
285
|
+
# the pattern #role_term_value and #languages already use. Unfiltered, a
|
|
286
|
+
# marccountry code reached the display and the Solr places facet as a
|
|
287
|
+
# place name, so "mau" sat in the list beside Boston. A code still
|
|
288
|
+
# projects when it is all the record gives, because dropping it would
|
|
289
|
+
# lose the only statement the record made.
|
|
290
|
+
#
|
|
291
|
+
# TODO: expand a marccountry code through a registry, as LanguageCodes
|
|
292
|
+
# does for eng -> English. That needs a vendored code list.
|
|
293
|
+
def place_of_publication
|
|
294
|
+
doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
|
|
295
|
+
text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
|
|
296
|
+
next text if text
|
|
297
|
+
|
|
298
|
+
clean(place.at_xpath("mods:placeTerm", NAMESPACE)&.text)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
267
302
|
def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
|
|
268
303
|
|
|
269
304
|
# Serials. The @authority a record puts on a frequency is not projected:
|
|
@@ -338,10 +373,14 @@ module NEU
|
|
|
338
373
|
# record, and a transcribed copy goes stale the moment that record is
|
|
339
374
|
# edited. A part is the exception, because a volume, issue and page range
|
|
340
375
|
# describe this article and no other record holds that fact.
|
|
376
|
+
#
|
|
377
|
+
# An entry survives on its part alone. Requiring a title discarded the
|
|
378
|
+
# one piece of the block that was ours along with the metadata that never
|
|
379
|
+
# was, and how to render a titleless host is the consumer's call.
|
|
341
380
|
def host_collections
|
|
342
381
|
doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
|
|
343
|
-
|
|
344
|
-
|
|
382
|
+
entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
|
|
383
|
+
entry if entry.values.any?
|
|
345
384
|
end
|
|
346
385
|
end
|
|
347
386
|
|
|
@@ -349,6 +388,13 @@ module NEU
|
|
|
349
388
|
# catch-all below does not repeat them.
|
|
350
389
|
NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
|
|
351
390
|
|
|
391
|
+
# The part detail types volume and issue already have named keys on a
|
|
392
|
+
# host entry, so #host_details does not repeat them -- the same split
|
|
393
|
+
# NAMED_RELATED_ITEM_TYPES makes for relatedItem. A caption is kept
|
|
394
|
+
# because it is the label a cataloguer wrote for the number ("chap."
|
|
395
|
+
# before "3"), which no consumer can reconstruct from an open @type.
|
|
396
|
+
NAMED_HOST_DETAIL_TYPES = %w[volume issue].freeze
|
|
397
|
+
|
|
352
398
|
# Every other relatedItem, keeping its @type. MODS also defines
|
|
353
399
|
# constituent, otherFormat, original, preceding, succeeding, isReferencedBy
|
|
354
400
|
# and reviewOf, and a record carrying any of them projected nothing at all.
|
|
@@ -429,9 +475,20 @@ module NEU
|
|
|
429
475
|
# The parts are projected as separate flat fields rather than one nested
|
|
430
476
|
# value, because the value half has three consumers that need a real date
|
|
431
477
|
# object -- a Solr sort key, a citation year and an OAI date.
|
|
478
|
+
# MODS puts seven date elements under originInfo and this reads all of
|
|
479
|
+
# them. dateCaptured is when the object was digitised and dateModified is
|
|
480
|
+
# when the resource changed -- preservation and cataloguing provenance,
|
|
481
|
+
# which a consumer may keep off a page but cannot recover from anywhere
|
|
482
|
+
# else. dateValid is the period the content holds for, and dateOther is
|
|
483
|
+
# where a date fitting no other element lands, which is where a quantity
|
|
484
|
+
# of migrated v1 date data goes.
|
|
432
485
|
def date_created_parts = date_parts("dateCreated")
|
|
433
486
|
def date_issued_parts = date_parts("dateIssued")
|
|
434
487
|
def copyright_date_parts = date_parts("copyrightDate")
|
|
488
|
+
def date_captured_parts = date_parts("dateCaptured")
|
|
489
|
+
def date_valid_parts = date_parts("dateValid")
|
|
490
|
+
def date_other_parts = date_parts("dateOther")
|
|
491
|
+
def date_modified_parts = date_parts("dateModified")
|
|
435
492
|
|
|
436
493
|
def date_created = date_created_parts[:value]
|
|
437
494
|
def date_created_precision = date_created_parts[:precision]
|
|
@@ -454,6 +511,34 @@ module NEU
|
|
|
454
511
|
def copyright_date_qualifier = copyright_date_parts[:qualifier]
|
|
455
512
|
def copyright_date_key_date = copyright_date_parts[:key_date]
|
|
456
513
|
|
|
514
|
+
def date_captured = date_captured_parts[:value]
|
|
515
|
+
def date_captured_precision = date_captured_parts[:precision]
|
|
516
|
+
def date_captured_end = date_captured_parts[:end_value]
|
|
517
|
+
def date_captured_end_precision = date_captured_parts[:end_precision]
|
|
518
|
+
def date_captured_qualifier = date_captured_parts[:qualifier]
|
|
519
|
+
def date_captured_key_date = date_captured_parts[:key_date]
|
|
520
|
+
|
|
521
|
+
def date_valid = date_valid_parts[:value]
|
|
522
|
+
def date_valid_precision = date_valid_parts[:precision]
|
|
523
|
+
def date_valid_end = date_valid_parts[:end_value]
|
|
524
|
+
def date_valid_end_precision = date_valid_parts[:end_precision]
|
|
525
|
+
def date_valid_qualifier = date_valid_parts[:qualifier]
|
|
526
|
+
def date_valid_key_date = date_valid_parts[:key_date]
|
|
527
|
+
|
|
528
|
+
def date_other = date_other_parts[:value]
|
|
529
|
+
def date_other_precision = date_other_parts[:precision]
|
|
530
|
+
def date_other_end = date_other_parts[:end_value]
|
|
531
|
+
def date_other_end_precision = date_other_parts[:end_precision]
|
|
532
|
+
def date_other_qualifier = date_other_parts[:qualifier]
|
|
533
|
+
def date_other_key_date = date_other_parts[:key_date]
|
|
534
|
+
|
|
535
|
+
def date_modified = date_modified_parts[:value]
|
|
536
|
+
def date_modified_precision = date_modified_parts[:precision]
|
|
537
|
+
def date_modified_end = date_modified_parts[:end_value]
|
|
538
|
+
def date_modified_end_precision = date_modified_parts[:end_precision]
|
|
539
|
+
def date_modified_qualifier = date_modified_parts[:qualifier]
|
|
540
|
+
def date_modified_key_date = date_modified_parts[:key_date]
|
|
541
|
+
|
|
457
542
|
# The [value, precision] pair the precision work introduced. Retained
|
|
458
543
|
# because it is the documented entry point for a caller that wants both
|
|
459
544
|
# halves and nothing else.
|
|
@@ -506,8 +591,9 @@ module NEU
|
|
|
506
591
|
edition: :many,
|
|
507
592
|
issuance: :many,
|
|
508
593
|
frequency: :many,
|
|
509
|
-
# Six rows per originInfo date
|
|
510
|
-
#
|
|
594
|
+
# Six rows per originInfo date, for each of the seven MODS defines.
|
|
595
|
+
# Flat rather than one nested value, because the value half has
|
|
596
|
+
# consumers that need a real date object.
|
|
511
597
|
date_created: :one,
|
|
512
598
|
date_created_precision: :one,
|
|
513
599
|
date_created_end: :one,
|
|
@@ -526,6 +612,30 @@ module NEU
|
|
|
526
612
|
copyright_date_end_precision: :one,
|
|
527
613
|
copyright_date_qualifier: :one,
|
|
528
614
|
copyright_date_key_date: :one,
|
|
615
|
+
date_captured: :one,
|
|
616
|
+
date_captured_precision: :one,
|
|
617
|
+
date_captured_end: :one,
|
|
618
|
+
date_captured_end_precision: :one,
|
|
619
|
+
date_captured_qualifier: :one,
|
|
620
|
+
date_captured_key_date: :one,
|
|
621
|
+
date_valid: :one,
|
|
622
|
+
date_valid_precision: :one,
|
|
623
|
+
date_valid_end: :one,
|
|
624
|
+
date_valid_end_precision: :one,
|
|
625
|
+
date_valid_qualifier: :one,
|
|
626
|
+
date_valid_key_date: :one,
|
|
627
|
+
date_other: :one,
|
|
628
|
+
date_other_precision: :one,
|
|
629
|
+
date_other_end: :one,
|
|
630
|
+
date_other_end_precision: :one,
|
|
631
|
+
date_other_qualifier: :one,
|
|
632
|
+
date_other_key_date: :one,
|
|
633
|
+
date_modified: :one,
|
|
634
|
+
date_modified_precision: :one,
|
|
635
|
+
date_modified_end: :one,
|
|
636
|
+
date_modified_end_precision: :one,
|
|
637
|
+
date_modified_qualifier: :one,
|
|
638
|
+
date_modified_key_date: :one,
|
|
529
639
|
|
|
530
640
|
# physical description
|
|
531
641
|
resource_type: :many,
|
|
@@ -621,14 +731,33 @@ module NEU
|
|
|
621
731
|
# One originInfo date element, read by its attributes rather than by
|
|
622
732
|
# position. A record is free to write point="end" first, and taking the
|
|
623
733
|
# first node would then invert the range.
|
|
734
|
+
#
|
|
735
|
+
# ONE DATE PER TYPE is the rule, and a repeated, non-ranged, unflagged
|
|
736
|
+
# date of the same type is discarded. A range is one date with two ends,
|
|
737
|
+
# which @point already models, and every consumer of the value -- a sort
|
|
738
|
+
# key, a citation year, an OAI date -- holds exactly one. Note the
|
|
739
|
+
# contrast with a repeated publisher, which survives because
|
|
740
|
+
# publication_information is :many; dates differ because the value is
|
|
741
|
+
# singular downstream, not because repetition went unnoticed.
|
|
624
742
|
def date_parts(element)
|
|
625
743
|
nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
|
|
626
744
|
return EMPTY_DATE if nodes.empty?
|
|
627
745
|
|
|
628
|
-
start = nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
629
|
-
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
630
746
|
finish = nodes.find { |n| attr_value(n, "point") == "end" }
|
|
631
|
-
date_entry(
|
|
747
|
+
date_entry(start_date_node(nodes), finish, nodes)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# The node the value comes from: the flagged one, then the declared
|
|
751
|
+
# start, then document order.
|
|
752
|
+
#
|
|
753
|
+
# keyDate leads because the flag is the record nominating its own
|
|
754
|
+
# principal date. Projecting the flag while reading the value from a
|
|
755
|
+
# different node made the two contradict each other -- a consumer was told
|
|
756
|
+
# the record chose this date and then handed the one before it.
|
|
757
|
+
def start_date_node(nodes)
|
|
758
|
+
nodes.find { |n| attr_value(n, "keyDate") == "yes" && attr_value(n, "point") != "end" } ||
|
|
759
|
+
nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
760
|
+
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
632
761
|
end
|
|
633
762
|
|
|
634
763
|
# The end point carries its OWN precision. "1935-06" to "1940" is legal,
|
|
@@ -738,6 +867,14 @@ module NEU
|
|
|
738
867
|
# punctuation of a citation is display policy, the same call #map_data
|
|
739
868
|
# makes for cartographics. MODS leaves @unit optional, so a page extent
|
|
740
869
|
# without one is read rather than dropped.
|
|
870
|
+
#
|
|
871
|
+
# Volume, issue and the page range keep named keys because they are the
|
|
872
|
+
# citation and a consumer asks for them by name. Everything else the
|
|
873
|
+
# schema allows under part arrives structured, because detail/@type and
|
|
874
|
+
# extent/@unit are open strings -- a fixed key per type cannot cover a
|
|
875
|
+
# vocabulary the schema does not close. #date is the article's year within
|
|
876
|
+
# the host, which after the title is the most-cited element of a journal
|
|
877
|
+
# citation and was reaching no consumer at all.
|
|
741
878
|
def host_part(node)
|
|
742
879
|
part = node.at_xpath("mods:part", NAMESPACE)
|
|
743
880
|
return {} if part.nil?
|
|
@@ -747,8 +884,46 @@ module NEU
|
|
|
747
884
|
volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
|
|
748
885
|
issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
|
|
749
886
|
start_page: child_text(part, "#{pages}/mods:start"),
|
|
750
|
-
end_page: child_text(part, "#{pages}/mods:end")
|
|
751
|
-
|
|
887
|
+
end_page: child_text(part, "#{pages}/mods:end"),
|
|
888
|
+
date: child_text(part, "mods:date"),
|
|
889
|
+
text: child_text(part, "mods:text"),
|
|
890
|
+
details: host_details(part),
|
|
891
|
+
extents: host_extents(part)
|
|
892
|
+
}.reject { |_, value| value.nil? || value == [] }
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def host_details(part)
|
|
896
|
+
part.xpath("mods:detail", NAMESPACE).filter_map do |node|
|
|
897
|
+
type = clean(node["type"])
|
|
898
|
+
next if NAMED_HOST_DETAIL_TYPES.include?(type)
|
|
899
|
+
|
|
900
|
+
entry = {
|
|
901
|
+
type: type,
|
|
902
|
+
number: child_text(node, "mods:number"),
|
|
903
|
+
caption: child_text(node, "mods:caption"),
|
|
904
|
+
title: child_text(node, "mods:title")
|
|
905
|
+
}
|
|
906
|
+
entry if entry.except(:type).values.any?
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# Every extent EXCEPT the page range, which start_page and end_page hold.
|
|
911
|
+
# A unit other than page -- the minutes of a recording, the columns of a
|
|
912
|
+
# newspaper -- means nothing without its unit, so the unit travels with
|
|
913
|
+
# the numbers rather than being flattened away.
|
|
914
|
+
def host_extents(part)
|
|
915
|
+
part.xpath("mods:extent", NAMESPACE).filter_map do |node|
|
|
916
|
+
unit = clean(node["unit"])
|
|
917
|
+
next if unit.nil? || unit == "page"
|
|
918
|
+
|
|
919
|
+
{
|
|
920
|
+
unit: unit,
|
|
921
|
+
start: child_text(node, "mods:start"),
|
|
922
|
+
end: child_text(node, "mods:end"),
|
|
923
|
+
total: child_text(node, "mods:total"),
|
|
924
|
+
list: child_text(node, "mods:list")
|
|
925
|
+
}
|
|
926
|
+
end
|
|
752
927
|
end
|
|
753
928
|
|
|
754
929
|
def text_at(xpath)
|
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
|
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.11.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-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|