neu-mods 0.13.0 → 0.14.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/CHANGELOG.md +57 -0
- data/README.md +30 -0
- data/lib/neu/mods/projection.rb +156 -20
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07f1d513851b2423d9d5f9c5a12537aef16005ed8e86e699d34cdd3b01b65a5a
|
|
4
|
+
data.tar.gz: fa862f2409dc0dd04344179cf400bd06ad24f725178f43ce64459133c9e05cdb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7867291e7502916ebdb5f409827158867f97c667b1907dfeb979a6034287daa2a1db714b11185fb5fe50e2aaafb9da9177d391f0b4241b98b6e545ef3d9c901f
|
|
7
|
+
data.tar.gz: 47fcd25686fc79ccdd1446a242c9d226277ce93cdccccaadd9b36f164087c2cfdc3f052f4fa7df2db171dc6a5ea806afbf69be59338736c4f6328f93d623ed1a
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.14.0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Consumers pin this gem, so a change to a projected SHAPE belongs here: Atlas
|
|
4
|
+
derives its access-copy attribute set and its Solr indexer from the projection,
|
|
5
|
+
and Cerberus pre-fills its edit forms from it. A shape change that reaches them
|
|
6
|
+
unannounced is one they discover as a nil value or a missing display row.
|
|
7
|
+
|
|
8
|
+
Releases before 0.14.0 are not recorded. Their diffs are in git; this file
|
|
9
|
+
starts where the convention does.
|
|
10
|
+
|
|
11
|
+
## 0.14.0
|
|
12
|
+
|
|
13
|
+
The projection gains the facts a consumer needs to offer a metadata value as a
|
|
14
|
+
browse link. Additive, but four entry shapes grow keys.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **The vocabulary a value was taken from**, as `authority:`,
|
|
19
|
+
`authority_uri:` and `value_uri:` on `names`, `origin_agents`, `languages`,
|
|
20
|
+
`genres` and `subject_headings`. Resolved off the element holding the value,
|
|
21
|
+
then off an enclosing `<subject>`; never off a `<role>`/`<roleTerm>`, which
|
|
22
|
+
names the relator's vocabulary rather than the name's. The gem tested these
|
|
23
|
+
attributes to decide editability (`Selectors#editable_creator_name?`,
|
|
24
|
+
`#keyword_subject?`) and projected none of them.
|
|
25
|
+
- **`subject_headings[:heading]`** — the parts joined with ` -- `. The join
|
|
26
|
+
moves into the gem because the composed heading is now both the string a
|
|
27
|
+
display renders and the string a browse index holds; two callers joining
|
|
28
|
+
independently is how those two drift apart. `parts:` stays, for the Advanced
|
|
29
|
+
edit form and for a consumer wanting one step.
|
|
30
|
+
- **`subject_headings[:axis]`** — the MODS element the heading's main term came
|
|
31
|
+
from, which is its first child carrying heading text. A consumer cannot
|
|
32
|
+
derive it from `parts:`, and it is what says which browse a heading belongs
|
|
33
|
+
to: `Salt marshes -- Massachusetts` is a topic heading with a place
|
|
34
|
+
subdivision, not a place. A `<name>` axis splits by `@type` into
|
|
35
|
+
`personal_name` or `corporate_name`.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **`corporate_name_subjects` now includes a `<subject><name>` with no
|
|
40
|
+
`@type`.** Such a name already displayed, because `#subject_heading_part`
|
|
41
|
+
never consulted `@type`, and it reached no axis, because both axis
|
|
42
|
+
projections required it. Corporate rather than personal: MODS expects
|
|
43
|
+
`@type="personal"` on a person, and the untyped subject names DRS holds are
|
|
44
|
+
institutional. `personal_name_subjects` is unchanged.
|
|
45
|
+
|
|
46
|
+
### Not changed
|
|
47
|
+
|
|
48
|
+
- **The per-axis subject projections stay plain string arrays.**
|
|
49
|
+
`topical_subjects` and its siblings carry no authority: `subject_headings`
|
|
50
|
+
reports it per heading, which is what a display and a browse index both read,
|
|
51
|
+
and nothing consumes a vocabulary on the flat axes.
|
|
52
|
+
- **The other labeled fields carry no authority either.** `format`, `extent`,
|
|
53
|
+
`classification` and the rest keep `{ value:, display_label:, href: }`.
|
|
54
|
+
Nothing gates on their vocabulary, and three more keys on fifteen fields is
|
|
55
|
+
JSON no consumer reads.
|
|
56
|
+
- **No new `FIELDS` entry.** Every change above is a key on an existing entry,
|
|
57
|
+
so a consumer deriving its schema from the registry needs no new column.
|
data/README.md
CHANGED
|
@@ -91,6 +91,21 @@ doc.origin_agents # => [{ name:, roles:, affiliation:, display_label:, href:,
|
|
|
91
91
|
# projects nil. The four fields that JOIN several elements into one string --
|
|
92
92
|
# abstract and the three accessCondition fields -- take companion scalars
|
|
93
93
|
# instead (doc.abstract_display_label, doc.abstract_href).
|
|
94
|
+
|
|
95
|
+
# A BROWSABLE projection also carries the vocabulary its value was taken from,
|
|
96
|
+
# as { authority:, authority_uri:, value_uri: } -- names (and originInfo
|
|
97
|
+
# agents), languages, genres and subject headings. A consumer asking "may this
|
|
98
|
+
# value be offered as a browse?" asks for any of the three: MODS lets a record
|
|
99
|
+
# declare its vocabulary by URI alone, so requiring @authority would call an
|
|
100
|
+
# authorityURI-bearing corporate name uncontrolled.
|
|
101
|
+
#
|
|
102
|
+
# This resolves DIFFERENTLY from the pair above, which is why it is a separate
|
|
103
|
+
# port rather than three more keys on qualifiers_of. A header often comes from
|
|
104
|
+
# a PARENT; an authority never does. It is read off the element holding the
|
|
105
|
+
# value, then off an enclosing <subject> -- a pre-coordinated heading declares
|
|
106
|
+
# its vocabulary once, on the heading. A <role>/<roleTerm> authority is never
|
|
107
|
+
# consulted: it is the vocabulary of the RELATOR, and the deposit form writes a
|
|
108
|
+
# marcrelator roleTerm on every creator it collects.
|
|
94
109
|
doc.host_collections
|
|
95
110
|
# => [{ title:, volume:, issue:, start_page:, end_page:,
|
|
96
111
|
# date:, text:, details: [...], extents: [...] }, ...]
|
|
@@ -111,6 +126,21 @@ doc.location # => [{ physical_location:, shelf_location:, url:,
|
|
|
111
126
|
# display_label:, href: }, ...]
|
|
112
127
|
doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
|
|
113
128
|
doc.title_subjects # => ["The Great Gatsby"] composed like the main title
|
|
129
|
+
doc.subject_headings
|
|
130
|
+
# => [{ parts: ["Salt marshes", "Massachusetts"],
|
|
131
|
+
# heading: "Salt marshes -- Massachusetts",
|
|
132
|
+
# axis: "topic", authority: "lcsh", authority_uri:,
|
|
133
|
+
# value_uri:, display_label:, href: }, ...]
|
|
134
|
+
# one top-level <subject> as ONE heading. :heading is the
|
|
135
|
+
# parts joined, here rather than with the caller because
|
|
136
|
+
# it is both the string a display renders and the string
|
|
137
|
+
# a browse index holds -- two joins is how those drift.
|
|
138
|
+
# :axis names the element of the heading's MAIN term
|
|
139
|
+
# (its first child carrying heading text), which is what
|
|
140
|
+
# says which browse the heading belongs to: a place
|
|
141
|
+
# subdivision does not make a topic heading a place.
|
|
142
|
+
# A <name> axis splits by @type: "personal_name" or
|
|
143
|
+
# "corporate_name"
|
|
114
144
|
doc.hierarchical_geographic_subjects
|
|
115
145
|
# => [{ country:, state:, city:, ... }, ...] eleven levels,
|
|
116
146
|
# structured for the reason map_data is
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -54,9 +54,8 @@ module NEU
|
|
|
54
54
|
# Returns "" when there is no title. Exposed as NEU::MODS.compose_title.
|
|
55
55
|
#
|
|
56
56
|
# nonSort, title, subtitle, partName, partNumber -- the order the
|
|
57
|
-
# librarians
|
|
58
|
-
#
|
|
59
|
-
# composer has to fix one.
|
|
57
|
+
# librarians chose. titleInfo is an unordered choice in the schema, so no
|
|
58
|
+
# document order is available to follow and the composer has to fix one.
|
|
60
59
|
#
|
|
61
60
|
# A period separates the title or subtitle from the parts, and one part
|
|
62
61
|
# from the next. The separator travels with its part rather than with the
|
|
@@ -165,18 +164,49 @@ module NEU
|
|
|
165
164
|
# MARC code rather than a place name.
|
|
166
165
|
HEADING_OMITTED_CHILDREN = %w[cartographics geographicCode].freeze
|
|
167
166
|
|
|
167
|
+
# What a cataloguer puts between the steps of a pre-coordinated heading.
|
|
168
|
+
# Here rather than with the consumer because the composed heading is a
|
|
169
|
+
# projected value now: a display and an index that both read it cannot
|
|
170
|
+
# separate it differently. Spaces included, which is the separator DRS
|
|
171
|
+
# has displayed for years.
|
|
172
|
+
HEADING_SEPARATOR = " -- "
|
|
173
|
+
|
|
174
|
+
# A <subject><name> with no @type reaches the corporate axis. The display
|
|
175
|
+
# already composes such a name, because #subject_heading_part never
|
|
176
|
+
# consulted @type, so the alternative is a heading a reader sees and no
|
|
177
|
+
# browse holds. Corporate rather than personal: MODS expects
|
|
178
|
+
# @type="personal" on a person, and the untyped subject names DRS holds
|
|
179
|
+
# are institutional.
|
|
180
|
+
TYPELESS_NAME_SUBJECT_TYPE = "corporate"
|
|
181
|
+
|
|
168
182
|
# Every top-level <subject> as ONE heading, its parts in document order.
|
|
169
183
|
# A pre-coordinated heading like "Salt marshes--Massachusetts--20th
|
|
170
184
|
# century" is a single statement, and the per-axis fields below cannot say
|
|
171
185
|
# which parts belonged together: they pool every topic on the record into
|
|
172
186
|
# one list, so a fragment of a heading and a whole heading read alike.
|
|
173
187
|
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
188
|
+
# The parts stay, because the Advanced edit form and a consumer wanting
|
|
189
|
+
# one step of a heading both ask for them. `heading:` is the same parts
|
|
190
|
+
# joined, and it is here rather than left to each caller because the
|
|
191
|
+
# composed heading is now BOTH the string a display renders and the
|
|
192
|
+
# string a browse index holds. Two callers joining independently is how
|
|
193
|
+
# a displayed value and an indexed value drift apart, and this join
|
|
194
|
+
# makes them the same string rather than two that happen to match.
|
|
195
|
+
#
|
|
196
|
+
# `axis:` names the MODS element the heading's MAIN term came from, which
|
|
197
|
+
# is the first child that carries heading text. A consumer cannot derive
|
|
198
|
+
# it from the parts -- they are bare strings -- and it is the fact that
|
|
199
|
+
# says which browse a heading belongs to: "Salt marshes -- Massachusetts"
|
|
200
|
+
# is a topic heading with a place subdivision, not a place. Reported as
|
|
201
|
+
# the element name, so the browse vocabulary stays with the consumer.
|
|
176
202
|
def subject_headings
|
|
177
203
|
doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
|
|
204
|
+
axis_node = heading_axis_node(node)
|
|
178
205
|
parts = subject_heading_parts(node)
|
|
179
|
-
|
|
206
|
+
next if axis_node.nil? || parts.empty?
|
|
207
|
+
|
|
208
|
+
{ parts: parts, heading: parts.join(HEADING_SEPARATOR), axis: heading_axis(axis_node),
|
|
209
|
+
**authority_of(axis_node), **qualifiers_of(node) }
|
|
180
210
|
end
|
|
181
211
|
end
|
|
182
212
|
|
|
@@ -247,6 +277,10 @@ module NEU
|
|
|
247
277
|
# and without it a consumer grouping role-less names can only guess.
|
|
248
278
|
usage: attr_value(node, "usage"),
|
|
249
279
|
alternative_names: alternative_names(node),
|
|
280
|
+
# The vocabulary this name was taken from, read off the <name> alone.
|
|
281
|
+
# A marcrelator <roleTerm> inside it says what the person DID, not
|
|
282
|
+
# which list the name came from -- see #authority_of.
|
|
283
|
+
**authority_of(node),
|
|
250
284
|
**qualifiers_of(node)
|
|
251
285
|
}
|
|
252
286
|
end
|
|
@@ -321,11 +355,15 @@ module NEU
|
|
|
321
355
|
# recover it from the term.
|
|
322
356
|
def languages
|
|
323
357
|
doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
|
|
324
|
-
|
|
358
|
+
node = language_term_node(lang)
|
|
359
|
+
term = language_term_of(node)
|
|
325
360
|
next unless term
|
|
326
361
|
|
|
362
|
+
# The authority comes off the <languageTerm> the term was read from,
|
|
363
|
+
# never off the <language> around it: MODS carries @authority on the
|
|
364
|
+
# term, and a code-only record declares `iso639-2b` there.
|
|
327
365
|
{ term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang),
|
|
328
|
-
**qualifiers_of(lang) }
|
|
366
|
+
**authority_of(node), **qualifiers_of(lang) }
|
|
329
367
|
end
|
|
330
368
|
end
|
|
331
369
|
|
|
@@ -342,7 +380,11 @@ module NEU
|
|
|
342
380
|
def extent = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:extent", from: "..")
|
|
343
381
|
def digital_origin = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin", from: "..")
|
|
344
382
|
|
|
345
|
-
|
|
383
|
+
# A genre is a browse axis, so its entry carries the vocabulary the term
|
|
384
|
+
# came from. The other labeled fields do not: nothing gates on their
|
|
385
|
+
# vocabulary, and three more keys on fifteen fields is JSON no consumer
|
|
386
|
+
# reads.
|
|
387
|
+
def genres = labeled_texts_at("/mods:mods/mods:genre", authority: true)
|
|
346
388
|
|
|
347
389
|
# Who the resource is for. The last displayed top-level element with no
|
|
348
390
|
# projection at all: a record naming its audience said so to nobody.
|
|
@@ -923,6 +965,11 @@ module NEU
|
|
|
923
965
|
# display would otherwise never let a curator change.
|
|
924
966
|
def main_title_display_label = attr_value(primary_title_info, "displayLabel")
|
|
925
967
|
|
|
968
|
+
# The three attributes MODS uses to declare where a value came from, and
|
|
969
|
+
# the projected key each reports under. Read by #authority_of below.
|
|
970
|
+
AUTHORITY_ATTRIBUTES = { authority: "authority", authority_uri: "authorityURI",
|
|
971
|
+
value_uri: "valueURI" }.freeze
|
|
972
|
+
|
|
926
973
|
private
|
|
927
974
|
|
|
928
975
|
# --- helpers -------------------------------------------------------------
|
|
@@ -1073,6 +1120,48 @@ module NEU
|
|
|
1073
1120
|
{ display_label: attr_value(node, "displayLabel"), href: xlink_href(node) }
|
|
1074
1121
|
end
|
|
1075
1122
|
|
|
1123
|
+
# The attributes naming the vocabulary a VALUE was taken from, which is
|
|
1124
|
+
# what tells a controlled term apart from one a depositor typed. A
|
|
1125
|
+
# consumer gating a browse link on "is this term controlled?" asks for
|
|
1126
|
+
# any of the three: MODS lets a record declare its vocabulary by URI
|
|
1127
|
+
# alone, so requiring @authority would call an authorityURI-bearing name
|
|
1128
|
+
# uncontrolled.
|
|
1129
|
+
#
|
|
1130
|
+
# NOT part of #qualifiers_of, and the difference is the resolution rule
|
|
1131
|
+
# rather than taste. That pair answers "where does the HEADER come
|
|
1132
|
+
# from", which is often a PARENT -- six projections pass `from:` for
|
|
1133
|
+
# exactly that reason, because MODS puts @displayLabel on originInfo and
|
|
1134
|
+
# physicalDescription rather than on the publisher or extent inside
|
|
1135
|
+
# them. An authority is the opposite: `<form authority="marcform">`
|
|
1136
|
+
# carries it on the element holding the text, so reading it off the
|
|
1137
|
+
# label's element would find nothing there and attribute a parent's
|
|
1138
|
+
# vocabulary to a child elsewhere.
|
|
1139
|
+
#
|
|
1140
|
+
# Each attribute resolves on the element, then on an enclosing
|
|
1141
|
+
# <subject>: a pre-coordinated heading declares its vocabulary once, on
|
|
1142
|
+
# the heading, and every part of it belongs to that vocabulary.
|
|
1143
|
+
#
|
|
1144
|
+
# A <role>/<roleTerm> authority is never consulted, which falls out of
|
|
1145
|
+
# only ever reading the element and its <subject> ancestor. That matters
|
|
1146
|
+
# because the deposit form writes a marcrelator roleTerm on every
|
|
1147
|
+
# creator it collects, so an "any authority in the subtree" check would
|
|
1148
|
+
# call every depositor-entered name controlled.
|
|
1149
|
+
def authority_of(node)
|
|
1150
|
+
heading = enclosing_subject(node)
|
|
1151
|
+
AUTHORITY_ATTRIBUTES.transform_values do |attribute|
|
|
1152
|
+
attr_value(node, attribute) || attr_value(heading, attribute)
|
|
1153
|
+
end
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
# The <subject> a node sits inside, matched by namespace rather than by
|
|
1157
|
+
# prefix for the reason #xlink_href is: a document binds the MODS
|
|
1158
|
+
# namespace to whatever prefix it likes.
|
|
1159
|
+
def enclosing_subject(node)
|
|
1160
|
+
node&.ancestors&.find do |ancestor|
|
|
1161
|
+
ancestor.name == "subject" && ancestor.namespace&.href == NAMESPACE["mods"]
|
|
1162
|
+
end
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1076
1165
|
# xlink:href by namespace rather than by prefix. A document is free to
|
|
1077
1166
|
# bind the XLink namespace to any prefix, or to none, and node["xlink:href"]
|
|
1078
1167
|
# matches the literal prefix alone.
|
|
@@ -1087,8 +1176,9 @@ module NEU
|
|
|
1087
1176
|
# header from. That is not always the element holding the text: MODS puts
|
|
1088
1177
|
# @displayLabel on originInfo and physicalDescription, never on the
|
|
1089
1178
|
# publisher, place, extent or digitalOrigin inside them.
|
|
1090
|
-
def labeled(value, label_node)
|
|
1091
|
-
{ value: value, **qualifiers_of(label_node) }
|
|
1179
|
+
def labeled(value, label_node, authority_node: nil)
|
|
1180
|
+
entry = { value: value, **qualifiers_of(label_node) }
|
|
1181
|
+
authority_node ? entry.merge(authority_of(authority_node)) : entry
|
|
1092
1182
|
end
|
|
1093
1183
|
|
|
1094
1184
|
# The qualifiers of an originInfo block. @eventType says what the block
|
|
@@ -1126,10 +1216,16 @@ module NEU
|
|
|
1126
1216
|
# #texts_at, with each value carrying the qualifiers of its element.
|
|
1127
1217
|
# `from:` is an XPath relative to the text-bearing node, naming the
|
|
1128
1218
|
# ancestor the header comes from instead.
|
|
1129
|
-
|
|
1219
|
+
# `authority:` adds the vocabulary of the VALUE, which resolves off the
|
|
1220
|
+
# text-bearing node even where `from:` points the header at an ancestor
|
|
1221
|
+
# -- `<form authority="marcform">` is exactly that shape.
|
|
1222
|
+
def labeled_texts_at(xpath, from: nil, authority: false)
|
|
1130
1223
|
doc.xpath(xpath, NAMESPACE).filter_map do |node|
|
|
1131
1224
|
value = clean(node.text)
|
|
1132
|
-
|
|
1225
|
+
next unless value
|
|
1226
|
+
|
|
1227
|
+
labeled(value, from ? node.at_xpath(from, NAMESPACE) : node,
|
|
1228
|
+
authority_node: authority ? node : nil)
|
|
1133
1229
|
end
|
|
1134
1230
|
end
|
|
1135
1231
|
|
|
@@ -1185,11 +1281,33 @@ module NEU
|
|
|
1185
1281
|
end
|
|
1186
1282
|
end
|
|
1187
1283
|
|
|
1284
|
+
# The corporate axis also takes a subject name with NO @type (see
|
|
1285
|
+
# TYPELESS_NAME_SUBJECT_TYPE), so a name the heading composes reaches a
|
|
1286
|
+
# browse instead of displaying and projecting nowhere.
|
|
1188
1287
|
def name_subjects(type)
|
|
1189
|
-
|
|
1288
|
+
predicate = "@type='#{type}'"
|
|
1289
|
+
predicate = "#{predicate} or not(@type)" if type == TYPELESS_NAME_SUBJECT_TYPE
|
|
1290
|
+
doc.xpath("/mods:mods/mods:subject/mods:name[#{predicate}]", NAMESPACE)
|
|
1190
1291
|
.filter_map { |node| name_display_value_w_date(node) }
|
|
1191
1292
|
end
|
|
1192
1293
|
|
|
1294
|
+
# The child a heading's axis and authority come from: the first one
|
|
1295
|
+
# carrying heading text. A heading with none projects nothing, so its
|
|
1296
|
+
# axis is never asked for.
|
|
1297
|
+
def heading_axis_node(node)
|
|
1298
|
+
node.xpath("mods:*", NAMESPACE).find { |child| subject_heading_part(child).compact.any? }
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
# The axis of one heading child, as the MODS element that holds it. A
|
|
1302
|
+
# <name> splits by @type, because a person and an organisation are
|
|
1303
|
+
# separate browses and MODS says which on the element rather than in the
|
|
1304
|
+
# element name.
|
|
1305
|
+
def heading_axis(child)
|
|
1306
|
+
return "#{attr_value(child, "type") || TYPELESS_NAME_SUBJECT_TYPE}_name" if child.name == "name"
|
|
1307
|
+
|
|
1308
|
+
snake_case(child.name)
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1193
1311
|
def subject_heading_parts(node)
|
|
1194
1312
|
node.xpath("mods:*", NAMESPACE).flat_map { |child| subject_heading_part(child) }.compact
|
|
1195
1313
|
end
|
|
@@ -1303,6 +1421,13 @@ module NEU
|
|
|
1303
1421
|
[head, *rest.map(&:capitalize)].join
|
|
1304
1422
|
end
|
|
1305
1423
|
|
|
1424
|
+
# #camelize's inverse, for reporting a schema element name as a projected
|
|
1425
|
+
# one: "hierarchicalGeographic" -> "hierarchical_geographic". Hand-rolled
|
|
1426
|
+
# for the reason the gem has no Rails dependency at all.
|
|
1427
|
+
def snake_case(name)
|
|
1428
|
+
name.to_s.gsub(/([a-z])([A-Z])/) { "#{Regexp.last_match(1)}_#{Regexp.last_match(2).downcase}" }
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1306
1431
|
def texts_under(node, xpath)
|
|
1307
1432
|
node.xpath(xpath, NAMESPACE).filter_map { |child| clean(child.text) }
|
|
1308
1433
|
end
|
|
@@ -1311,13 +1436,24 @@ module NEU
|
|
|
1311
1436
|
doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
|
|
1312
1437
|
end
|
|
1313
1438
|
|
|
1314
|
-
#
|
|
1315
|
-
#
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1439
|
+
# Which <languageTerm> the language of one element is read from, text
|
|
1440
|
+
# form preferred. The node is located separately from its text because
|
|
1441
|
+
# the authority governing the term sits on that element: the value and
|
|
1442
|
+
# its vocabulary have to come from one node rather than from two
|
|
1443
|
+
# independent lookups.
|
|
1444
|
+
def language_term_node(lang)
|
|
1445
|
+
lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE) ||
|
|
1446
|
+
lang.at_xpath("mods:languageTerm", NAMESPACE)
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
# A text term as the record wrote it; a code through the ISO 639
|
|
1450
|
+
# registry, so a record saying `eng` projects "English".
|
|
1451
|
+
def language_term_of(node)
|
|
1452
|
+
return nil unless node
|
|
1453
|
+
|
|
1454
|
+
return clean(node.text) if attr_value(node, "type") == "text"
|
|
1319
1455
|
|
|
1320
|
-
code = clean(
|
|
1456
|
+
code = clean(node.text)
|
|
1321
1457
|
code && LanguageCodes.term(code)
|
|
1322
1458
|
end
|
|
1323
1459
|
|
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.14.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-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|
|
@@ -63,6 +63,7 @@ extensions: []
|
|
|
63
63
|
extra_rdoc_files: []
|
|
64
64
|
files:
|
|
65
65
|
- ".version"
|
|
66
|
+
- CHANGELOG.md
|
|
66
67
|
- Gemfile
|
|
67
68
|
- README.md
|
|
68
69
|
- Rakefile
|