neu-mods 0.5.0 → 0.6.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 -1
- data/lib/neu/mods/projection.rb +261 -49
- data/lib/neu/mods/selectors.rb +20 -4
- data/lib/neu-mods.rb +6 -0
- 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: 5b89b85501bd24a3e03e6cb9c0d8945c32b5b053ce21ce12d06c92ff1d411f99
|
|
4
|
+
data.tar.gz: f7ba90fecf34428546872120f116b0de258bb56b4fb42e2c2d90a4d76eea338d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 126a6f6a2fc04be9ee91a14c9469b05c9ebd5535a65f254116ac402d681e5d68d113c060003b365269a496d18a3917976b5ff9cd60ad1cbc4dfc746b63f4c77a
|
|
7
|
+
data.tar.gz: db7440392fde517da3bd14f2ed90fe8623e8da15940989d49c85bb1e1c3a338f8b0c8f9e3df7bfbaafc608fe1379b3cd84cec627e3d78dab24a59e26f57d98a8
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.6.0
|
data/README.md
CHANGED
|
@@ -37,9 +37,22 @@ doc.date_created_with_precision
|
|
|
37
37
|
# => [DateTime, "year"|"month"|"day"] w3cdtf YYYY, YYYY-MM
|
|
38
38
|
# and YYYY-MM-DD all parse; the precision says which shape
|
|
39
39
|
# the record declared, so display cannot invent a month or
|
|
40
|
-
# a day the record never claimed
|
|
40
|
+
# a day the record never claimed. Same for
|
|
41
|
+
# date_issued_with_precision and
|
|
42
|
+
# copyright_date_with_precision.
|
|
43
|
+
doc.notes # => [{ type: "funding", value: "..." }, ...]
|
|
44
|
+
doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
|
|
45
|
+
# every relatedItem that is not a series or a host
|
|
46
|
+
doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
|
|
47
|
+
doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
|
|
41
48
|
doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
|
|
42
49
|
|
|
50
|
+
# The field registry -- the single declaration of what this gem projects.
|
|
51
|
+
# name => :one or :many. to_h is derived from it, and a consumer builds its own
|
|
52
|
+
# schema from it rather than re-listing the field set by hand. Cardinality
|
|
53
|
+
# follows what MODS marks repeatable, so a field can never silently truncate.
|
|
54
|
+
NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
|
|
55
|
+
|
|
43
56
|
# Pure title composition (no document needed) — for callers that already hold
|
|
44
57
|
# the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
|
|
45
58
|
NEU::MODS.compose_title(non_sort: "", title: "What's New",
|
|
@@ -104,6 +117,14 @@ intentional notes:
|
|
|
104
117
|
text forms (the norm) are unaffected; code-only records would differ. Vendoring
|
|
105
118
|
those lookup tables (or depending on `iso-639`) is deferred to keep the gem
|
|
106
119
|
Nokogiri-only and small.
|
|
120
|
+
- **`description` is not projected.** MODS has no element of that name, and the
|
|
121
|
+
two candidates — an `abstract` variant and `physicalDescription/note` —
|
|
122
|
+
describe different things. Projecting a guess would put wrong data in the
|
|
123
|
+
field rather than leave an empty one, so it waits on a decision.
|
|
124
|
+
- **A date range is not projected.** `dateCreated`, `dateIssued` and
|
|
125
|
+
`copyrightDate` each project one value with its precision. A record that
|
|
126
|
+
ranges a date uses `point="start"` / `point="end"`, which needs a shape of its
|
|
127
|
+
own rather than a second array.
|
|
107
128
|
|
|
108
129
|
## Source convention
|
|
109
130
|
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -12,6 +12,8 @@ module NEU
|
|
|
12
12
|
# Empty-value conventions mirror Atlas: scalar fields are "" when absent
|
|
13
13
|
# (matching `.text.squish` on an empty node set), except `permanent_url` and
|
|
14
14
|
# `date_created`, which are nil when their node is absent. Arrays are [].
|
|
15
|
+
# Which fields are scalar and which are arrays is declared in FIELDS, not
|
|
16
|
+
# left to each method to decide.
|
|
15
17
|
module Projection
|
|
16
18
|
# --- Title ---------------------------------------------------------------
|
|
17
19
|
|
|
@@ -25,16 +27,19 @@ module NEU
|
|
|
25
27
|
# rewrite the curator's characters in the preservation XML on the next save.
|
|
26
28
|
# #access_title_parts is the normalised surface.
|
|
27
29
|
def title_parts
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
non_sort: child_text(ti, "mods:nonSort"),
|
|
31
|
-
subtitle: child_text(ti, "mods:subTitle"),
|
|
32
|
-
title: child_text(ti, "mods:title"),
|
|
33
|
-
part_name: child_text(ti, "mods:partName"),
|
|
34
|
-
part_number: child_text(ti, "mods:partNumber")
|
|
35
|
-
}
|
|
30
|
+
title_parts_of(primary_title_info)
|
|
36
31
|
end
|
|
37
32
|
|
|
33
|
+
# The variant titles, each composed and normalised like the main title.
|
|
34
|
+
# MODS repeats titleInfo, so a record may carry more than one of a type.
|
|
35
|
+
# These are what keeps the primary-title fallback's refusal to promote a
|
|
36
|
+
# variant from hiding anything: the variant still reaches a reader, under
|
|
37
|
+
# a label that says which kind of title it is.
|
|
38
|
+
def alternative_title = variant_titles("alternative")
|
|
39
|
+
def uniform_title = variant_titles("uniform")
|
|
40
|
+
def translated_title = variant_titles("translated")
|
|
41
|
+
def abbreviated_title = variant_titles("abbreviated")
|
|
42
|
+
|
|
38
43
|
# Composed display title (the former Atlas MODSDecoration#plain_title), driven
|
|
39
44
|
# off the scoped primary title.
|
|
40
45
|
def plain_title
|
|
@@ -86,10 +91,21 @@ module NEU
|
|
|
86
91
|
join_paragraphs(abstract_nodes)
|
|
87
92
|
end
|
|
88
93
|
|
|
94
|
+
# Every top-level accessCondition joined, regardless of @type. Retained
|
|
95
|
+
# because it is the only projection that carries an untyped or
|
|
96
|
+
# unrecognised accessCondition, which the two typed fields below cannot
|
|
97
|
+
# see -- a consumer that renders only those needs this as its fallback.
|
|
89
98
|
def access_condition
|
|
90
99
|
join_paragraphs(doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE))
|
|
91
100
|
end
|
|
92
101
|
|
|
102
|
+
# The two @type values MODS defines, projected apart. Collapsing them into
|
|
103
|
+
# one value presented an access *restriction* to a reader as a *licence*,
|
|
104
|
+
# which is the one defect in this area that misinforms someone about their
|
|
105
|
+
# rights rather than merely hiding a field.
|
|
106
|
+
def use_and_reproduction = access_conditions_of_type("use and reproduction")
|
|
107
|
+
def restriction_on_access = access_conditions_of_type("restriction on access")
|
|
108
|
+
|
|
93
109
|
# --- Subjects ------------------------------------------------------------
|
|
94
110
|
|
|
95
111
|
# The editable free-text keyword set (Cerberus simple form): topics under the
|
|
@@ -104,6 +120,18 @@ module NEU
|
|
|
104
120
|
doc.xpath("/mods:mods/mods:subject/mods:topic", NAMESPACE).map { |t| clean(t.text) }
|
|
105
121
|
end
|
|
106
122
|
|
|
123
|
+
# The other subject axes. Cerberus's IPTC ingest writes subject/geographic
|
|
124
|
+
# from the IPTC City and State fields, so this one was also being written
|
|
125
|
+
# on every batch and read back by nothing.
|
|
126
|
+
def geographic_subjects = texts_at("/mods:mods/mods:subject/mods:geographic")
|
|
127
|
+
def temporal_subjects = texts_at("/mods:mods/mods:subject/mods:temporal")
|
|
128
|
+
|
|
129
|
+
# Name subjects compose through the same display-value port as #names, so
|
|
130
|
+
# one person reads the same whether they authored the work or are its
|
|
131
|
+
# subject.
|
|
132
|
+
def personal_name_subjects = name_subjects("personal")
|
|
133
|
+
def corporate_name_subjects = name_subjects("corporate")
|
|
134
|
+
|
|
107
135
|
# --- Names ---------------------------------------------------------------
|
|
108
136
|
|
|
109
137
|
# All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
|
|
@@ -148,18 +176,85 @@ module NEU
|
|
|
148
176
|
end.compact
|
|
149
177
|
end
|
|
150
178
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def
|
|
179
|
+
# MODS repeats typeOfResource, and repeats physicalDescription (and form and
|
|
180
|
+
# extent within one), so all four are :many. A record that is both text and
|
|
181
|
+
# a still image used to project as text alone.
|
|
182
|
+
def resource_type = texts_at("/mods:mods/mods:typeOfResource")
|
|
183
|
+
def format = texts_at("/mods:mods/mods:physicalDescription/mods:form")
|
|
184
|
+
def extent = texts_at("/mods:mods/mods:physicalDescription/mods:extent")
|
|
185
|
+
def digital_origin = texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
|
|
155
186
|
|
|
156
187
|
def genres
|
|
157
188
|
doc.xpath("/mods:mods/mods:genre", NAMESPACE).map { |g| clean(g.text) }
|
|
158
189
|
end
|
|
159
190
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
191
|
+
# originInfo repeats, and so do publisher and edition within one. Cerberus's
|
|
192
|
+
# IPTC ingest writes the publisher from the IPTC Source field on every batch,
|
|
193
|
+
# so this element was being written into the preservation XML and then read
|
|
194
|
+
# back by nothing.
|
|
195
|
+
def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
|
|
196
|
+
def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
|
|
197
|
+
|
|
198
|
+
# Every top-level note, keeping its @type. The type carries meaning -- a
|
|
199
|
+
# "statement of responsibility" is not a "funding" note -- so flattening
|
|
200
|
+
# them into bare strings would repeat the accessCondition mistake.
|
|
201
|
+
def notes
|
|
202
|
+
doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
|
|
203
|
+
value = clean(node.text)
|
|
204
|
+
{ type: clean(node["type"]), value: value } if value
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# location repeats, and one location mixes kinds: a shelf mark and a URL
|
|
209
|
+
# are not interchangeable, and a consumer has to know which it holds
|
|
210
|
+
# before it can decide to linkify it. So the parts stay apart.
|
|
211
|
+
def location
|
|
212
|
+
doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
|
|
213
|
+
entry = {
|
|
214
|
+
physical_location: child_text(node, "mods:physicalLocation"),
|
|
215
|
+
shelf_location: child_text(node, "mods:shelfLocation"),
|
|
216
|
+
url: child_text(node, "mods:url")
|
|
217
|
+
}
|
|
218
|
+
entry if entry.values.any?
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# subject/cartographics, kept structured. Composing "scale ; projection
|
|
223
|
+
# coordinates" into one string is display policy, and this gem does not own
|
|
224
|
+
# that -- a consumer that wants only the coordinates should not have to
|
|
225
|
+
# unpick a sentence to get them.
|
|
226
|
+
def map_data
|
|
227
|
+
doc.xpath("/mods:mods/mods:subject/mods:cartographics", NAMESPACE).filter_map do |node|
|
|
228
|
+
entry = {
|
|
229
|
+
scale: child_text(node, "mods:scale"),
|
|
230
|
+
projection: child_text(node, "mods:projection"),
|
|
231
|
+
coordinates: child_text(node, "mods:coordinates")
|
|
232
|
+
}
|
|
233
|
+
entry if entry.values.any?
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def related_series = related_item_titles("series")
|
|
238
|
+
def host_collections = related_item_titles("host")
|
|
239
|
+
|
|
240
|
+
# relatedItem @type values that already have a field of their own, so the
|
|
241
|
+
# catch-all below does not repeat them.
|
|
242
|
+
NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
|
|
243
|
+
|
|
244
|
+
# Every other relatedItem, keeping its @type. MODS also defines
|
|
245
|
+
# constituent, otherFormat, original, preceding, succeeding, isReferencedBy
|
|
246
|
+
# and reviewOf, and a record carrying any of them projected nothing at all.
|
|
247
|
+
# The type rides along because "the print edition" and "reviewed in" are
|
|
248
|
+
# not the same relationship, and no consumer can recover which it holds
|
|
249
|
+
# from the title alone. An untyped relatedItem lands here with a nil type.
|
|
250
|
+
def related_items
|
|
251
|
+
doc.xpath("/mods:mods/mods:relatedItem", NAMESPACE).filter_map do |node|
|
|
252
|
+
type = clean(node["type"])
|
|
253
|
+
next if NAMED_RELATED_ITEM_TYPES.include?(type)
|
|
254
|
+
|
|
255
|
+
title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
|
|
256
|
+
{ type: type, title: title } if title
|
|
257
|
+
end
|
|
163
258
|
end
|
|
164
259
|
|
|
165
260
|
def identifiers
|
|
@@ -177,56 +272,112 @@ module NEU
|
|
|
177
272
|
# instead of being guessed after it.
|
|
178
273
|
W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
|
|
179
274
|
|
|
180
|
-
#
|
|
181
|
-
# [value, precision]. value is nil if
|
|
182
|
-
# present but unparseable (mirrors Atlas's safe_date_parse
|
|
183
|
-
# precision is "year", "month" or "day", and nil whenever value
|
|
184
|
-
# DateTime.
|
|
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.
|
|
185
280
|
#
|
|
186
281
|
# The precision has to be captured here, at the only point where the shape
|
|
187
282
|
# is still visible: a year-only date parses to January 1st, and no consumer
|
|
188
283
|
# downstream can tell that month and day from a record that claimed them.
|
|
189
284
|
# A preservation repository must not project a precision it was not given.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
285
|
+
#
|
|
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")
|
|
199
294
|
|
|
200
295
|
def date_created = date_created_with_precision.first
|
|
201
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
|
|
202
301
|
|
|
203
302
|
# --- Full projection -----------------------------------------------------
|
|
204
303
|
|
|
304
|
+
# The field registry: the single declaration of what this gem projects.
|
|
305
|
+
# Field name => cardinality, :one or :many. The projection method of the
|
|
306
|
+
# same name owns the XPath; this row says the field exists and whether it
|
|
307
|
+
# is single- or multi-valued. #to_h is derived from it, and Atlas derives
|
|
308
|
+
# its Metadata::MODS attr_json set from it, so a field cannot be projected
|
|
309
|
+
# here and go undeclared there (or the reverse).
|
|
310
|
+
#
|
|
311
|
+
# Cardinality is the half that earns its keep. The at_xpath-versus-xpath
|
|
312
|
+
# choice here and the single-versus-array column choice in Atlas used to be
|
|
313
|
+
# made independently in two repos with nothing tying them together, which is
|
|
314
|
+
# how repeatable MODS elements ended up truncated to their first match.
|
|
315
|
+
# #cardinality_of checks each method against its row.
|
|
316
|
+
FIELDS = {
|
|
317
|
+
# titles
|
|
318
|
+
main_title: :one,
|
|
319
|
+
alternative_title: :many,
|
|
320
|
+
uniform_title: :many,
|
|
321
|
+
translated_title: :many,
|
|
322
|
+
abbreviated_title: :many,
|
|
323
|
+
|
|
324
|
+
names: :many,
|
|
325
|
+
languages: :many,
|
|
326
|
+
abstract: :one,
|
|
327
|
+
|
|
328
|
+
# origin
|
|
329
|
+
publication_information: :many,
|
|
330
|
+
edition: :many,
|
|
331
|
+
date_created: :one,
|
|
332
|
+
date_created_precision: :one,
|
|
333
|
+
date_issued: :one,
|
|
334
|
+
date_issued_precision: :one,
|
|
335
|
+
copyright_date: :one,
|
|
336
|
+
copyright_date_precision: :one,
|
|
337
|
+
|
|
338
|
+
# physical description
|
|
339
|
+
resource_type: :many,
|
|
340
|
+
genres: :many,
|
|
341
|
+
format: :many,
|
|
342
|
+
extent: :many,
|
|
343
|
+
digital_origin: :many,
|
|
344
|
+
notes: :many,
|
|
345
|
+
|
|
346
|
+
# subjects
|
|
347
|
+
topical_subjects: :many,
|
|
348
|
+
geographic_subjects: :many,
|
|
349
|
+
temporal_subjects: :many,
|
|
350
|
+
personal_name_subjects: :many,
|
|
351
|
+
corporate_name_subjects: :many,
|
|
352
|
+
map_data: :many,
|
|
353
|
+
|
|
354
|
+
# related items
|
|
355
|
+
related_series: :many,
|
|
356
|
+
host_collections: :many,
|
|
357
|
+
related_items: :many,
|
|
358
|
+
|
|
359
|
+
# identifiers and location
|
|
360
|
+
identifiers: :many,
|
|
361
|
+
permanent_url: :one,
|
|
362
|
+
location: :many,
|
|
363
|
+
|
|
364
|
+
# access
|
|
365
|
+
access_condition: :one,
|
|
366
|
+
use_and_reproduction: :one,
|
|
367
|
+
restriction_on_access: :one
|
|
368
|
+
}.freeze
|
|
369
|
+
|
|
205
370
|
# The complete read projection, keyed to Atlas's Metadata::MODS attribute
|
|
206
371
|
# names -- a drop-in source for `convert_xml_to_json`.
|
|
207
372
|
def to_h
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{
|
|
211
|
-
main_title: access_title_parts,
|
|
212
|
-
names: names,
|
|
213
|
-
languages: languages,
|
|
214
|
-
date_created: date,
|
|
215
|
-
date_created_precision: date_precision,
|
|
216
|
-
resource_type: resource_type,
|
|
217
|
-
genres: genres,
|
|
218
|
-
format: format,
|
|
219
|
-
extent: extent,
|
|
220
|
-
digital_origin: digital_origin,
|
|
221
|
-
abstract: abstract,
|
|
222
|
-
related_series: related_series,
|
|
223
|
-
topical_subjects: topical_subjects,
|
|
224
|
-
identifiers: identifiers,
|
|
225
|
-
permanent_url: permanent_url,
|
|
226
|
-
access_condition: access_condition
|
|
227
|
-
}
|
|
373
|
+
FIELDS.keys.to_h { |field| [field, public_send(field)] }
|
|
228
374
|
end
|
|
229
375
|
|
|
376
|
+
# The cardinality a projected value actually has, for checking a value
|
|
377
|
+
# against its FIELDS row. An Array is :many and anything else is :one, so a
|
|
378
|
+
# field declared :many that forgot to switch at_xpath for xpath is caught.
|
|
379
|
+
def self.cardinality_of(value) = value.is_a?(Array) ? :many : :one
|
|
380
|
+
|
|
230
381
|
# The title parts as the access copy wants them: normalised like the
|
|
231
382
|
# abstract, so a curly quote, an invisible format mark or a Windows-1252
|
|
232
383
|
# control cannot reach Solr or a display template. Titles and prose share
|
|
@@ -235,6 +386,11 @@ module NEU
|
|
|
235
386
|
title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
236
387
|
end
|
|
237
388
|
|
|
389
|
+
# Atlas names this field main_title; the registry requires a method per
|
|
390
|
+
# field name, and #access_title_parts is the descriptive name for what it
|
|
391
|
+
# returns. Kept as an alias rather than a rename so both read well.
|
|
392
|
+
def main_title = access_title_parts
|
|
393
|
+
|
|
238
394
|
private
|
|
239
395
|
|
|
240
396
|
# --- helpers -------------------------------------------------------------
|
|
@@ -259,11 +415,58 @@ module NEU
|
|
|
259
415
|
["", nil]
|
|
260
416
|
end
|
|
261
417
|
|
|
418
|
+
def date_with_precision(element)
|
|
419
|
+
node = doc.at_xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
|
|
420
|
+
return [nil, nil] unless node
|
|
421
|
+
|
|
422
|
+
str = NEU::MODS.canonical_ws(node.text)
|
|
423
|
+
return [nil, nil] if str.empty?
|
|
424
|
+
|
|
425
|
+
parse_w3cdtf(str)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Byte-faithful title parts off any titleInfo node, shared by #title_parts
|
|
429
|
+
# (which Cerberus pre-fills its edit forms from) and the variant titles.
|
|
430
|
+
def title_parts_of(node)
|
|
431
|
+
{
|
|
432
|
+
non_sort: child_text(node, "mods:nonSort"),
|
|
433
|
+
subtitle: child_text(node, "mods:subTitle"),
|
|
434
|
+
title: child_text(node, "mods:title"),
|
|
435
|
+
part_name: child_text(node, "mods:partName"),
|
|
436
|
+
part_number: child_text(node, "mods:partNumber")
|
|
437
|
+
}
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A variant title composed the way the access copy wants it: normalised
|
|
441
|
+
# first, like #access_title_parts, so a curly quote or an invisible format
|
|
442
|
+
# mark cannot reach Solr or a display template through this route either.
|
|
443
|
+
def variant_titles(type)
|
|
444
|
+
doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
|
|
445
|
+
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
446
|
+
clean(Projection.compose_title(parts))
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def name_subjects(type)
|
|
451
|
+
doc.xpath("/mods:mods/mods:subject/mods:name[@type='#{type}']", NAMESPACE)
|
|
452
|
+
.filter_map { |node| name_display_value_w_date(node) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def related_item_titles(type)
|
|
456
|
+
texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
|
|
457
|
+
end
|
|
458
|
+
|
|
262
459
|
def text_at(xpath)
|
|
263
460
|
node = doc.at_xpath(xpath, NAMESPACE)
|
|
264
461
|
node ? NEU::MODS.canonical_ws(node.text) : ""
|
|
265
462
|
end
|
|
266
463
|
|
|
464
|
+
# The :many counterpart of #text_at. Blank members drop out rather than
|
|
465
|
+
# arriving as nil, so a consumer mapping over the array cannot trip on one.
|
|
466
|
+
def texts_at(xpath)
|
|
467
|
+
doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
|
|
468
|
+
end
|
|
469
|
+
|
|
267
470
|
def child_text(parent, xpath)
|
|
268
471
|
return nil unless parent
|
|
269
472
|
|
|
@@ -288,6 +491,15 @@ module NEU
|
|
|
288
491
|
NEU::MODS.canonical_ws(str)
|
|
289
492
|
end
|
|
290
493
|
|
|
494
|
+
# The schema leaves accessCondition/@type an open string, so match on the
|
|
495
|
+
# canonicalised, case-folded value rather than in the XPath: real records
|
|
496
|
+
# carry "Use and Reproduction" as readily as the MODS-recommended casing.
|
|
497
|
+
def access_conditions_of_type(type)
|
|
498
|
+
nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
|
|
499
|
+
.select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
|
|
500
|
+
join_paragraphs(nodes)
|
|
501
|
+
end
|
|
502
|
+
|
|
291
503
|
def join_paragraphs(nodes)
|
|
292
504
|
nodes.map { |n| NEU::MODS.normalize_paragraphs(n.text) }.reject(&:empty?).join("\n\n")
|
|
293
505
|
end
|
data/lib/neu/mods/selectors.rb
CHANGED
|
@@ -8,12 +8,28 @@ module NEU
|
|
|
8
8
|
# definition is the point: the node an editor changes is provably the node the
|
|
9
9
|
# projection reads. Mixed into Document; operates on `doc`.
|
|
10
10
|
module Selectors
|
|
11
|
-
# Top-level primary titleInfo, falling back to the first top-level titleInfo
|
|
12
|
-
# Scoped to direct children of <mods:mods> so a
|
|
13
|
-
# titleInfo (e.g. a series title) is never matched.
|
|
11
|
+
# Top-level primary titleInfo, falling back to the first top-level titleInfo
|
|
12
|
+
# that is NOT a variant. Scoped to direct children of <mods:mods> so a
|
|
13
|
+
# relatedItem's nested titleInfo (e.g. a series title) is never matched.
|
|
14
|
+
#
|
|
15
|
+
# MODS does not require usage="primary", so the fallback fires often, and
|
|
16
|
+
# an unfiltered one let document order decide the record's title. It also
|
|
17
|
+
# decided which node an edit form wrote to: MODSMerge overwrites the node
|
|
18
|
+
# this returns, so an alternative title reached here was destroyed on the
|
|
19
|
+
# next title edit, leaving the record with no primary title at all. nil is
|
|
20
|
+
# the right answer instead -- MODSMerge creates a proper primary titleInfo
|
|
21
|
+
# from nil, and each variant is projected under its own field.
|
|
14
22
|
def primary_title_info
|
|
15
23
|
doc.at_xpath("/mods:mods/mods:titleInfo[@usage='primary']", NAMESPACE) ||
|
|
16
|
-
doc.
|
|
24
|
+
doc.xpath("/mods:mods/mods:titleInfo", NAMESPACE).reject { |ti| variant_title?(ti) }.first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# MODS enumerates titleInfo/@type as exactly abbreviated, translated,
|
|
28
|
+
# alternative and uniform -- every one of them a variant. So the presence
|
|
29
|
+
# of any @type marks a variant, which also keeps an unrecognised or
|
|
30
|
+
# misspelled value out of the write path rather than guessing at it.
|
|
31
|
+
def variant_title?(node)
|
|
32
|
+
!NEU::MODS.canonical_ws(node["type"].to_s).empty?
|
|
17
33
|
end
|
|
18
34
|
|
|
19
35
|
# All top-level <abstract> elements (MODS permits several).
|
data/lib/neu-mods.rb
CHANGED
|
@@ -24,6 +24,12 @@ module NEU
|
|
|
24
24
|
# The MODS v3 namespace, as a Nokogiri xpath namespace hash.
|
|
25
25
|
NAMESPACE = { "mods" => "http://www.loc.gov/mods/v3" }.freeze
|
|
26
26
|
|
|
27
|
+
# The projected field set and its cardinality (see Projection::FIELDS),
|
|
28
|
+
# surfaced here so a consumer deriving its own schema from it -- Atlas's
|
|
29
|
+
# Metadata::MODS attr_json set -- reads the shared contract off the top-level
|
|
30
|
+
# module instead of reaching into a mixin.
|
|
31
|
+
FIELDS = Projection::FIELDS
|
|
32
|
+
|
|
27
33
|
module_function
|
|
28
34
|
|
|
29
35
|
# Whitespace no-op guard (see Canonicalize).
|