neu-mods 0.5.0 → 0.6.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: 1469790b96ff15516d9766db6673159fb9ad14128e02c4399842580c198a1740
4
- data.tar.gz: 97c442fbd0765c6de753bbb140da3fa2ac61fedcb63104d38801c569a876ddcd
3
+ metadata.gz: 5b89b85501bd24a3e03e6cb9c0d8945c32b5b053ce21ce12d06c92ff1d411f99
4
+ data.tar.gz: f7ba90fecf34428546872120f116b0de258bb56b4fb42e2c2d90a4d76eea338d
5
5
  SHA512:
6
- metadata.gz: 5b2fdf0e42f5fd65fccbc732ddf4a15b3f311f4719a3a95a294517da643c8005dcbb3c25a7b3e2b1e7afcb67c1f29f9cecacf7ec157640ef3a2b2d56e13197ac
7
- data.tar.gz: 4ffc16684b6176a667fcf62dadc6e20c5fed2733c1f6bdcd39df345fe6df77f5cd412f62fe879676f1075f7c690ca6cb368b6876bfca0e4a98477af8112731c2
6
+ metadata.gz: 126a6f6a2fc04be9ee91a14c9469b05c9ebd5535a65f254116ac402d681e5d68d113c060003b365269a496d18a3917976b5ff9cd60ad1cbc4dfc746b63f4c77a
7
+ data.tar.gz: db7440392fde517da3bd14f2ed90fe8623e8da15940989d49c85bb1e1c3a338f8b0c8f9e3df7bfbaafc608fe1379b3cd84cec627e3d78dab24a59e26f57d98a8
data/.version CHANGED
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.6.0
data/README.md CHANGED
@@ -37,9 +37,22 @@ doc.date_created_with_precision
37
37
  # => [DateTime, "year"|"month"|"day"] w3cdtf YYYY, YYYY-MM
38
38
  # and YYYY-MM-DD all parse; the precision says which shape
39
39
  # the record declared, so display cannot invent a month or
40
- # a day the record never claimed
40
+ # a day the record never claimed. Same for
41
+ # date_issued_with_precision and
42
+ # copyright_date_with_precision.
43
+ doc.notes # => [{ type: "funding", value: "..." }, ...]
44
+ doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
45
+ # every relatedItem that is not a series or a host
46
+ doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
47
+ doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
41
48
  doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
42
49
 
50
+ # The field registry -- the single declaration of what this gem projects.
51
+ # name => :one or :many. to_h is derived from it, and a consumer builds its own
52
+ # schema from it rather than re-listing the field set by hand. Cardinality
53
+ # follows what MODS marks repeatable, so a field can never silently truncate.
54
+ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
55
+
43
56
  # Pure title composition (no document needed) — for callers that already hold
44
57
  # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
45
58
  NEU::MODS.compose_title(non_sort: "", title: "What's New",
@@ -104,6 +117,14 @@ intentional notes:
104
117
  text forms (the norm) are unaffected; code-only records would differ. Vendoring
105
118
  those lookup tables (or depending on `iso-639`) is deferred to keep the gem
106
119
  Nokogiri-only and small.
120
+ - **`description` is not projected.** MODS has no element of that name, and the
121
+ two candidates — an `abstract` variant and `physicalDescription/note` —
122
+ describe different things. Projecting a guess would put wrong data in the
123
+ field rather than leave an empty one, so it waits on a decision.
124
+ - **A date range is not projected.** `dateCreated`, `dateIssued` and
125
+ `copyrightDate` each project one value with its precision. A record that
126
+ ranges a date uses `point="start"` / `point="end"`, which needs a shape of its
127
+ own rather than a second array.
107
128
 
108
129
  ## Source convention
109
130
 
@@ -12,6 +12,8 @@ module NEU
12
12
  # Empty-value conventions mirror Atlas: scalar fields are "" when absent
13
13
  # (matching `.text.squish` on an empty node set), except `permanent_url` and
14
14
  # `date_created`, which are nil when their node is absent. Arrays are [].
15
+ # Which fields are scalar and which are arrays is declared in FIELDS, not
16
+ # left to each method to decide.
15
17
  module Projection
16
18
  # --- Title ---------------------------------------------------------------
17
19
 
@@ -25,16 +27,19 @@ module NEU
25
27
  # rewrite the curator's characters in the preservation XML on the next save.
26
28
  # #access_title_parts is the normalised surface.
27
29
  def title_parts
28
- ti = primary_title_info
29
- {
30
- non_sort: child_text(ti, "mods:nonSort"),
31
- subtitle: child_text(ti, "mods:subTitle"),
32
- title: child_text(ti, "mods:title"),
33
- part_name: child_text(ti, "mods:partName"),
34
- part_number: child_text(ti, "mods:partNumber")
35
- }
30
+ title_parts_of(primary_title_info)
36
31
  end
37
32
 
33
+ # The variant titles, each composed and normalised like the main title.
34
+ # MODS repeats titleInfo, so a record may carry more than one of a type.
35
+ # These are what keeps the primary-title fallback's refusal to promote a
36
+ # variant from hiding anything: the variant still reaches a reader, under
37
+ # a label that says which kind of title it is.
38
+ def alternative_title = variant_titles("alternative")
39
+ def uniform_title = variant_titles("uniform")
40
+ def translated_title = variant_titles("translated")
41
+ def abbreviated_title = variant_titles("abbreviated")
42
+
38
43
  # Composed display title (the former Atlas MODSDecoration#plain_title), driven
39
44
  # off the scoped primary title.
40
45
  def plain_title
@@ -86,10 +91,21 @@ module NEU
86
91
  join_paragraphs(abstract_nodes)
87
92
  end
88
93
 
94
+ # Every top-level accessCondition joined, regardless of @type. Retained
95
+ # because it is the only projection that carries an untyped or
96
+ # unrecognised accessCondition, which the two typed fields below cannot
97
+ # see -- a consumer that renders only those needs this as its fallback.
89
98
  def access_condition
90
99
  join_paragraphs(doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE))
91
100
  end
92
101
 
102
+ # The two @type values MODS defines, projected apart. Collapsing them into
103
+ # one value presented an access *restriction* to a reader as a *licence*,
104
+ # which is the one defect in this area that misinforms someone about their
105
+ # rights rather than merely hiding a field.
106
+ def use_and_reproduction = access_conditions_of_type("use and reproduction")
107
+ def restriction_on_access = access_conditions_of_type("restriction on access")
108
+
93
109
  # --- Subjects ------------------------------------------------------------
94
110
 
95
111
  # The editable free-text keyword set (Cerberus simple form): topics under the
@@ -104,6 +120,18 @@ module NEU
104
120
  doc.xpath("/mods:mods/mods:subject/mods:topic", NAMESPACE).map { |t| clean(t.text) }
105
121
  end
106
122
 
123
+ # The other subject axes. Cerberus's IPTC ingest writes subject/geographic
124
+ # from the IPTC City and State fields, so this one was also being written
125
+ # on every batch and read back by nothing.
126
+ def geographic_subjects = texts_at("/mods:mods/mods:subject/mods:geographic")
127
+ def temporal_subjects = texts_at("/mods:mods/mods:subject/mods:temporal")
128
+
129
+ # Name subjects compose through the same display-value port as #names, so
130
+ # one person reads the same whether they authored the work or are its
131
+ # subject.
132
+ def personal_name_subjects = name_subjects("personal")
133
+ def corporate_name_subjects = name_subjects("corporate")
134
+
107
135
  # --- Names ---------------------------------------------------------------
108
136
 
109
137
  # All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
@@ -148,18 +176,85 @@ module NEU
148
176
  end.compact
149
177
  end
150
178
 
151
- def resource_type = text_at("/mods:mods/mods:typeOfResource")
152
- def format = text_at("/mods:mods/mods:physicalDescription/mods:form")
153
- def extent = text_at("/mods:mods/mods:physicalDescription/mods:extent")
154
- def digital_origin = text_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
179
+ # MODS repeats typeOfResource, and repeats physicalDescription (and form and
180
+ # extent within one), so all four are :many. A record that is both text and
181
+ # a still image used to project as text alone.
182
+ def resource_type = texts_at("/mods:mods/mods:typeOfResource")
183
+ def format = texts_at("/mods:mods/mods:physicalDescription/mods:form")
184
+ def extent = texts_at("/mods:mods/mods:physicalDescription/mods:extent")
185
+ def digital_origin = texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
155
186
 
156
187
  def genres
157
188
  doc.xpath("/mods:mods/mods:genre", NAMESPACE).map { |g| clean(g.text) }
158
189
  end
159
190
 
160
- def related_series
161
- doc.xpath("/mods:mods/mods:relatedItem[@type='series']/mods:titleInfo/mods:title", NAMESPACE)
162
- .map { |t| clean(t.text) }
191
+ # originInfo repeats, and so do publisher and edition within one. Cerberus's
192
+ # IPTC ingest writes the publisher from the IPTC Source field on every batch,
193
+ # so this element was being written into the preservation XML and then read
194
+ # back by nothing.
195
+ def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
196
+ def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
197
+
198
+ # Every top-level note, keeping its @type. The type carries meaning -- a
199
+ # "statement of responsibility" is not a "funding" note -- so flattening
200
+ # them into bare strings would repeat the accessCondition mistake.
201
+ def notes
202
+ doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
203
+ value = clean(node.text)
204
+ { type: clean(node["type"]), value: value } if value
205
+ end
206
+ end
207
+
208
+ # location repeats, and one location mixes kinds: a shelf mark and a URL
209
+ # are not interchangeable, and a consumer has to know which it holds
210
+ # before it can decide to linkify it. So the parts stay apart.
211
+ def location
212
+ doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
213
+ entry = {
214
+ physical_location: child_text(node, "mods:physicalLocation"),
215
+ shelf_location: child_text(node, "mods:shelfLocation"),
216
+ url: child_text(node, "mods:url")
217
+ }
218
+ entry if entry.values.any?
219
+ end
220
+ end
221
+
222
+ # subject/cartographics, kept structured. Composing "scale ; projection
223
+ # coordinates" into one string is display policy, and this gem does not own
224
+ # that -- a consumer that wants only the coordinates should not have to
225
+ # unpick a sentence to get them.
226
+ def map_data
227
+ doc.xpath("/mods:mods/mods:subject/mods:cartographics", NAMESPACE).filter_map do |node|
228
+ entry = {
229
+ scale: child_text(node, "mods:scale"),
230
+ projection: child_text(node, "mods:projection"),
231
+ coordinates: child_text(node, "mods:coordinates")
232
+ }
233
+ entry if entry.values.any?
234
+ end
235
+ end
236
+
237
+ def related_series = related_item_titles("series")
238
+ def host_collections = related_item_titles("host")
239
+
240
+ # relatedItem @type values that already have a field of their own, so the
241
+ # catch-all below does not repeat them.
242
+ NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
243
+
244
+ # Every other relatedItem, keeping its @type. MODS also defines
245
+ # constituent, otherFormat, original, preceding, succeeding, isReferencedBy
246
+ # and reviewOf, and a record carrying any of them projected nothing at all.
247
+ # The type rides along because "the print edition" and "reviewed in" are
248
+ # not the same relationship, and no consumer can recover which it holds
249
+ # from the title alone. An untyped relatedItem lands here with a nil type.
250
+ def related_items
251
+ doc.xpath("/mods:mods/mods:relatedItem", NAMESPACE).filter_map do |node|
252
+ type = clean(node["type"])
253
+ next if NAMED_RELATED_ITEM_TYPES.include?(type)
254
+
255
+ title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
256
+ { type: type, title: title } if title
257
+ end
163
258
  end
164
259
 
165
260
  def identifiers
@@ -177,56 +272,112 @@ module NEU
177
272
  # instead of being guessed after it.
178
273
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
179
274
 
180
- # Parsed dateCreated paired with the granularity the record declared, as
181
- # [value, precision]. value is nil if no originInfo/dateCreated, or "" if
182
- # present but unparseable (mirrors Atlas's safe_date_parse rescue).
183
- # precision is "year", "month" or "day", and nil whenever value is not a
184
- # DateTime.
275
+ # The three originInfo dates, each paired with the granularity the record
276
+ # declared, as [value, precision]. value is nil if the element is absent,
277
+ # or "" if present but unparseable (mirrors Atlas's safe_date_parse
278
+ # rescue). precision is "year", "month" or "day", and nil whenever value
279
+ # is not a DateTime.
185
280
  #
186
281
  # The precision has to be captured here, at the only point where the shape
187
282
  # is still visible: a year-only date parses to January 1st, and no consumer
188
283
  # downstream can tell that month and day from a record that claimed them.
189
284
  # A preservation repository must not project a precision it was not given.
190
- def date_created_with_precision
191
- node = doc.at_xpath("/mods:mods/mods:originInfo/mods:dateCreated", NAMESPACE)
192
- return [nil, nil] unless node
193
-
194
- str = NEU::MODS.canonical_ws(node.text)
195
- return [nil, nil] if str.empty?
196
-
197
- parse_w3cdtf(str)
198
- end
285
+ #
286
+ # These stay :one where the sibling originInfo elements are :many. MODS
287
+ # repeats them, but a date carries a precision alongside it, and an array
288
+ # of values paired with an array of precisions is a shape no consumer
289
+ # wants. A record that ranges its dates uses point="start"/"end", which is
290
+ # its own projection question rather than a cardinality one.
291
+ def date_created_with_precision = date_with_precision("dateCreated")
292
+ def date_issued_with_precision = date_with_precision("dateIssued")
293
+ def copyright_date_with_precision = date_with_precision("copyrightDate")
199
294
 
200
295
  def date_created = date_created_with_precision.first
201
296
  def date_created_precision = date_created_with_precision.last
297
+ def date_issued = date_issued_with_precision.first
298
+ def date_issued_precision = date_issued_with_precision.last
299
+ def copyright_date = copyright_date_with_precision.first
300
+ def copyright_date_precision = copyright_date_with_precision.last
202
301
 
203
302
  # --- Full projection -----------------------------------------------------
204
303
 
304
+ # The field registry: the single declaration of what this gem projects.
305
+ # Field name => cardinality, :one or :many. The projection method of the
306
+ # same name owns the XPath; this row says the field exists and whether it
307
+ # is single- or multi-valued. #to_h is derived from it, and Atlas derives
308
+ # its Metadata::MODS attr_json set from it, so a field cannot be projected
309
+ # here and go undeclared there (or the reverse).
310
+ #
311
+ # Cardinality is the half that earns its keep. The at_xpath-versus-xpath
312
+ # choice here and the single-versus-array column choice in Atlas used to be
313
+ # made independently in two repos with nothing tying them together, which is
314
+ # how repeatable MODS elements ended up truncated to their first match.
315
+ # #cardinality_of checks each method against its row.
316
+ FIELDS = {
317
+ # titles
318
+ main_title: :one,
319
+ alternative_title: :many,
320
+ uniform_title: :many,
321
+ translated_title: :many,
322
+ abbreviated_title: :many,
323
+
324
+ names: :many,
325
+ languages: :many,
326
+ abstract: :one,
327
+
328
+ # origin
329
+ publication_information: :many,
330
+ edition: :many,
331
+ date_created: :one,
332
+ date_created_precision: :one,
333
+ date_issued: :one,
334
+ date_issued_precision: :one,
335
+ copyright_date: :one,
336
+ copyright_date_precision: :one,
337
+
338
+ # physical description
339
+ resource_type: :many,
340
+ genres: :many,
341
+ format: :many,
342
+ extent: :many,
343
+ digital_origin: :many,
344
+ notes: :many,
345
+
346
+ # subjects
347
+ topical_subjects: :many,
348
+ geographic_subjects: :many,
349
+ temporal_subjects: :many,
350
+ personal_name_subjects: :many,
351
+ corporate_name_subjects: :many,
352
+ map_data: :many,
353
+
354
+ # related items
355
+ related_series: :many,
356
+ host_collections: :many,
357
+ related_items: :many,
358
+
359
+ # identifiers and location
360
+ identifiers: :many,
361
+ permanent_url: :one,
362
+ location: :many,
363
+
364
+ # access
365
+ access_condition: :one,
366
+ use_and_reproduction: :one,
367
+ restriction_on_access: :one
368
+ }.freeze
369
+
205
370
  # The complete read projection, keyed to Atlas's Metadata::MODS attribute
206
371
  # names -- a drop-in source for `convert_xml_to_json`.
207
372
  def to_h
208
- date, date_precision = date_created_with_precision
209
-
210
- {
211
- main_title: access_title_parts,
212
- names: names,
213
- languages: languages,
214
- date_created: date,
215
- date_created_precision: date_precision,
216
- resource_type: resource_type,
217
- genres: genres,
218
- format: format,
219
- extent: extent,
220
- digital_origin: digital_origin,
221
- abstract: abstract,
222
- related_series: related_series,
223
- topical_subjects: topical_subjects,
224
- identifiers: identifiers,
225
- permanent_url: permanent_url,
226
- access_condition: access_condition
227
- }
373
+ FIELDS.keys.to_h { |field| [field, public_send(field)] }
228
374
  end
229
375
 
376
+ # The cardinality a projected value actually has, for checking a value
377
+ # against its FIELDS row. An Array is :many and anything else is :one, so a
378
+ # field declared :many that forgot to switch at_xpath for xpath is caught.
379
+ def self.cardinality_of(value) = value.is_a?(Array) ? :many : :one
380
+
230
381
  # The title parts as the access copy wants them: normalised like the
231
382
  # abstract, so a curly quote, an invisible format mark or a Windows-1252
232
383
  # control cannot reach Solr or a display template. Titles and prose share
@@ -235,6 +386,11 @@ module NEU
235
386
  title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) }
236
387
  end
237
388
 
389
+ # Atlas names this field main_title; the registry requires a method per
390
+ # field name, and #access_title_parts is the descriptive name for what it
391
+ # returns. Kept as an alias rather than a rename so both read well.
392
+ def main_title = access_title_parts
393
+
238
394
  private
239
395
 
240
396
  # --- helpers -------------------------------------------------------------
@@ -259,11 +415,58 @@ module NEU
259
415
  ["", nil]
260
416
  end
261
417
 
418
+ def date_with_precision(element)
419
+ node = doc.at_xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
420
+ return [nil, nil] unless node
421
+
422
+ str = NEU::MODS.canonical_ws(node.text)
423
+ return [nil, nil] if str.empty?
424
+
425
+ parse_w3cdtf(str)
426
+ end
427
+
428
+ # Byte-faithful title parts off any titleInfo node, shared by #title_parts
429
+ # (which Cerberus pre-fills its edit forms from) and the variant titles.
430
+ def title_parts_of(node)
431
+ {
432
+ non_sort: child_text(node, "mods:nonSort"),
433
+ subtitle: child_text(node, "mods:subTitle"),
434
+ title: child_text(node, "mods:title"),
435
+ part_name: child_text(node, "mods:partName"),
436
+ part_number: child_text(node, "mods:partNumber")
437
+ }
438
+ end
439
+
440
+ # A variant title composed the way the access copy wants it: normalised
441
+ # first, like #access_title_parts, so a curly quote or an invisible format
442
+ # mark cannot reach Solr or a display template through this route either.
443
+ def variant_titles(type)
444
+ doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
445
+ parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
446
+ clean(Projection.compose_title(parts))
447
+ end
448
+ end
449
+
450
+ def name_subjects(type)
451
+ doc.xpath("/mods:mods/mods:subject/mods:name[@type='#{type}']", NAMESPACE)
452
+ .filter_map { |node| name_display_value_w_date(node) }
453
+ end
454
+
455
+ def related_item_titles(type)
456
+ texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
457
+ end
458
+
262
459
  def text_at(xpath)
263
460
  node = doc.at_xpath(xpath, NAMESPACE)
264
461
  node ? NEU::MODS.canonical_ws(node.text) : ""
265
462
  end
266
463
 
464
+ # The :many counterpart of #text_at. Blank members drop out rather than
465
+ # arriving as nil, so a consumer mapping over the array cannot trip on one.
466
+ def texts_at(xpath)
467
+ doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
468
+ end
469
+
267
470
  def child_text(parent, xpath)
268
471
  return nil unless parent
269
472
 
@@ -288,6 +491,15 @@ module NEU
288
491
  NEU::MODS.canonical_ws(str)
289
492
  end
290
493
 
494
+ # The schema leaves accessCondition/@type an open string, so match on the
495
+ # canonicalised, case-folded value rather than in the XPath: real records
496
+ # carry "Use and Reproduction" as readily as the MODS-recommended casing.
497
+ def access_conditions_of_type(type)
498
+ nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
499
+ .select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
500
+ join_paragraphs(nodes)
501
+ end
502
+
291
503
  def join_paragraphs(nodes)
292
504
  nodes.map { |n| NEU::MODS.normalize_paragraphs(n.text) }.reject(&:empty?).join("\n\n")
293
505
  end
@@ -8,12 +8,28 @@ module NEU
8
8
  # definition is the point: the node an editor changes is provably the node the
9
9
  # projection reads. Mixed into Document; operates on `doc`.
10
10
  module Selectors
11
- # Top-level primary titleInfo, falling back to the first top-level titleInfo.
12
- # Scoped to direct children of <mods:mods> so a relatedItem's nested
13
- # titleInfo (e.g. a series title) is never matched.
11
+ # Top-level primary titleInfo, falling back to the first top-level titleInfo
12
+ # that is NOT a variant. Scoped to direct children of <mods:mods> so a
13
+ # relatedItem's nested titleInfo (e.g. a series title) is never matched.
14
+ #
15
+ # MODS does not require usage="primary", so the fallback fires often, and
16
+ # an unfiltered one let document order decide the record's title. It also
17
+ # decided which node an edit form wrote to: MODSMerge overwrites the node
18
+ # this returns, so an alternative title reached here was destroyed on the
19
+ # next title edit, leaving the record with no primary title at all. nil is
20
+ # the right answer instead -- MODSMerge creates a proper primary titleInfo
21
+ # from nil, and each variant is projected under its own field.
14
22
  def primary_title_info
15
23
  doc.at_xpath("/mods:mods/mods:titleInfo[@usage='primary']", NAMESPACE) ||
16
- doc.at_xpath("/mods:mods/mods:titleInfo", NAMESPACE)
24
+ doc.xpath("/mods:mods/mods:titleInfo", NAMESPACE).reject { |ti| variant_title?(ti) }.first
25
+ end
26
+
27
+ # MODS enumerates titleInfo/@type as exactly abbreviated, translated,
28
+ # alternative and uniform -- every one of them a variant. So the presence
29
+ # of any @type marks a variant, which also keeps an unrecognised or
30
+ # misspelled value out of the write path rather than guessing at it.
31
+ def variant_title?(node)
32
+ !NEU::MODS.canonical_ws(node["type"].to_s).empty?
17
33
  end
18
34
 
19
35
  # All top-level <abstract> elements (MODS permits several).
data/lib/neu-mods.rb CHANGED
@@ -24,6 +24,12 @@ module NEU
24
24
  # The MODS v3 namespace, as a Nokogiri xpath namespace hash.
25
25
  NAMESPACE = { "mods" => "http://www.loc.gov/mods/v3" }.freeze
26
26
 
27
+ # The projected field set and its cardinality (see Projection::FIELDS),
28
+ # surfaced here so a consumer deriving its own schema from it -- Atlas's
29
+ # Metadata::MODS attr_json set -- reads the shared contract off the top-level
30
+ # module instead of reaching into a mixin.
31
+ FIELDS = Projection::FIELDS
32
+
27
33
  module_function
28
34
 
29
35
  # Whitespace no-op guard (see Canonicalize).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff