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.
@@ -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