neu-mods 0.7.1 → 0.8.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 +22 -15
  4. data/lib/neu/mods/projection.rb +145 -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: 3c0467914a611aa9d46065edcd2c0a18cd0decd642e769b70ecfaecc18e7ef1e
4
+ data.tar.gz: f97ba11b67874fefcb98f9de2af47eda370bb61f679e9b10994cf465b31876f2
5
5
  SHA512:
6
- metadata.gz: ce304892f19636ecc57c487810a2ef2932f253d9702dcce6974a16c8e047b56ceeffb3ae25750bf2e5937345523567dd9d598ef59c32e671dcc80d62541748c8
7
- data.tar.gz: a31247bacadff8b81b4789c2dc69647b2036b98e246af02492d1ecdfb14e1a625e1deb2f7c7ad7afff5298d531a6b6bcacfa803ab3ea7629ecea623e45e06667
6
+ metadata.gz: 94029ec3b5d70d835677c4ad49cfc02bf7a501c782a91adb38c3a5cf6310ee727ffcef7736975526b699c796ff574ab3be5645ecaa7d576494b9b28c88c67ac1
7
+ data.tar.gz: 3b5c3b2f36af51e6af7424ff5ecdbf365a6c56358bf3aec3cff370b2473ad334cfaed9c20eae4aa2025ab583eb9d0ec9d7439d94fb7d8687e80dd301a64c7c38
data/.version CHANGED
@@ -1 +1 @@
1
- 0.7.1
1
+ 0.8.0
data/README.md CHANGED
@@ -34,13 +34,17 @@ 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
@@ -123,14 +127,17 @@ intentional notes:
123
127
  label vocabulary belongs to the consumer — Cerberus's edit form and Atlas's
124
128
  display word the same role differently. An unrecognised language code also
125
129
  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.
130
+ - **`description` is not projected.** MODS does define `name/description`, but
131
+ that annotates a *name*, not the resource, so it is not the field Atlas once
132
+ called `description`. The two candidates for that one an `abstract` variant
133
+ and `physicalDescription/note` describe different things. Projecting a guess
134
+ would put wrong data in the field rather than leave an empty one, so it waits
135
+ on a decision.
136
+ - **A date carries more than a value.** Each of `dateCreated`, `dateIssued` and
137
+ `copyrightDate` projects a value, its precision, an end value with its own
138
+ precision, the `@qualifier` and the `@keyDate` flag. The gem does not *pick*
139
+ the key date, because "which date to sort on" and "which date to display" are
140
+ not necessarily the same answer, and choosing is the consumer's job.
134
141
 
135
142
  ## Source convention
136
143
 
@@ -132,14 +132,26 @@ module NEU
132
132
 
133
133
  # --- Names ---------------------------------------------------------------
134
134
 
135
+ # One name as the access copy wants it. `affiliation` is how a reader
136
+ # tells one J. Doe from another, and it is the field an institutional
137
+ # repository most wants: it repeats in the schema, so it is an array.
138
+ #
139
+ # Added to the entry rather than as a parallel field, so a name and its
140
+ # affiliation cannot be zipped together wrongly by a consumer.
141
+ def name_entry(node)
142
+ {
143
+ name: name_display_value_w_date(node),
144
+ role: name_role(node),
145
+ affiliation: texts_under(node, "mods:affiliation")
146
+ }
147
+ end
148
+
135
149
  # All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
136
150
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
137
151
  # display output is preserved). `role` prefers the type="text" roleTerm,
138
152
  # falling back to the raw code (NOT MARC-relator-translated -- see README).
139
153
  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
154
+ doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
143
155
  end
144
156
 
145
157
  # Editable (depositor-managed) creators: the plain names (no authority
@@ -161,7 +173,7 @@ module NEU
161
173
  def preserved_names
162
174
  doc.xpath("/mods:mods/mods:name", NAMESPACE)
163
175
  .reject { |node| editable_creator_name?(node) }
164
- .map { |node| { name: name_display_value_w_date(node), role: name_role(node) } }
176
+ .map { |node| name_entry(node) }
165
177
  end
166
178
 
167
179
  # --- Scalars / simple arrays --------------------------------------------
@@ -210,11 +222,15 @@ module NEU
210
222
  # location repeats, and one location mixes kinds: a shelf mark and a URL
211
223
  # are not interchangeable, and a consumer has to know which it holds
212
224
  # before it can decide to linkify it. So the parts stay apart.
225
+ #
226
+ # The shelf mark is mods:shelfLocator. There is no shelfLocation element
227
+ # in MODS, and the spec fixture carried the same misspelling, so the
228
+ # field was unconditionally nil and the spec asserted nothing.
213
229
  def location
214
230
  doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
215
231
  entry = {
216
232
  physical_location: child_text(node, "mods:physicalLocation"),
217
- shelf_location: child_text(node, "mods:shelfLocation"),
233
+ shelf_location: child_text(node, "mods:shelfLocator"),
218
234
  url: child_text(node, "mods:url")
219
235
  }
220
236
  entry if entry.values.any?
@@ -259,7 +275,18 @@ module NEU
259
275
  end
260
276
  end
261
277
 
262
- def identifiers = texts_at("/mods:mods/mods:identifier")
278
+ # { type:, value: }, because a DOI, an accession number and a collection
279
+ # id are not the same kind of thing and no consumer can tell them apart
280
+ # from the digits alone -- a reader shown a bare 10.1234/x cannot see it
281
+ # is a DOI, and a display cannot decide to linkify it. The same argument
282
+ # #notes already makes for its @type, and #permanent_url already proves
283
+ # the attribute is load-bearing by special-casing @type='hdl'.
284
+ def identifiers
285
+ doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
286
+ value = clean(node.text)
287
+ { type: clean(node["type"]), value: value } if value
288
+ end
289
+ end
263
290
 
264
291
  def permanent_url
265
292
  node = doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE)
@@ -272,32 +299,57 @@ module NEU
272
299
  # instead of being guessed after it.
273
300
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
274
301
 
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.
302
+ # What #date_parts returns when the element is absent entirely, so an
303
+ # absent date is distinguishable from one present and unparseable.
304
+ EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
305
+ end_precision: nil, qualifier: nil, key_date: nil }.freeze
306
+
307
+ # Everything a record declared about one originInfo date, as
308
+ # { value:, precision:, end_value:, end_precision:, qualifier:, key_date: }.
280
309
  #
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.
310
+ # A date is not a scalar. Precision established that: a year-only date
311
+ # parses to January 1st, and no consumer downstream can tell that month
312
+ # and day from a record that claimed them. A range and a qualifier are the
313
+ # same kind of claim, and dropping them breaks the same rule -- a
314
+ # preservation repository must not project a value the record did not
315
+ # give. A ranged record was worse than that: #at_xpath took the first
316
+ # node, so one end of the range was PROMOTED to be the date, and the
317
+ # output was indistinguishable from a single certain year.
285
318
  #
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
319
+ # The parts are projected as separate flat fields rather than one nested
320
+ # value, because the value half has three consumers that need a real date
321
+ # object -- a Solr sort key, a citation year and an OAI date.
322
+ def date_created_parts = date_parts("dateCreated")
323
+ def date_issued_parts = date_parts("dateIssued")
324
+ def copyright_date_parts = date_parts("copyrightDate")
325
+
326
+ def date_created = date_created_parts[:value]
327
+ def date_created_precision = date_created_parts[:precision]
328
+ def date_created_end = date_created_parts[:end_value]
329
+ def date_created_end_precision = date_created_parts[:end_precision]
330
+ def date_created_qualifier = date_created_parts[:qualifier]
331
+ def date_created_key_date = date_created_parts[:key_date]
332
+
333
+ def date_issued = date_issued_parts[:value]
334
+ def date_issued_precision = date_issued_parts[:precision]
335
+ def date_issued_end = date_issued_parts[:end_value]
336
+ def date_issued_end_precision = date_issued_parts[:end_precision]
337
+ def date_issued_qualifier = date_issued_parts[:qualifier]
338
+ def date_issued_key_date = date_issued_parts[:key_date]
339
+
340
+ def copyright_date = copyright_date_parts[:value]
341
+ def copyright_date_precision = copyright_date_parts[:precision]
342
+ def copyright_date_end = copyright_date_parts[:end_value]
343
+ def copyright_date_end_precision = copyright_date_parts[:end_precision]
344
+ def copyright_date_qualifier = copyright_date_parts[:qualifier]
345
+ def copyright_date_key_date = copyright_date_parts[:key_date]
346
+
347
+ # The [value, precision] pair the precision work introduced. Retained
348
+ # because it is the documented entry point for a caller that wants both
349
+ # halves and nothing else.
350
+ def date_created_with_precision = [date_created, date_created_precision]
351
+ def date_issued_with_precision = [date_issued, date_issued_precision]
352
+ def copyright_date_with_precision = [copyright_date, copyright_date_precision]
301
353
 
302
354
  # --- Full projection -----------------------------------------------------
303
355
 
@@ -328,12 +380,26 @@ module NEU
328
380
  # origin
329
381
  publication_information: :many,
330
382
  edition: :many,
383
+ # Six rows per originInfo date. Flat rather than one nested value,
384
+ # because the value half has consumers that need a real date object.
331
385
  date_created: :one,
332
386
  date_created_precision: :one,
387
+ date_created_end: :one,
388
+ date_created_end_precision: :one,
389
+ date_created_qualifier: :one,
390
+ date_created_key_date: :one,
333
391
  date_issued: :one,
334
392
  date_issued_precision: :one,
393
+ date_issued_end: :one,
394
+ date_issued_end_precision: :one,
395
+ date_issued_qualifier: :one,
396
+ date_issued_key_date: :one,
335
397
  copyright_date: :one,
336
398
  copyright_date_precision: :one,
399
+ copyright_date_end: :one,
400
+ copyright_date_end_precision: :one,
401
+ copyright_date_qualifier: :one,
402
+ copyright_date_key_date: :one,
337
403
 
338
404
  # physical description
339
405
  resource_type: :many,
@@ -415,8 +481,42 @@ module NEU
415
481
  ["", nil]
416
482
  end
417
483
 
418
- def date_with_precision(element)
419
- node = doc.at_xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
484
+ # One originInfo date element, read by its attributes rather than by
485
+ # position. A record is free to write point="end" first, and taking the
486
+ # first node would then invert the range.
487
+ def date_parts(element)
488
+ nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
489
+ return EMPTY_DATE if nodes.empty?
490
+
491
+ start = nodes.find { |n| attr_value(n, "point") == "start" } ||
492
+ nodes.find { |n| attr_value(n, "point") != "end" }
493
+ finish = nodes.find { |n| attr_value(n, "point") == "end" }
494
+ date_entry(start, finish, nodes)
495
+ end
496
+
497
+ # The end point carries its OWN precision. "1935-06" to "1940" is legal,
498
+ # and reusing the start's granularity for both would assert something the
499
+ # end never claimed -- the precision bug in a new place.
500
+ #
501
+ # The qualifier falls back from the start to the end because v1's loader
502
+ # applied it to both points, and a record marking only one is still
503
+ # telling us the date is uncertain. An unrecognised value survives as
504
+ # itself: MODS enumerates approximate, inferred and questionable, but the
505
+ # record still said something.
506
+ def date_entry(start, finish, nodes)
507
+ value, precision = node_date(start)
508
+ end_value, end_precision = node_date(finish)
509
+ {
510
+ value: value,
511
+ precision: precision,
512
+ end_value: end_value,
513
+ end_precision: end_precision,
514
+ qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
515
+ key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
516
+ }
517
+ end
518
+
519
+ def node_date(node)
420
520
  return [nil, nil] unless node
421
521
 
422
522
  str = NEU::MODS.canonical_ws(node.text)
@@ -425,6 +525,13 @@ module NEU
425
525
  parse_w3cdtf(str)
426
526
  end
427
527
 
528
+ def attr_value(node, name)
529
+ return nil unless node
530
+
531
+ value = NEU::MODS.canonical_ws(node[name].to_s)
532
+ value.empty? ? nil : value
533
+ end
534
+
428
535
  # Byte-faithful title parts off any titleInfo node, shared by #title_parts
429
536
  # (which Cerberus pre-fills its edit forms from) and the variant titles.
430
537
  def title_parts_of(node)
@@ -466,6 +573,12 @@ module NEU
466
573
  # record template that seeds an empty <topic> for an edit form to fill --
467
574
  # which is exactly what Atlas's MODSBuilder writes -- otherwise projects
468
575
  # [nil], and every consumer of that array has to guard for it.
576
+ # #texts_at scoped to a node rather than the document, for a repeatable
577
+ # child of one element.
578
+ def texts_under(node, xpath)
579
+ node.xpath(xpath, NAMESPACE).filter_map { |child| clean(child.text) }
580
+ end
581
+
469
582
  def texts_at(xpath)
470
583
  doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
471
584
  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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff