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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/README.md +22 -15
- data/lib/neu/mods/projection.rb +150 -40
- 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
|
@@ -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
|
|
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|
|
|
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:
|
|
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).
|
|
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
|
-
#
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
290
|
-
#
|
|
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
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
def
|
|
300
|
-
|
|
301
|
-
def
|
|
302
|
-
def
|
|
303
|
-
def
|
|
304
|
-
def
|
|
305
|
-
|
|
306
|
-
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]
|
|
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
|
-
|
|
425
|
-
|
|
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
|
|
471
|
-
#
|
|
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
|