neu-mods 0.7.0 → 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 +150 -40
  5. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afd81033ef60fdd497880f538d3730d1518a37b362307028a165a1f93105bdd2
4
- data.tar.gz: 3f6e1aa77c231d6622dceb4d4ae66d500329776659bf4feb2cb4ea8ded455932
3
+ metadata.gz: 3c0467914a611aa9d46065edcd2c0a18cd0decd642e769b70ecfaecc18e7ef1e
4
+ data.tar.gz: f97ba11b67874fefcb98f9de2af47eda370bb61f679e9b10994cf465b31876f2
5
5
  SHA512:
6
- metadata.gz: 6b643273728275f712a266a33a64cf34bbe078fa405ca29ffd59af52f0e9ee7c9102f3867f6d4129bcbb5b050d28c9a00995190ec5568fea6761ee1af87254c4
7
- data.tar.gz: b86248e24362d8e1436c47c4a0decf5254207ad587791f8fb0434ac7560e4d7a79e3e429464d89ad50dcc92d208e63ad456235a0c94a6c328b7b6efc0951d22f
6
+ metadata.gz: 94029ec3b5d70d835677c4ad49cfc02bf7a501c782a91adb38c3a5cf6310ee727ffcef7736975526b699c796ff574ab3be5645ecaa7d576494b9b28c88c67ac1
7
+ data.tar.gz: 3b5c3b2f36af51e6af7424ff5ecdbf365a6c56358bf3aec3cff370b2473ad334cfaed9c20eae4aa2025ab583eb9d0ec9d7439d94fb7d8687e80dd301a64c7c38
data/.version CHANGED
@@ -1 +1 @@
1
- 0.7.0
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
 
@@ -116,9 +116,7 @@ module NEU
116
116
 
117
117
  # Every <topic> under any top-level <subject> (the access-copy projection,
118
118
  # equivalent to Atlas's extract_topical_subjects).
119
- def topical_subjects
120
- doc.xpath("/mods:mods/mods:subject/mods:topic", NAMESPACE).map { |t| clean(t.text) }
121
- end
119
+ def topical_subjects = texts_at("/mods:mods/mods:subject/mods:topic")
122
120
 
123
121
  # The other subject axes. Cerberus's IPTC ingest writes subject/geographic
124
122
  # from the IPTC City and State fields, so this one was also being written
@@ -134,14 +132,26 @@ module NEU
134
132
 
135
133
  # --- Names ---------------------------------------------------------------
136
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
+
137
149
  # All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
138
150
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
139
151
  # display output is preserved). `role` prefers the type="text" roleTerm,
140
152
  # falling back to the raw code (NOT MARC-relator-translated -- see README).
141
153
  def names
142
- doc.xpath("/mods:mods/mods:name", NAMESPACE).map do |node|
143
- { name: name_display_value_w_date(node), role: name_role(node) }
144
- end
154
+ doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
145
155
  end
146
156
 
147
157
  # Editable (depositor-managed) creators: the plain names (no authority
@@ -163,7 +173,7 @@ module NEU
163
173
  def preserved_names
164
174
  doc.xpath("/mods:mods/mods:name", NAMESPACE)
165
175
  .reject { |node| editable_creator_name?(node) }
166
- .map { |node| { name: name_display_value_w_date(node), role: name_role(node) } }
176
+ .map { |node| name_entry(node) }
167
177
  end
168
178
 
169
179
  # --- Scalars / simple arrays --------------------------------------------
@@ -190,9 +200,7 @@ module NEU
190
200
  def extent = texts_at("/mods:mods/mods:physicalDescription/mods:extent")
191
201
  def digital_origin = texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
192
202
 
193
- def genres
194
- doc.xpath("/mods:mods/mods:genre", NAMESPACE).map { |g| clean(g.text) }
195
- end
203
+ def genres = texts_at("/mods:mods/mods:genre")
196
204
 
197
205
  # originInfo repeats, and so do publisher and edition within one. Cerberus's
198
206
  # IPTC ingest writes the publisher from the IPTC Source field on every batch,
@@ -214,11 +222,15 @@ module NEU
214
222
  # location repeats, and one location mixes kinds: a shelf mark and a URL
215
223
  # are not interchangeable, and a consumer has to know which it holds
216
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.
217
229
  def location
218
230
  doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
219
231
  entry = {
220
232
  physical_location: child_text(node, "mods:physicalLocation"),
221
- shelf_location: child_text(node, "mods:shelfLocation"),
233
+ shelf_location: child_text(node, "mods:shelfLocator"),
222
234
  url: child_text(node, "mods:url")
223
235
  }
224
236
  entry if entry.values.any?
@@ -263,8 +275,17 @@ module NEU
263
275
  end
264
276
  end
265
277
 
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'.
266
284
  def identifiers
267
- doc.xpath("/mods:mods/mods:identifier", NAMESPACE).map { |i| clean(i.text) }
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
268
289
  end
269
290
 
270
291
  def permanent_url
@@ -278,32 +299,57 @@ module NEU
278
299
  # instead of being guessed after it.
279
300
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
280
301
 
281
- # The three originInfo dates, each paired with the granularity the record
282
- # declared, as [value, precision]. value is nil if the element is absent,
283
- # or "" if present but unparseable (mirrors Atlas's safe_date_parse
284
- # rescue). precision is "year", "month" or "day", and nil whenever value
285
- # 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: }.
286
309
  #
287
- # The precision has to be captured here, at the only point where the shape
288
- # is still visible: a year-only date parses to January 1st, and no consumer
289
- # downstream can tell that month and day from a record that claimed them.
290
- # 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.
291
318
  #
292
- # These stay :one where the sibling originInfo elements are :many. MODS
293
- # repeats them, but a date carries a precision alongside it, and an array
294
- # of values paired with an array of precisions is a shape no consumer
295
- # wants. A record that ranges its dates uses point="start"/"end", which is
296
- # its own projection question rather than a cardinality one.
297
- def date_created_with_precision = date_with_precision("dateCreated")
298
- def date_issued_with_precision = date_with_precision("dateIssued")
299
- def copyright_date_with_precision = date_with_precision("copyrightDate")
300
-
301
- def date_created = date_created_with_precision.first
302
- def date_created_precision = date_created_with_precision.last
303
- def date_issued = date_issued_with_precision.first
304
- def date_issued_precision = date_issued_with_precision.last
305
- def copyright_date = copyright_date_with_precision.first
306
- 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]
307
353
 
308
354
  # --- Full projection -----------------------------------------------------
309
355
 
@@ -334,12 +380,26 @@ module NEU
334
380
  # origin
335
381
  publication_information: :many,
336
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.
337
385
  date_created: :one,
338
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,
339
391
  date_issued: :one,
340
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,
341
397
  copyright_date: :one,
342
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,
343
403
 
344
404
  # physical description
345
405
  resource_type: :many,
@@ -421,8 +481,42 @@ module NEU
421
481
  ["", nil]
422
482
  end
423
483
 
424
- def date_with_precision(element)
425
- 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)
426
520
  return [nil, nil] unless node
427
521
 
428
522
  str = NEU::MODS.canonical_ws(node.text)
@@ -431,6 +525,13 @@ module NEU
431
525
  parse_w3cdtf(str)
432
526
  end
433
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
+
434
535
  # Byte-faithful title parts off any titleInfo node, shared by #title_parts
435
536
  # (which Cerberus pre-fills its edit forms from) and the variant titles.
436
537
  def title_parts_of(node)
@@ -467,8 +568,17 @@ module NEU
467
568
  node ? NEU::MODS.canonical_ws(node.text) : ""
468
569
  end
469
570
 
470
- # The :many counterpart of #text_at. Blank members drop out rather than
471
- # arriving as nil, so a consumer mapping over the array cannot trip on one.
571
+ # The :many counterpart of #text_at, and the one way this file builds a
572
+ # string array. Blank members drop out rather than arriving as nil: a
573
+ # record template that seeds an empty <topic> for an edit form to fill --
574
+ # which is exactly what Atlas's MODSBuilder writes -- otherwise projects
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
+
472
582
  def texts_at(xpath)
473
583
  doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
474
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.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff