neu-mods 0.7.1 → 0.9.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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/README.md +29 -15
  4. data/lib/neu/mods/projection.rb +244 -32
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7e678073c1e18b2626473e1e8fcec32052a2a33075d34cea6fe96dca40e8315
4
- data.tar.gz: 5d3d44f6bf9ee5600dfdaded3a9d2119472e86c1aa6621fbcd92ea8358619738
3
+ metadata.gz: 5416d755319f464e252ef4fa07d0372efb11e1379d76ad6155b78f42a61c221a
4
+ data.tar.gz: ddfcf9eb792155c216bf9710ef6cfcc379f914791a782da30577158bb02a1da7
5
5
  SHA512:
6
- metadata.gz: ce304892f19636ecc57c487810a2ef2932f253d9702dcce6974a16c8e047b56ceeffb3ae25750bf2e5937345523567dd9d598ef59c32e671dcc80d62541748c8
7
- data.tar.gz: a31247bacadff8b81b4789c2dc69647b2036b98e246af02492d1ecdfb14e1a625e1deb2f7c7ad7afff5298d531a6b6bcacfa803ab3ea7629ecea623e45e06667
6
+ metadata.gz: d465b12175d8b15f7da7d7c355af0246294b3e7df0c276be775b6ccf41c14840f716fa7624ed86a54bff69da56625041cf7ca1f245e9ff6bb8f4035b44f70799
7
+ data.tar.gz: ce373c9bb16d1a7e6142a4e0556e669ddb07d0bbe77db172f7954d4d54eb298b4a688c5cf3a60bd5237611670bbfb162c67d7d9c1bd5377a6579d0c78b9a9030
data/.version CHANGED
@@ -1 +1 @@
1
- 0.7.1
1
+ 0.9.0
data/README.md CHANGED
@@ -34,18 +34,29 @@ doc.abstract # => normalized, paragraph-joined String
34
34
  doc.languages # => ["English"] a code-only <languageTerm>eng</> included
35
35
  doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
36
36
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
37
- doc.date_created_with_precision
38
- # => [DateTime, "year"|"month"|"day"] w3cdtf YYYY, YYYY-MM
39
- # and YYYY-MM-DD all parse; the precision says which shape
40
- # the record declared, so display cannot invent a month or
41
- # a day the record never claimed. Same for
42
- # date_issued_with_precision and
43
- # copyright_date_with_precision.
37
+ doc.date_created_parts
38
+ # => { value:, precision:, end_value:, end_precision:,
39
+ # qualifier:, key_date: } everything the record
40
+ # declared about one date. w3cdtf YYYY, YYYY-MM and
41
+ # YYYY-MM-DD all parse, and the precision says which
42
+ # shape it gave, so display cannot invent a month or a
43
+ # day. The points are read by @point, not by document
44
+ # order, and the end carries its OWN precision.
45
+ # Same for date_issued_parts and copyright_date_parts;
46
+ # each part is also a reader of its own, e.g.
47
+ # doc.date_created_qualifier.
44
48
  doc.notes # => [{ type: "funding", value: "..." }, ...]
45
49
  doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
46
50
  # every relatedItem that is not a series or a host
47
51
  doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
48
52
  doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
53
+ doc.title_subjects # => ["The Great Gatsby"] composed like the main title
54
+ doc.hierarchical_geographic_subjects
55
+ # => [{ country:, state:, city:, ... }, ...] eleven levels,
56
+ # structured for the reason map_data is
57
+ doc.record_info # => { content_source:, origin:, description_standard:,
58
+ # creation_date:, change_date:, language_of_cataloging: }
59
+ # describes the CATALOGUING, not the resource
49
60
  doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
50
61
 
51
62
  # The field registry -- the single declaration of what this gem projects.
@@ -123,14 +134,17 @@ intentional notes:
123
134
  label vocabulary belongs to the consumer — Cerberus's edit form and Atlas's
124
135
  display word the same role differently. An unrecognised language code also
125
136
  stays raw, since the record still said something.
126
- - **`description` is not projected.** MODS has no element of that name, and the
127
- two candidates an `abstract` variant and `physicalDescription/note`
128
- describe different things. Projecting a guess would put wrong data in the
129
- field rather than leave an empty one, so it waits on a decision.
130
- - **A date range is not projected.** `dateCreated`, `dateIssued` and
131
- `copyrightDate` each project one value with its precision. A record that
132
- ranges a date uses `point="start"` / `point="end"`, which needs a shape of its
133
- own rather than a second array.
137
+ - **`description` is not projected.** MODS does define `name/description`, but
138
+ that annotates a *name*, not the resource, so it is not the field Atlas once
139
+ called `description`. The two candidates for that one an `abstract` variant
140
+ and `physicalDescription/note` describe different things. Projecting a guess
141
+ would put wrong data in the field rather than leave an empty one, so it waits
142
+ on a decision.
143
+ - **A date carries more than a value.** Each of `dateCreated`, `dateIssued` and
144
+ `copyrightDate` projects a value, its precision, an end value with its own
145
+ precision, the `@qualifier` and the `@keyDate` flag. The gem does not *pick*
146
+ the key date, because "which date to sort on" and "which date to display" are
147
+ not necessarily the same answer, and choosing is the consumer's job.
134
148
 
135
149
  ## Source convention
136
150
 
@@ -130,16 +130,59 @@ module NEU
130
130
  def personal_name_subjects = name_subjects("personal")
131
131
  def corporate_name_subjects = name_subjects("corporate")
132
132
 
133
+ def genre_subjects = texts_at("/mods:mods/mods:subject/mods:genre")
134
+
135
+ # A MARC GAC code. Projected as the record wrote it: turning it into a
136
+ # place name needs a lookup table, which is the same call the gem already
137
+ # made for MARC relators -- the label vocabulary belongs to the consumer.
138
+ def geographic_code_subjects = texts_at("/mods:mods/mods:subject/mods:geographicCode")
139
+
140
+ # A subject that is a work has a nonSort, a subTitle and part numbers like
141
+ # any other titleInfo, so it composes through the same port as the main
142
+ # title rather than taking titleInfo/title alone.
143
+ def title_subjects
144
+ doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map do |node|
145
+ parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
146
+ clean(Projection.compose_title(parts))
147
+ end
148
+ end
149
+
150
+ # Kept structured for the reason #map_data is. Flattening country / state
151
+ # / city into "United States -- New York (State) -- Parksville" would make
152
+ # a consumer that wants the city alone unpick a sentence.
153
+ #
154
+ # This is the axis bdr_43888.mods.xml uses INSTEAD of subject/geographic,
155
+ # so that record projected no place at all -- a live ingest path, not a
156
+ # hypothetical.
157
+ def hierarchical_geographic_subjects
158
+ doc.xpath("/mods:mods/mods:subject/mods:hierarchicalGeographic", NAMESPACE).filter_map do |node|
159
+ entry = HIERARCHICAL_GEOGRAPHIC_LEVELS.to_h { |level| [level, child_text(node, "mods:#{camelize(level)}")] }
160
+ entry if entry.values.any?
161
+ end
162
+ end
163
+
133
164
  # --- Names ---------------------------------------------------------------
134
165
 
166
+ # One name as the access copy wants it. `affiliation` is how a reader
167
+ # tells one J. Doe from another, and it is the field an institutional
168
+ # repository most wants: it repeats in the schema, so it is an array.
169
+ #
170
+ # Added to the entry rather than as a parallel field, so a name and its
171
+ # affiliation cannot be zipped together wrongly by a consumer.
172
+ def name_entry(node)
173
+ {
174
+ name: name_display_value_w_date(node),
175
+ role: name_role(node),
176
+ affiliation: texts_under(node, "mods:affiliation")
177
+ }
178
+ end
179
+
135
180
  # All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
136
181
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
137
182
  # display output is preserved). `role` prefers the type="text" roleTerm,
138
183
  # falling back to the raw code (NOT MARC-relator-translated -- see README).
139
184
  def names
140
- doc.xpath("/mods:mods/mods:name", NAMESPACE).map do |node|
141
- { name: name_display_value_w_date(node), role: name_role(node) }
142
- end
185
+ doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
143
186
  end
144
187
 
145
188
  # Editable (depositor-managed) creators: the plain names (no authority
@@ -161,7 +204,7 @@ module NEU
161
204
  def preserved_names
162
205
  doc.xpath("/mods:mods/mods:name", NAMESPACE)
163
206
  .reject { |node| editable_creator_name?(node) }
164
- .map { |node| { name: name_display_value_w_date(node), role: name_role(node) } }
207
+ .map { |node| name_entry(node) }
165
208
  end
166
209
 
167
210
  # --- Scalars / simple arrays --------------------------------------------
@@ -196,6 +239,23 @@ module NEU
196
239
  # back by nothing.
197
240
  def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
198
241
  def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
242
+ def place_of_publication = texts_at("/mods:mods/mods:originInfo/mods:place/mods:placeTerm")
243
+ def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
244
+
245
+ # Serials. The @authority a record puts on a frequency is not projected:
246
+ # authority handling is a question the gem defers everywhere else -- for
247
+ # genre, subject and name -- and answering it for one field would be
248
+ # inconsistent.
249
+ def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
250
+
251
+ def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
252
+ def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
253
+
254
+ # An LCC or DDC call number. Note this is NOT the same concept as Atlas's
255
+ # classification_ssim, which carries a FileSet content-type vocabulary --
256
+ # the name collision is accidental and the consumer has to pick a free
257
+ # Solr field.
258
+ def classification = texts_at("/mods:mods/mods:classification")
199
259
 
200
260
  # Every top-level note, keeping its @type. The type carries meaning -- a
201
261
  # "statement of responsibility" is not a "funding" note -- so flattening
@@ -210,11 +270,15 @@ module NEU
210
270
  # location repeats, and one location mixes kinds: a shelf mark and a URL
211
271
  # are not interchangeable, and a consumer has to know which it holds
212
272
  # before it can decide to linkify it. So the parts stay apart.
273
+ #
274
+ # The shelf mark is mods:shelfLocator. There is no shelfLocation element
275
+ # in MODS, and the spec fixture carried the same misspelling, so the
276
+ # field was unconditionally nil and the spec asserted nothing.
213
277
  def location
214
278
  doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
215
279
  entry = {
216
280
  physical_location: child_text(node, "mods:physicalLocation"),
217
- shelf_location: child_text(node, "mods:shelfLocation"),
281
+ shelf_location: child_text(node, "mods:shelfLocator"),
218
282
  url: child_text(node, "mods:url")
219
283
  }
220
284
  entry if entry.values.any?
@@ -259,7 +323,18 @@ module NEU
259
323
  end
260
324
  end
261
325
 
262
- def identifiers = texts_at("/mods:mods/mods:identifier")
326
+ # { type:, value: }, because a DOI, an accession number and a collection
327
+ # id are not the same kind of thing and no consumer can tell them apart
328
+ # from the digits alone -- a reader shown a bare 10.1234/x cannot see it
329
+ # is a DOI, and a display cannot decide to linkify it. The same argument
330
+ # #notes already makes for its @type, and #permanent_url already proves
331
+ # the attribute is load-bearing by special-casing @type='hdl'.
332
+ def identifiers
333
+ doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
334
+ value = clean(node.text)
335
+ { type: clean(node["type"]), value: value } if value
336
+ end
337
+ end
263
338
 
264
339
  def permanent_url
265
340
  node = doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE)
@@ -270,34 +345,92 @@ module NEU
270
345
  # or a full date. Matching the shape explicitly, rather than widening
271
346
  # DateTime.parse, is what lets the declared precision fall out of the parse
272
347
  # instead of being guessed after it.
348
+ # The eleven children the XSD allows under hierarchicalGeographic, in the
349
+ # order MODS lists them -- broadest first, which is also the order a
350
+ # consumer composing a place string wants to reverse.
351
+ HIERARCHICAL_GEOGRAPHIC_LEVELS = %i[
352
+ continent country province region state territory county city
353
+ city_section island area
354
+ ].freeze
355
+
356
+ # recordInfo children. Read as a single value: the schema repeats the
357
+ # element, but a record with two cataloguing provenances is not a case
358
+ # anyone has, and an array here buys nothing.
359
+ RECORD_INFO_PARTS = {
360
+ content_source: "mods:recordContentSource",
361
+ origin: "mods:recordOrigin",
362
+ description_standard: "mods:descriptionStandard",
363
+ creation_date: "mods:recordCreationDate",
364
+ change_date: "mods:recordChangeDate",
365
+ language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
366
+ }.freeze
367
+
273
368
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
274
369
 
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.
370
+ # What #date_parts returns when the element is absent entirely, so an
371
+ # absent date is distinguishable from one present and unparseable.
372
+ EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
373
+ end_precision: nil, qualifier: nil, key_date: nil }.freeze
374
+
375
+ # Everything a record declared about one originInfo date, as
376
+ # { value:, precision:, end_value:, end_precision:, qualifier:, key_date: }.
280
377
  #
281
- # The precision has to be captured here, at the only point where the shape
282
- # is still visible: a year-only date parses to January 1st, and no consumer
283
- # downstream can tell that month and day from a record that claimed them.
284
- # A preservation repository must not project a precision it was not given.
378
+ # A date is not a scalar. Precision established that: a year-only date
379
+ # parses to January 1st, and no consumer downstream can tell that month
380
+ # and day from a record that claimed them. A range and a qualifier are the
381
+ # same kind of claim, and dropping them breaks the same rule -- a
382
+ # preservation repository must not project a value the record did not
383
+ # give. A ranged record was worse than that: #at_xpath took the first
384
+ # node, so one end of the range was PROMOTED to be the date, and the
385
+ # output was indistinguishable from a single certain year.
285
386
  #
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")
294
-
295
- def date_created = date_created_with_precision.first
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
387
+ # The parts are projected as separate flat fields rather than one nested
388
+ # value, because the value half has three consumers that need a real date
389
+ # object -- a Solr sort key, a citation year and an OAI date.
390
+ def date_created_parts = date_parts("dateCreated")
391
+ def date_issued_parts = date_parts("dateIssued")
392
+ def copyright_date_parts = date_parts("copyrightDate")
393
+
394
+ def date_created = date_created_parts[:value]
395
+ def date_created_precision = date_created_parts[:precision]
396
+ def date_created_end = date_created_parts[:end_value]
397
+ def date_created_end_precision = date_created_parts[:end_precision]
398
+ def date_created_qualifier = date_created_parts[:qualifier]
399
+ def date_created_key_date = date_created_parts[:key_date]
400
+
401
+ def date_issued = date_issued_parts[:value]
402
+ def date_issued_precision = date_issued_parts[:precision]
403
+ def date_issued_end = date_issued_parts[:end_value]
404
+ def date_issued_end_precision = date_issued_parts[:end_precision]
405
+ def date_issued_qualifier = date_issued_parts[:qualifier]
406
+ def date_issued_key_date = date_issued_parts[:key_date]
407
+
408
+ def copyright_date = copyright_date_parts[:value]
409
+ def copyright_date_precision = copyright_date_parts[:precision]
410
+ def copyright_date_end = copyright_date_parts[:end_value]
411
+ def copyright_date_end_precision = copyright_date_parts[:end_precision]
412
+ def copyright_date_qualifier = copyright_date_parts[:qualifier]
413
+ def copyright_date_key_date = copyright_date_parts[:key_date]
414
+
415
+ # The [value, precision] pair the precision work introduced. Retained
416
+ # because it is the documented entry point for a caller that wants both
417
+ # halves and nothing else.
418
+ def date_created_with_precision = [date_created, date_created_precision]
419
+ def date_issued_with_precision = [date_issued, date_issued_precision]
420
+ def copyright_date_with_precision = [copyright_date, copyright_date_precision]
421
+
422
+ # Who catalogued this record, to what standard, and when. It describes the
423
+ # CATALOGUING rather than the resource, which is why it is one value and
424
+ # why a consumer is unlikely to want it beside Publisher -- but dropping a
425
+ # preservation repository's provenance statement on read is wrong on its
426
+ # face, so it is projected and the display question is the consumer's.
427
+ def record_info
428
+ node = doc.at_xpath("/mods:mods/mods:recordInfo", NAMESPACE)
429
+ return nil unless node
430
+
431
+ entry = RECORD_INFO_PARTS.transform_values { |xpath| child_text(node, xpath) }
432
+ entry if entry.values.any?
433
+ end
301
434
 
302
435
  # --- Full projection -----------------------------------------------------
303
436
 
@@ -327,13 +460,30 @@ module NEU
327
460
 
328
461
  # origin
329
462
  publication_information: :many,
463
+ place_of_publication: :many,
330
464
  edition: :many,
465
+ issuance: :many,
466
+ frequency: :many,
467
+ # Six rows per originInfo date. Flat rather than one nested value,
468
+ # because the value half has consumers that need a real date object.
331
469
  date_created: :one,
332
470
  date_created_precision: :one,
471
+ date_created_end: :one,
472
+ date_created_end_precision: :one,
473
+ date_created_qualifier: :one,
474
+ date_created_key_date: :one,
333
475
  date_issued: :one,
334
476
  date_issued_precision: :one,
477
+ date_issued_end: :one,
478
+ date_issued_end_precision: :one,
479
+ date_issued_qualifier: :one,
480
+ date_issued_key_date: :one,
335
481
  copyright_date: :one,
336
482
  copyright_date_precision: :one,
483
+ copyright_date_end: :one,
484
+ copyright_date_end_precision: :one,
485
+ copyright_date_qualifier: :one,
486
+ copyright_date_key_date: :one,
337
487
 
338
488
  # physical description
339
489
  resource_type: :many,
@@ -341,7 +491,9 @@ module NEU
341
491
  format: :many,
342
492
  extent: :many,
343
493
  digital_origin: :many,
494
+ reformatting_quality: :many,
344
495
  notes: :many,
496
+ table_of_contents: :many,
345
497
 
346
498
  # subjects
347
499
  topical_subjects: :many,
@@ -349,6 +501,10 @@ module NEU
349
501
  temporal_subjects: :many,
350
502
  personal_name_subjects: :many,
351
503
  corporate_name_subjects: :many,
504
+ genre_subjects: :many,
505
+ geographic_code_subjects: :many,
506
+ title_subjects: :many,
507
+ hierarchical_geographic_subjects: :many,
352
508
  map_data: :many,
353
509
 
354
510
  # related items
@@ -358,7 +514,9 @@ module NEU
358
514
 
359
515
  # identifiers and location
360
516
  identifiers: :many,
517
+ classification: :many,
361
518
  permanent_url: :one,
519
+ record_info: :one,
362
520
  location: :many,
363
521
 
364
522
  # access
@@ -415,8 +573,42 @@ module NEU
415
573
  ["", nil]
416
574
  end
417
575
 
418
- def date_with_precision(element)
419
- node = doc.at_xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
576
+ # One originInfo date element, read by its attributes rather than by
577
+ # position. A record is free to write point="end" first, and taking the
578
+ # first node would then invert the range.
579
+ def date_parts(element)
580
+ nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
581
+ return EMPTY_DATE if nodes.empty?
582
+
583
+ start = nodes.find { |n| attr_value(n, "point") == "start" } ||
584
+ nodes.find { |n| attr_value(n, "point") != "end" }
585
+ finish = nodes.find { |n| attr_value(n, "point") == "end" }
586
+ date_entry(start, finish, nodes)
587
+ end
588
+
589
+ # The end point carries its OWN precision. "1935-06" to "1940" is legal,
590
+ # and reusing the start's granularity for both would assert something the
591
+ # end never claimed -- the precision bug in a new place.
592
+ #
593
+ # The qualifier falls back from the start to the end because v1's loader
594
+ # applied it to both points, and a record marking only one is still
595
+ # telling us the date is uncertain. An unrecognised value survives as
596
+ # itself: MODS enumerates approximate, inferred and questionable, but the
597
+ # record still said something.
598
+ def date_entry(start, finish, nodes)
599
+ value, precision = node_date(start)
600
+ end_value, end_precision = node_date(finish)
601
+ {
602
+ value: value,
603
+ precision: precision,
604
+ end_value: end_value,
605
+ end_precision: end_precision,
606
+ qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
607
+ key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
608
+ }
609
+ end
610
+
611
+ def node_date(node)
420
612
  return [nil, nil] unless node
421
613
 
422
614
  str = NEU::MODS.canonical_ws(node.text)
@@ -425,6 +617,13 @@ module NEU
425
617
  parse_w3cdtf(str)
426
618
  end
427
619
 
620
+ def attr_value(node, name)
621
+ return nil unless node
622
+
623
+ value = NEU::MODS.canonical_ws(node[name].to_s)
624
+ value.empty? ? nil : value
625
+ end
626
+
428
627
  # Byte-faithful title parts off any titleInfo node, shared by #title_parts
429
628
  # (which Cerberus pre-fills its edit forms from) and the variant titles.
430
629
  def title_parts_of(node)
@@ -466,6 +665,19 @@ module NEU
466
665
  # record template that seeds an empty <topic> for an edit form to fill --
467
666
  # which is exactly what Atlas's MODSBuilder writes -- otherwise projects
468
667
  # [nil], and every consumer of that array has to guard for it.
668
+ # #texts_at scoped to a node rather than the document, for a repeatable
669
+ # child of one element.
670
+ # :city_section -> "citySection". The level names are snake_case in the
671
+ # projection and camelCase in the schema.
672
+ def camelize(level)
673
+ head, *rest = level.to_s.split("_")
674
+ [head, *rest.map(&:capitalize)].join
675
+ end
676
+
677
+ def texts_under(node, xpath)
678
+ node.xpath(xpath, NAMESPACE).filter_map { |child| clean(child.text) }
679
+ end
680
+
469
681
  def texts_at(xpath)
470
682
  doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
471
683
  end
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.7.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff