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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04a0acbc467e71ac94fb6b48eb7dbcaca01ccaa5f3c6894268f1cb689967a505
4
- data.tar.gz: 6b799da10a7a33c6f8a27d5746317a866a856978c27b6fdca9d2b1a0b4170a62
3
+ metadata.gz: 07f1d513851b2423d9d5f9c5a12537aef16005ed8e86e699d34cdd3b01b65a5a
4
+ data.tar.gz: fa862f2409dc0dd04344179cf400bd06ad24f725178f43ce64459133c9e05cdb
5
5
  SHA512:
6
- metadata.gz: 0edb1eeac6b75e5f94fb3eaf2ae15ada5e37d7ca245c2ed5836d502d9f27043ad81a7a7ca3f85639a25233828fae2bc89eaa629dbae0537e776a24ad34bea0f2
7
- data.tar.gz: c944c560bd9ec49eb4b352809df9af3a1dab42d8faa2589d56b0a01c7b5bd69cecceb1ef78a86f6bfdc89b3a97ee4a94ad1ba42577231573fd4b87ebaf90db3b
6
+ metadata.gz: 7867291e7502916ebdb5f409827158867f97c667b1907dfeb979a6034287daa2a1db714b11185fb5fe50e2aaafb9da9177d391f0b4241b98b6e545ef3d9c901f
7
+ data.tar.gz: 47fcd25686fc79ccdd1446a242c9d226277ce93cdccccaadd9b36f164087c2cfdc3f052f4fa7df2db171dc6a5ea806afbf69be59338736c4f6328f93d623ed1a
data/.version CHANGED
@@ -1 +1 @@
1
- 0.13.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
@@ -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 settled on 2026-09-14. titleInfo is an unordered choice in
58
- # the schema, so no document order is available to follow and the
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
- # Kept in parts rather than joined. The separator is display policy, the
175
- # same call #map_data makes for cartographics.
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
- { parts: parts, **qualifiers_of(node) } unless parts.empty?
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
- term = language_term(lang)
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
- def genres = labeled_texts_at("/mods:mods/mods:genre")
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
- def labeled_texts_at(xpath, from: nil)
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
- labeled(value, from ? node.at_xpath(from, NAMESPACE) : node) if value
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
- doc.xpath("/mods:mods/mods:subject/mods:name[@type='#{type}']", NAMESPACE)
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
- # The language of one element: the text term, or a code read through the
1315
- # ISO 639 registry.
1316
- def language_term(lang)
1317
- text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
1318
- return clean(text.text) if text
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(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
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.13.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-14 00:00:00.000000000 Z
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