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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/README.md +29 -15
- data/lib/neu/mods/projection.rb +244 -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: 5416d755319f464e252ef4fa07d0372efb11e1379d76ad6155b78f42a61c221a
|
|
4
|
+
data.tar.gz: ddfcf9eb792155c216bf9710ef6cfcc379f914791a782da30577158bb02a1da7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d465b12175d8b15f7da7d7c355af0246294b3e7df0c276be775b6ccf41c14840f716fa7624ed86a54bff69da56625041cf7ca1f245e9ff6bb8f4035b44f70799
|
|
7
|
+
data.tar.gz: ce373c9bb16d1a7e6142a4e0556e669ddb07d0bbe77db172f7954d4d54eb298b4a688c5cf3a60bd5237611670bbfb162c67d7d9c1bd5377a6579d0c78b9a9030
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
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.
|
|
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
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -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
|
|
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|
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
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
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def
|
|
292
|
-
|
|
293
|
-
def
|
|
294
|
-
|
|
295
|
-
def
|
|
296
|
-
def
|
|
297
|
-
def
|
|
298
|
-
def
|
|
299
|
-
|
|
300
|
-
def
|
|
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
|
-
|
|
419
|
-
|
|
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
|