neu-mods 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +32 -0
- data/README.md +62 -223
- data/lib/neu/mods/builders.rb +63 -0
- data/lib/neu/mods/canonicalize.rb +6 -151
- data/lib/neu/mods/document.rb +5 -0
- data/lib/neu/mods/namespaces.rb +13 -0
- data/lib/neu/mods/projection/access.rb +74 -0
- data/lib/neu/mods/projection/dates.rb +170 -0
- data/lib/neu/mods/projection/identifiers.rb +80 -0
- data/lib/neu/mods/projection/languages.rb +59 -0
- data/lib/neu/mods/projection/name_display.rb +110 -0
- data/lib/neu/mods/projection/names.rb +71 -0
- data/lib/neu/mods/projection/origin_info.rb +86 -0
- data/lib/neu/mods/projection/physical_description.rb +53 -0
- data/lib/neu/mods/projection/related_items.rb +100 -0
- data/lib/neu/mods/projection/subjects.rb +148 -0
- data/lib/neu/mods/projection/support.rb +139 -0
- data/lib/neu/mods/projection/titles.rb +109 -0
- data/lib/neu/mods/projection.rb +41 -1516
- data/lib/neu/mods/selectors.rb +19 -94
- data/lib/neu/mods/text_normalizer.rb +118 -0
- data/lib/neu-mods.rb +6 -12
- metadata +57 -14
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NEU
|
|
4
|
+
module MODS
|
|
5
|
+
# The MODS v3 namespace, as a Nokogiri xpath namespace hash.
|
|
6
|
+
NAMESPACE = { "mods" => "http://www.loc.gov/mods/v3" }.freeze
|
|
7
|
+
|
|
8
|
+
# XLink, which MODS uses for the @xlink:href a display hyperlinks an element
|
|
9
|
+
# to. Held as a bare URI rather than a prefix map because the attribute is
|
|
10
|
+
# read by namespace: a document may bind XLink to any prefix it likes.
|
|
11
|
+
XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../namespaces"
|
|
4
|
+
require_relative "../canonicalize"
|
|
5
|
+
require_relative "../selectors"
|
|
6
|
+
require_relative "support"
|
|
7
|
+
|
|
8
|
+
module NEU
|
|
9
|
+
module MODS
|
|
10
|
+
module Projection
|
|
11
|
+
# The abstract and the access conditions, each joined into one value with
|
|
12
|
+
# its header and link as companion scalars. See docs/other-fields.md.
|
|
13
|
+
module Access
|
|
14
|
+
include Support
|
|
15
|
+
include Selectors
|
|
16
|
+
|
|
17
|
+
def abstract
|
|
18
|
+
join_paragraphs(abstract_nodes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Companion scalars, because #abstract joins every element into one string.
|
|
22
|
+
def abstract_display_label = first_attr(abstract_nodes, "displayLabel")
|
|
23
|
+
def abstract_href = first_href(abstract_nodes)
|
|
24
|
+
|
|
25
|
+
# Every accessCondition, whatever its type. The only field that carries an
|
|
26
|
+
# untyped or unrecognised one.
|
|
27
|
+
def access_condition
|
|
28
|
+
join_paragraphs(doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Kept apart so a restriction is never shown to a reader as a licence.
|
|
32
|
+
def use_and_reproduction = access_conditions_of_type("use and reproduction")
|
|
33
|
+
def restriction_on_access = access_conditions_of_type("restriction on access")
|
|
34
|
+
|
|
35
|
+
def access_condition_display_label = first_attr(access_condition_nodes, "displayLabel")
|
|
36
|
+
def access_condition_href = first_href(access_condition_nodes)
|
|
37
|
+
|
|
38
|
+
def use_and_reproduction_display_label
|
|
39
|
+
first_attr(access_condition_nodes("use and reproduction"), "displayLabel")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def use_and_reproduction_href = first_href(access_condition_nodes("use and reproduction"))
|
|
43
|
+
|
|
44
|
+
def restriction_on_access_display_label
|
|
45
|
+
first_attr(access_condition_nodes("restriction on access"), "displayLabel")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def restriction_on_access_href = first_href(access_condition_nodes("restriction on access"))
|
|
49
|
+
|
|
50
|
+
# Letters and digits only, lower-cased: records write @type in any casing.
|
|
51
|
+
def self.fold_type(str)
|
|
52
|
+
Canonicalize.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Shared by the text and its companions, so a header always comes from
|
|
58
|
+
# the elements the value does.
|
|
59
|
+
def access_condition_nodes(type = nil)
|
|
60
|
+
nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
|
|
61
|
+
return nodes if type.nil?
|
|
62
|
+
|
|
63
|
+
wanted = Access.fold_type(type)
|
|
64
|
+
nodes.select { |node| Access.fold_type(node["type"]) == wanted }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Matched on the folded @type, not in the XPath, because @type is open.
|
|
68
|
+
def access_conditions_of_type(type)
|
|
69
|
+
join_paragraphs(access_condition_nodes(type))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "../namespaces"
|
|
5
|
+
require_relative "../canonicalize"
|
|
6
|
+
require_relative "support"
|
|
7
|
+
|
|
8
|
+
module NEU
|
|
9
|
+
module MODS
|
|
10
|
+
module Projection
|
|
11
|
+
# The seven originInfo date elements, each projected as nine flat fields.
|
|
12
|
+
# See docs/dates.md, which lists every generated field.
|
|
13
|
+
module Dates
|
|
14
|
+
include Support
|
|
15
|
+
|
|
16
|
+
# Year, year-month, full date, or full date with a time. Never fall back
|
|
17
|
+
# to a bare DateTime.parse: it fills missing parts from TODAY, so "19uu"
|
|
18
|
+
# would parse as the current date.
|
|
19
|
+
W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
|
|
20
|
+
|
|
21
|
+
# The same shapes without hyphens, read ONLY under @encoding="iso8601":
|
|
22
|
+
# an accession number is eight digits too.
|
|
23
|
+
ISO8601_BASIC_DATE = /\A(\d{4})(?:(\d{2})(?:(\d{2})(T\S+)?)?)?\z/
|
|
24
|
+
|
|
25
|
+
# @encoding, folded, so "ISO-8601" matches too.
|
|
26
|
+
ISO8601_ENCODING = "iso8601"
|
|
27
|
+
|
|
28
|
+
# Field prefix => the MODS element it reads, in FIELDS order. Written out
|
|
29
|
+
# rather than derived, so a search for a field name lands here.
|
|
30
|
+
DATE_FIELDS = {
|
|
31
|
+
date_created: "dateCreated",
|
|
32
|
+
date_issued: "dateIssued",
|
|
33
|
+
copyright_date: "copyrightDate",
|
|
34
|
+
date_captured: "dateCaptured",
|
|
35
|
+
date_valid: "dateValid",
|
|
36
|
+
date_other: "dateOther",
|
|
37
|
+
date_modified: "dateModified"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Field suffix => the key of the #date_parts entry it reads. Each element
|
|
41
|
+
# projects one field per row.
|
|
42
|
+
DATE_KEYS = {
|
|
43
|
+
"" => :value,
|
|
44
|
+
"_precision" => :precision,
|
|
45
|
+
"_end" => :end_value,
|
|
46
|
+
"_end_precision" => :end_precision,
|
|
47
|
+
"_qualifier" => :qualifier,
|
|
48
|
+
"_key_date" => :key_date,
|
|
49
|
+
"_text" => :text,
|
|
50
|
+
"_display_label" => :display_label,
|
|
51
|
+
"_event_type" => :event_type
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# What #date_parts returns when the element is absent entirely, so an
|
|
55
|
+
# absent date is distinguishable from one present and unparseable.
|
|
56
|
+
EMPTY_DATE = DATE_KEYS.values.to_h { |key| [key, nil] }.freeze
|
|
57
|
+
|
|
58
|
+
# The FIELDS rows for the generated fields, each element's rows together.
|
|
59
|
+
DATE_FIELD_ROWS = DATE_FIELDS.keys.product(DATE_KEYS.keys)
|
|
60
|
+
.to_h { |field, suffix| [:"#{field}#{suffix}", :one] }.freeze
|
|
61
|
+
|
|
62
|
+
# The same seven elements as DATE_FIELDS, in the order a consumer deciding
|
|
63
|
+
# a place header reads them. Keep this order: #origin_date_elements
|
|
64
|
+
# returns elements in it.
|
|
65
|
+
DATE_ELEMENTS = %w[dateIssued dateCreated copyrightDate dateCaptured
|
|
66
|
+
dateValid dateOther dateModified].freeze
|
|
67
|
+
|
|
68
|
+
# A *_parts method per element and an accessor per DATE_FIELDS and
|
|
69
|
+
# DATE_KEYS pair (date_issued_end, date_created_text, ...). A search for
|
|
70
|
+
# `def date_issued_end` finds nothing; search the two tables instead.
|
|
71
|
+
DATE_FIELDS.each do |field, element|
|
|
72
|
+
define_method(:"#{field}_parts") { date_parts(element) }
|
|
73
|
+
DATE_KEYS.each do |suffix, key|
|
|
74
|
+
define_method(:"#{field}#{suffix}") { date_parts(element)[key] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# [value, precision], for a caller that wants those two alone.
|
|
79
|
+
def date_created_with_precision = [date_created, date_created_precision]
|
|
80
|
+
def date_issued_with_precision = [date_issued, date_issued_precision]
|
|
81
|
+
def copyright_date_with_precision = [copyright_date, copyright_date_precision]
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# [DateTime, precision], or nil for no shape or an impossible date
|
|
86
|
+
# (2026-02-30). A timestamp is parsed whole, so its time of day survives.
|
|
87
|
+
def parse_shaped_date(regexp, str)
|
|
88
|
+
m = regexp.match(str)
|
|
89
|
+
return nil unless m
|
|
90
|
+
return [DateTime.parse(str), "day"] if m[4]
|
|
91
|
+
|
|
92
|
+
[DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), declared_precision(m)]
|
|
93
|
+
rescue Date::Error
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# w3cdtf always, then the basic ISO form only if the record declares it.
|
|
98
|
+
def parse_declared_date(str, encoding)
|
|
99
|
+
parse_shaped_date(W3CDTF_DATE, str) ||
|
|
100
|
+
(iso8601?(encoding) ? parse_shaped_date(ISO8601_BASIC_DATE, str) : nil)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def iso8601?(encoding)
|
|
104
|
+
encoding.to_s.downcase.delete("-") == ISO8601_ENCODING
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The granularity the record stopped at.
|
|
108
|
+
def declared_precision(match)
|
|
109
|
+
return "day" if match[3]
|
|
110
|
+
|
|
111
|
+
match[2] ? "month" : "year"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Everything a record declared about one date element. Nodes are chosen
|
|
115
|
+
# by @point and @keyDate, never by position: a record may write the end
|
|
116
|
+
# first. One date per element; see docs/dates.md.
|
|
117
|
+
def date_parts(element)
|
|
118
|
+
nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
|
|
119
|
+
return EMPTY_DATE if nodes.empty?
|
|
120
|
+
|
|
121
|
+
finish = nodes.find { |n| attr_value(n, "point") == "end" }
|
|
122
|
+
date_entry(start_date_node(nodes), finish, nodes)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The flagged node, then the declared start, then document order. The
|
|
126
|
+
# flag leads so the value and #key_date always describe the same node.
|
|
127
|
+
def start_date_node(nodes)
|
|
128
|
+
nodes.find { |n| attr_value(n, "keyDate") == "yes" && attr_value(n, "point") != "end" } ||
|
|
129
|
+
nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
130
|
+
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The end carries its OWN precision ("1935-06" to "1940" is legal). The
|
|
134
|
+
# qualifier falls back from the start to the end, because v1 applied it
|
|
135
|
+
# to both points.
|
|
136
|
+
def date_entry(start, finish, nodes)
|
|
137
|
+
value, precision, text = node_date(start)
|
|
138
|
+
end_value, end_precision = node_date(finish)
|
|
139
|
+
origin = (start || finish)&.parent
|
|
140
|
+
{
|
|
141
|
+
value: value,
|
|
142
|
+
precision: precision,
|
|
143
|
+
end_value: end_value,
|
|
144
|
+
end_precision: end_precision,
|
|
145
|
+
qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
|
|
146
|
+
key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
|
|
147
|
+
text: text,
|
|
148
|
+
# Off the enclosing originInfo, because that is where MODS puts both.
|
|
149
|
+
display_label: attr_value(origin, "displayLabel"),
|
|
150
|
+
event_type: attr_value(origin, "eventType")
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# [value, precision, text]. An unreadable date keeps its literal as text
|
|
155
|
+
# ("ca. 1920") instead of a value: never invent a date, never drop one.
|
|
156
|
+
def node_date(node)
|
|
157
|
+
return [nil, nil, nil] unless node
|
|
158
|
+
|
|
159
|
+
str = Canonicalize.canonical_ws(node.text)
|
|
160
|
+
return [nil, nil, nil] if str.empty?
|
|
161
|
+
|
|
162
|
+
parsed = parse_declared_date(str, attr_value(node, "encoding"))
|
|
163
|
+
return [parsed[0], parsed[1], nil] if parsed
|
|
164
|
+
|
|
165
|
+
[nil, nil, str]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../namespaces"
|
|
4
|
+
require_relative "support"
|
|
5
|
+
|
|
6
|
+
module NEU
|
|
7
|
+
module MODS
|
|
8
|
+
module Projection
|
|
9
|
+
# Identifiers, classification, location and recordInfo: what the record
|
|
10
|
+
# is called elsewhere and where the resource is held. See docs/other-fields.md.
|
|
11
|
+
module Identifiers
|
|
12
|
+
include Support
|
|
13
|
+
|
|
14
|
+
# An LCC or DDC call number, not Atlas's classification_ssim.
|
|
15
|
+
def classification = labeled_texts_at("/mods:mods/mods:classification")
|
|
16
|
+
|
|
17
|
+
# Typed, so a display can tell a DOI from an accession number, and
|
|
18
|
+
# flagged when @invalid="yes" marks the identifier dead.
|
|
19
|
+
def identifiers
|
|
20
|
+
doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
|
|
21
|
+
value = clean(node.text)
|
|
22
|
+
if value
|
|
23
|
+
{ type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes",
|
|
24
|
+
**qualifiers_of(node) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def permanent_url
|
|
30
|
+
clean(handle_node&.text)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# No href companion: the value is the URL.
|
|
34
|
+
def permanent_url_display_label
|
|
35
|
+
attr_value(handle_node, "displayLabel")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The parts stay apart: a shelf mark and a URL are different kinds. The
|
|
39
|
+
# element is shelfLocator; MODS has no shelfLocation.
|
|
40
|
+
def location
|
|
41
|
+
doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
|
|
42
|
+
entry = {
|
|
43
|
+
physical_location: child_text(node, "mods:physicalLocation"),
|
|
44
|
+
shelf_location: child_text(node, "mods:shelfLocator"),
|
|
45
|
+
url: child_text(node, "mods:url")
|
|
46
|
+
}
|
|
47
|
+
entry.merge(qualifiers_of(node)) if entry.values.any?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# recordInfo children, read as one value.
|
|
52
|
+
RECORD_INFO_PARTS = {
|
|
53
|
+
content_source: "mods:recordContentSource",
|
|
54
|
+
origin: "mods:recordOrigin",
|
|
55
|
+
description_standard: "mods:descriptionStandard",
|
|
56
|
+
creation_date: "mods:recordCreationDate",
|
|
57
|
+
change_date: "mods:recordChangeDate",
|
|
58
|
+
language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Who catalogued the record, to what standard, and when.
|
|
62
|
+
def record_info
|
|
63
|
+
node = doc.at_xpath("/mods:mods/mods:recordInfo", NAMESPACE)
|
|
64
|
+
return nil unless node
|
|
65
|
+
|
|
66
|
+
entry = RECORD_INFO_PARTS.transform_values { |xpath| child_text(node, xpath) }
|
|
67
|
+
entry if entry.values.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# The handle identifier, which both the permanent URL and its header
|
|
73
|
+
# are read from.
|
|
74
|
+
def handle_node
|
|
75
|
+
doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../namespaces"
|
|
4
|
+
require_relative "../language_codes"
|
|
5
|
+
require_relative "support"
|
|
6
|
+
|
|
7
|
+
module NEU
|
|
8
|
+
module MODS
|
|
9
|
+
module Projection
|
|
10
|
+
# Top-level language elements, a code-only term read through the ISO 639
|
|
11
|
+
# registry. See docs/other-fields.md.
|
|
12
|
+
module Languages
|
|
13
|
+
include Support
|
|
14
|
+
|
|
15
|
+
# { term:, object_part:, script: } per language element. An entry, because
|
|
16
|
+
# @objectPart changes what the record claims.
|
|
17
|
+
def languages
|
|
18
|
+
doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
|
|
19
|
+
node = language_term_node(lang)
|
|
20
|
+
term = language_term_of(node)
|
|
21
|
+
next unless term
|
|
22
|
+
|
|
23
|
+
# The authority comes off the <languageTerm>, never the <language>:
|
|
24
|
+
# MODS puts @authority on the term.
|
|
25
|
+
{ term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang),
|
|
26
|
+
**authority_of(node), **qualifiers_of(lang) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Located apart from its text, so the term and its authority come from one
|
|
33
|
+
# node.
|
|
34
|
+
def language_term_node(lang)
|
|
35
|
+
lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE) ||
|
|
36
|
+
lang.at_xpath("mods:languageTerm", NAMESPACE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A text term as written; a code through the ISO 639 registry.
|
|
40
|
+
def language_term_of(node)
|
|
41
|
+
return nil unless node
|
|
42
|
+
|
|
43
|
+
return clean(node.text) if attr_value(node, "type") == "text"
|
|
44
|
+
|
|
45
|
+
code = clean(node.text)
|
|
46
|
+
code && LanguageCodes.term(code)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Text form preferred, never translated: ISO 15924 is not vendored.
|
|
50
|
+
def script_term(lang)
|
|
51
|
+
text = lang.at_xpath("mods:scriptTerm[@type='text']", NAMESPACE)
|
|
52
|
+
return clean(text.text) if text
|
|
53
|
+
|
|
54
|
+
clean(lang.at_xpath("mods:scriptTerm", NAMESPACE)&.text)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../namespaces"
|
|
4
|
+
require_relative "support"
|
|
5
|
+
|
|
6
|
+
module NEU
|
|
7
|
+
module MODS
|
|
8
|
+
module Projection
|
|
9
|
+
# Faithful port of the mods gem's display_value_w_date, quirks included,
|
|
10
|
+
# plus the role readers that decide whether the edit form owns a name.
|
|
11
|
+
# See docs/names.md.
|
|
12
|
+
module NameDisplay
|
|
13
|
+
include Support
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Read every namePart through #part_text, so the separators composed here
|
|
18
|
+
# are the only whitespace in the result.
|
|
19
|
+
def name_display_value_w_date(node, type = attr_value(node, "type"))
|
|
20
|
+
dv = name_display_value(node, type)
|
|
21
|
+
node.xpath("mods:namePart[@type='date']", NAMESPACE).each do |np|
|
|
22
|
+
d = part_text(np)
|
|
23
|
+
dv += ", #{d}" unless d.empty? || dv.end_with?(d)
|
|
24
|
+
end
|
|
25
|
+
dv = dv.delete_prefix(", ")
|
|
26
|
+
dv.strip.empty? ? nil : dv.strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name_display_value(node, type)
|
|
30
|
+
display_form = part_text(node.at_xpath("mods:displayForm", NAMESPACE))
|
|
31
|
+
return display_form unless display_form.empty?
|
|
32
|
+
|
|
33
|
+
type == "personal" ? personal_display_value(node) : non_date_parts_joined(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def personal_display_value(node)
|
|
37
|
+
family = joined_parts(node, "family")
|
|
38
|
+
given = joined_parts(node, "given")
|
|
39
|
+
dv =
|
|
40
|
+
if family.empty?
|
|
41
|
+
given
|
|
42
|
+
else
|
|
43
|
+
given.empty? ? family : "#{family}, #{given}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return non_date_parts_joined(node) if dv.empty?
|
|
47
|
+
|
|
48
|
+
append_terms_of_address(node, dv)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def append_terms_of_address(node, dv)
|
|
52
|
+
first = true
|
|
53
|
+
node.xpath("mods:namePart[@type='termsOfAddress']", NAMESPACE).each do |np|
|
|
54
|
+
term = part_text(np)
|
|
55
|
+
next if term.empty?
|
|
56
|
+
|
|
57
|
+
dv += first ? " #{term}" : ", #{term}"
|
|
58
|
+
first = false
|
|
59
|
+
end
|
|
60
|
+
dv
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# No separator, as the mods gem's NodeSet#text had: two `given` parts
|
|
64
|
+
# become "A.(B)". The conformance spec pins this quirk.
|
|
65
|
+
def joined_parts(node, type)
|
|
66
|
+
node.xpath("mods:namePart[@type='#{type}']", NAMESPACE).map { |np| part_text(np) }.join
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def non_date_parts_joined(node)
|
|
70
|
+
node.xpath("mods:namePart", NAMESPACE)
|
|
71
|
+
.reject { |np| np["type"] == "date" }
|
|
72
|
+
.map { |np| part_text(np) }
|
|
73
|
+
.reject(&:empty?)
|
|
74
|
+
.join(" ")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# One name element's text, whitespace-canonicalized, "" when absent.
|
|
78
|
+
def part_text(node)
|
|
79
|
+
clean_part(node&.text)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def name_roles(node)
|
|
83
|
+
node.xpath("mods:role", NAMESPACE).filter_map { |role| role_term_value(role) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The first declared role. Do not widen this to "any role is Creator":
|
|
87
|
+
# the form would then own a name whose other roles it cannot represent,
|
|
88
|
+
# and saving would drop them from the preservation XML.
|
|
89
|
+
def name_role(node) = name_roles(node).first
|
|
90
|
+
|
|
91
|
+
# The text roleTerm, else the raw code, untranslated. nil if neither.
|
|
92
|
+
def role_term_value(role)
|
|
93
|
+
%w[text code].each do |type|
|
|
94
|
+
term = part_text(role.at_xpath("mods:roleTerm[@type='#{type}']", NAMESPACE))
|
|
95
|
+
return term unless term.empty?
|
|
96
|
+
end
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# No authority attribute and a Creator role. Shared by the write path's
|
|
101
|
+
# node selection and the read path's pre-fill, and reads the attributes
|
|
102
|
+
# the way #authority_of does, so the two never disagree.
|
|
103
|
+
def editable_creator_name?(node)
|
|
104
|
+
AUTHORITY_ATTRIBUTES.values.none? { |attr| attr_value(node, attr) } &&
|
|
105
|
+
name_role(node) == "Creator"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../namespaces"
|
|
4
|
+
require_relative "../selectors"
|
|
5
|
+
require_relative "support"
|
|
6
|
+
require_relative "name_display"
|
|
7
|
+
|
|
8
|
+
module NEU
|
|
9
|
+
module MODS
|
|
10
|
+
module Projection
|
|
11
|
+
# Top-level names: the composed entries for the access copy and the
|
|
12
|
+
# editable and preserved sets the edit form reads. See docs/names.md.
|
|
13
|
+
module Names
|
|
14
|
+
include Support
|
|
15
|
+
include NameDisplay
|
|
16
|
+
include Selectors
|
|
17
|
+
|
|
18
|
+
# One name as the access copy wants it.
|
|
19
|
+
def name_entry(node)
|
|
20
|
+
{
|
|
21
|
+
name: name_display_value_w_date(node),
|
|
22
|
+
roles: name_roles(node),
|
|
23
|
+
affiliation: texts_under(node, "mods:affiliation"),
|
|
24
|
+
usage: attr_value(node, "usage"),
|
|
25
|
+
alternative_names: alternative_names(node),
|
|
26
|
+
# Off the <name> alone: a marcrelator <roleTerm> says what the person
|
|
27
|
+
# did, not which list the name came from.
|
|
28
|
+
**authority_of(node),
|
|
29
|
+
**qualifiers_of(node)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Composed with the ENCLOSING name's @type: alternativeName carries
|
|
34
|
+
# @altType, and an alternative for a personal name is still personal.
|
|
35
|
+
def alternative_names(node)
|
|
36
|
+
node.xpath("mods:alternativeName", NAMESPACE).filter_map do |alt|
|
|
37
|
+
name_display_value_w_date(alt, attr_value(node, "type"))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Every top-level name with name text. #preserved_names keeps a name with
|
|
42
|
+
# none, so a curator can still see the element to fix it.
|
|
43
|
+
def names
|
|
44
|
+
doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
|
|
45
|
+
entry = name_entry(node)
|
|
46
|
+
entry if entry[:name]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The plain Creator names the edit form manages, as structured parts
|
|
51
|
+
# for pre-fill rather than composed display strings.
|
|
52
|
+
def editable_personal_creators
|
|
53
|
+
editable_creator_nodes("personal").map do |node|
|
|
54
|
+
{ given: clean_part(joined_parts(node, "given")), family: clean_part(joined_parts(node, "family")) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def editable_corporate_creators
|
|
59
|
+
editable_creator_nodes("corporate").map { |node| { name: clean_part(non_date_parts_joined(node)) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The names the edit form does not manage, for read-only display.
|
|
63
|
+
def preserved_names
|
|
64
|
+
doc.xpath("/mods:mods/mods:name", NAMESPACE)
|
|
65
|
+
.reject { |node| editable_creator_name?(node) }
|
|
66
|
+
.map { |node| name_entry(node) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../namespaces"
|
|
4
|
+
require_relative "support"
|
|
5
|
+
require_relative "names"
|
|
6
|
+
require_relative "dates"
|
|
7
|
+
|
|
8
|
+
module NEU
|
|
9
|
+
module MODS
|
|
10
|
+
module Projection
|
|
11
|
+
# The non-date originInfo children, each carrying its block's header
|
|
12
|
+
# attributes. See docs/other-fields.md.
|
|
13
|
+
module OriginInfo
|
|
14
|
+
include Support
|
|
15
|
+
include Names
|
|
16
|
+
include Dates
|
|
17
|
+
|
|
18
|
+
# Headed by the enclosing originInfo: MODS puts neither attribute on its
|
|
19
|
+
# children.
|
|
20
|
+
def publication_information = origin_texts_at("mods:publisher")
|
|
21
|
+
def edition = origin_texts_at("mods:edition")
|
|
22
|
+
|
|
23
|
+
# A bare marccountry code ("mau") is not a place name, so it drops.
|
|
24
|
+
# TODO: expand marccountry codes through a vendored registry, as
|
|
25
|
+
# LanguageCodes does for eng -> English.
|
|
26
|
+
MARC_COUNTRY_AUTHORITY = "marccountry"
|
|
27
|
+
|
|
28
|
+
def place_of_publication
|
|
29
|
+
doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
|
|
30
|
+
value = place_term_value(place)
|
|
31
|
+
next unless value
|
|
32
|
+
|
|
33
|
+
{ value: value, **origin_qualifiers_of(place.parent),
|
|
34
|
+
date_elements: origin_date_elements(place.parent) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# originInfo/agent, composed like a top-level name.
|
|
39
|
+
def origin_agents
|
|
40
|
+
doc.xpath("/mods:mods/mods:originInfo/mods:agent", NAMESPACE).filter_map do |node|
|
|
41
|
+
entry = name_entry(node)
|
|
42
|
+
entry.merge(event_type: attr_value(node.parent, "eventType")) if entry[:name]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def place_term_value(place)
|
|
47
|
+
text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
|
|
48
|
+
return text if text
|
|
49
|
+
|
|
50
|
+
code = place.at_xpath("mods:placeTerm", NAMESPACE)
|
|
51
|
+
return nil if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
|
|
52
|
+
|
|
53
|
+
clean(code&.text)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def issuance = origin_texts_at("mods:issuance")
|
|
57
|
+
|
|
58
|
+
def frequency = origin_texts_at("mods:frequency")
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The general pair plus @eventType, which MODS puts on originInfo alone.
|
|
63
|
+
def origin_qualifiers_of(node)
|
|
64
|
+
qualifiers_of(node).merge(event_type: attr_value(node, "eventType"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `xpath` is relative to each originInfo, which the qualifiers come from.
|
|
68
|
+
def origin_texts_at(xpath)
|
|
69
|
+
doc.xpath("/mods:mods/mods:originInfo", NAMESPACE).flat_map do |origin|
|
|
70
|
+
origin.xpath(xpath, NAMESPACE).filter_map do |node|
|
|
71
|
+
value = clean(node.text)
|
|
72
|
+
{ value: value, **origin_qualifiers_of(origin) } if value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The date elements beside a place, which decide how its row is headed.
|
|
78
|
+
def origin_date_elements(origin)
|
|
79
|
+
return [] unless origin
|
|
80
|
+
|
|
81
|
+
DATE_ELEMENTS.select { |name| origin.at_xpath("mods:#{name}", NAMESPACE) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|