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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/README.md +22 -15
- data/lib/neu/mods/projection.rb +145 -32
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c0467914a611aa9d46065edcd2c0a18cd0decd642e769b70ecfaecc18e7ef1e
|
|
4
|
+
data.tar.gz: f97ba11b67874fefcb98f9de2af47eda370bb61f679e9b10994cf465b31876f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94029ec3b5d70d835677c4ad49cfc02bf7a501c782a91adb38c3a5cf6310ee727ffcef7736975526b699c796ff574ab3be5645ecaa7d576494b9b28c88c67ac1
|
|
7
|
+
data.tar.gz: 3b5c3b2f36af51e6af7424ff5ecdbf365a6c56358bf3aec3cff370b2473ad334cfaed9c20eae4aa2025ab583eb9d0ec9d7439d94fb7d8687e80dd301a64c7c38
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
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.
|
|
38
|
-
# =>
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -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
|
|
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|
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
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
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def
|
|
292
|
-
|
|
293
|
-
def
|
|
294
|
-
|
|
295
|
-
def
|
|
296
|
-
def
|
|
297
|
-
def
|
|
298
|
-
def
|
|
299
|
-
|
|
300
|
-
def
|
|
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
|
-
|
|
419
|
-
|
|
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
|