neu-mods 0.9.0 → 0.11.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 +21 -5
- data/lib/neu/mods/projection.rb +297 -24
- data/lib/neu/mods/selectors.rb +9 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f29586facbe4e40077b0875aa896886a812b231058e97c12822875fd6203edef
|
|
4
|
+
data.tar.gz: 0fc6d10f7b3a50ff8659497a2de526f0d9b394751c4b4acd09c1676338dfdb87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5801c6878c83a3ae03eef2af7970b1d492a215705d7105002eadb12740f5af35f383e490dc4bbc02f59a723c56c99e9b9afb2399a35b0f785613abe1ff11a922
|
|
7
|
+
data.tar.gz: 92ffead82d106913bb14ad273096397bb319290b9ce04db46c068412ce3da3cd7dc4670526c50b63413a4f70b10a145bfeef7feecc854abca0480ea3aed7c112
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.11.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ require "neu-mods"
|
|
|
27
27
|
doc = NEU::MODS::Document.parse(xml_string)
|
|
28
28
|
|
|
29
29
|
# Projection (plain data)
|
|
30
|
-
doc.plain_title # => "What's New - How We Respond to Disaster
|
|
30
|
+
doc.plain_title # => "What's New, Episode 1 - How We Respond to Disaster"
|
|
31
31
|
doc.title_parts # => { non_sort:, subtitle:, title:, part_name:, part_number: }
|
|
32
32
|
# byte-faithful -- the edit forms pre-fill from these
|
|
33
33
|
doc.abstract # => normalized, paragraph-joined String
|
|
@@ -42,9 +42,23 @@ doc.date_created_parts
|
|
|
42
42
|
# shape it gave, so display cannot invent a month or a
|
|
43
43
|
# day. The points are read by @point, not by document
|
|
44
44
|
# order, and the end carries its OWN precision.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
45
|
+
# A keyDate="yes" node chooses the value, ahead of
|
|
46
|
+
# @point and document order; one date per type is the
|
|
47
|
+
# rule, so an unflagged repeat is discarded.
|
|
48
|
+
# Same for the other six originInfo dates --
|
|
49
|
+
# date_issued, copyright_date, date_captured,
|
|
50
|
+
# date_valid, date_other and date_modified. Each part is
|
|
51
|
+
# also a reader of its own, e.g.
|
|
47
52
|
# doc.date_created_qualifier.
|
|
53
|
+
doc.place_of_publication
|
|
54
|
+
# => ["Boston"] the type="text" placeTerm wins, so a
|
|
55
|
+
# marccountry code does not reach a places facet as a
|
|
56
|
+
# place name; a code-only place still projects its code
|
|
57
|
+
doc.host_collections
|
|
58
|
+
# => [{ title:, volume:, issue:, start_page:, end_page:,
|
|
59
|
+
# date:, text:, details: [...], extents: [...] }, ...]
|
|
60
|
+
# this work's position in its host. The entry survives on
|
|
61
|
+
# its part alone, so a host with no titleInfo is kept
|
|
48
62
|
doc.notes # => [{ type: "funding", value: "..." }, ...]
|
|
49
63
|
doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
|
|
50
64
|
# every relatedItem that is not a series or a host
|
|
@@ -69,7 +83,9 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
|
|
|
69
83
|
# the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
|
|
70
84
|
NEU::MODS.compose_title(non_sort: "", title: "What's New",
|
|
71
85
|
part_name: "How We Respond to Disaster", part_number: "Episode 1")
|
|
72
|
-
# => "What's New - How We Respond to Disaster
|
|
86
|
+
# => "What's New, Episode 1 - How We Respond to Disaster" (== doc.plain_title)
|
|
87
|
+
# The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
|
|
88
|
+
# cataloguing convention, and titleInfo is an unordered choice in the schema.
|
|
73
89
|
|
|
74
90
|
# Selectors (live nodes — for editing)
|
|
75
91
|
node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
|
|
@@ -80,7 +96,7 @@ doc.to_xml
|
|
|
80
96
|
# node selection (for replace-on-save), and structure-aware build.
|
|
81
97
|
doc.editable_personal_creators # => [{ given:, family: }] (plain, Creator role)
|
|
82
98
|
doc.editable_corporate_creators # => [{ name: }]
|
|
83
|
-
doc.preserved_names # => [{ name:,
|
|
99
|
+
doc.preserved_names # => [{ name:, roles: }] (authority-bearing / non-Creator — read-only)
|
|
84
100
|
doc.editable_creator_nodes("personal") # => live <name> nodes to replace
|
|
85
101
|
doc.build_personal_name(given: "Jenny", family: "Smith") # => a plain personal <name> node
|
|
86
102
|
doc.build_corporate_name(name: "Northeastern University") # => a plain corporate <name> node
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -52,10 +52,17 @@ module NEU
|
|
|
52
52
|
# (reaching for Nokogiri in a decorator is the smell this avoids). Keys:
|
|
53
53
|
# :non_sort :title :subtitle :part_name :part_number (nil or "" for absent).
|
|
54
54
|
# Returns "" when there is no title. Exposed as NEU::MODS.compose_title.
|
|
55
|
+
#
|
|
56
|
+
# The part NUMBER precedes the part NAME. titleInfo is an unordered choice
|
|
57
|
+
# in the schema, so no document order is available to follow, and a fixed
|
|
58
|
+
# order that put the name first read the number as a trailing qualifier on
|
|
59
|
+
# the section rather than as the section it numbers. Cataloguing practice
|
|
60
|
+
# is "Part 2. The Marshes". The separator travels with the part, not with
|
|
61
|
+
# the position, so the swap moves the comma with the number.
|
|
55
62
|
def self.compose_title(parts)
|
|
56
63
|
return "" if parts[:title].to_s.strip.empty?
|
|
57
64
|
|
|
58
|
-
optional = { ": " => parts[:subtitle], "
|
|
65
|
+
optional = { ": " => parts[:subtitle], ", " => parts[:part_number], " - " => parts[:part_name] }
|
|
59
66
|
suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
|
|
60
67
|
"#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
|
|
61
68
|
end
|
|
@@ -114,6 +121,26 @@ module NEU
|
|
|
114
121
|
keyword_subjects.flat_map { |s| s.xpath("mods:topic", NAMESPACE).map { |t| t.text.strip } }
|
|
115
122
|
end
|
|
116
123
|
|
|
124
|
+
# Neither child carries heading text: cartographics is a structured
|
|
125
|
+
# coordinate a reader reaches through #map_data, and geographicCode is a
|
|
126
|
+
# MARC code rather than a place name.
|
|
127
|
+
HEADING_OMITTED_CHILDREN = %w[cartographics geographicCode].freeze
|
|
128
|
+
|
|
129
|
+
# Every top-level <subject> as ONE heading, its parts in document order.
|
|
130
|
+
# A pre-coordinated heading like "Salt marshes--Massachusetts--20th
|
|
131
|
+
# century" is a single statement, and the per-axis fields below cannot say
|
|
132
|
+
# which parts belonged together: they pool every topic on the record into
|
|
133
|
+
# one list, so a fragment of a heading and a whole heading read alike.
|
|
134
|
+
#
|
|
135
|
+
# Kept in parts rather than joined. The separator is display policy, the
|
|
136
|
+
# same call #map_data makes for cartographics.
|
|
137
|
+
def subject_headings
|
|
138
|
+
doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
|
|
139
|
+
parts = subject_heading_parts(node)
|
|
140
|
+
{ parts: parts } unless parts.empty?
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
117
144
|
# Every <topic> under any top-level <subject> (the access-copy projection,
|
|
118
145
|
# equivalent to Atlas's extract_topical_subjects).
|
|
119
146
|
def topical_subjects = texts_at("/mods:mods/mods:subject/mods:topic")
|
|
@@ -130,6 +157,11 @@ module NEU
|
|
|
130
157
|
def personal_name_subjects = name_subjects("personal")
|
|
131
158
|
def corporate_name_subjects = name_subjects("corporate")
|
|
132
159
|
|
|
160
|
+
# The last unprojected member of a closed set: every other subject child
|
|
161
|
+
# already has a field, so leaving this one out made "what a subject can
|
|
162
|
+
# carry" arbitrary rather than complete.
|
|
163
|
+
def occupation_subjects = texts_at("/mods:mods/mods:subject/mods:occupation")
|
|
164
|
+
|
|
133
165
|
def genre_subjects = texts_at("/mods:mods/mods:subject/mods:genre")
|
|
134
166
|
|
|
135
167
|
# A MARC GAC code. Projected as the record wrote it: turning it into a
|
|
@@ -141,10 +173,7 @@ module NEU
|
|
|
141
173
|
# any other titleInfo, so it composes through the same port as the main
|
|
142
174
|
# title rather than taking titleInfo/title alone.
|
|
143
175
|
def title_subjects
|
|
144
|
-
doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map
|
|
145
|
-
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
146
|
-
clean(Projection.compose_title(parts))
|
|
147
|
-
end
|
|
176
|
+
doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map { |node| composed_title_of(node) }
|
|
148
177
|
end
|
|
149
178
|
|
|
150
179
|
# Kept structured for the reason #map_data is. Flattening country / state
|
|
@@ -172,17 +201,29 @@ module NEU
|
|
|
172
201
|
def name_entry(node)
|
|
173
202
|
{
|
|
174
203
|
name: name_display_value_w_date(node),
|
|
175
|
-
|
|
204
|
+
roles: name_roles(node),
|
|
176
205
|
affiliation: texts_under(node, "mods:affiliation")
|
|
177
206
|
}
|
|
178
207
|
end
|
|
179
208
|
|
|
180
|
-
# All top-level names as { name:,
|
|
209
|
+
# All top-level names as { name:, roles: }. `name` reproduces the `mods` gem's
|
|
181
210
|
# display_value_w_date (including its quirks -- faithfully, so existing Solr/
|
|
182
|
-
# display output is preserved). `role`
|
|
183
|
-
#
|
|
211
|
+
# display output is preserved). MODS repeats `role` on one name, and a
|
|
212
|
+
# cataloguer who records that a person both wrote and edited a work means
|
|
213
|
+
# both. Each term prefers the type="text" roleTerm, falling back to the raw
|
|
214
|
+
# code (NOT MARC-relator-translated -- see README).
|
|
215
|
+
#
|
|
216
|
+
# A name with no name text drops out. A mods:name carrying only a role
|
|
217
|
+
# projected { name: nil, roles: ["edt"] }, which a display renders as a
|
|
218
|
+
# labelled empty row and which every consumer had to guard against with
|
|
219
|
+
# its own compact_blank. #preserved_names deliberately keeps it: that list
|
|
220
|
+
# tells a curator what the XML holds, so an element they need to fix has
|
|
221
|
+
# to stay visible there.
|
|
184
222
|
def names
|
|
185
|
-
doc.xpath("/mods:mods/mods:name", NAMESPACE).
|
|
223
|
+
doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
|
|
224
|
+
entry = name_entry(node)
|
|
225
|
+
entry if entry[:name]
|
|
226
|
+
end
|
|
186
227
|
end
|
|
187
228
|
|
|
188
229
|
# Editable (depositor-managed) creators: the plain names (no authority
|
|
@@ -200,7 +241,7 @@ module NEU
|
|
|
200
241
|
|
|
201
242
|
# Names the editable form does NOT manage (authority-bearing or non-Creator)
|
|
202
243
|
# -- for read-only display ("these exist; edit via the XML tab"). Composed
|
|
203
|
-
# display string +
|
|
244
|
+
# display string + roles, like #names but filtered to the preserved set.
|
|
204
245
|
def preserved_names
|
|
205
246
|
doc.xpath("/mods:mods/mods:name", NAMESPACE)
|
|
206
247
|
.reject { |node| editable_creator_name?(node) }
|
|
@@ -239,7 +280,25 @@ module NEU
|
|
|
239
280
|
# back by nothing.
|
|
240
281
|
def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
|
|
241
282
|
def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
|
|
242
|
-
|
|
283
|
+
|
|
284
|
+
# Prefer the type="text" term per place, falling back to a coded one --
|
|
285
|
+
# the pattern #role_term_value and #languages already use. Unfiltered, a
|
|
286
|
+
# marccountry code reached the display and the Solr places facet as a
|
|
287
|
+
# place name, so "mau" sat in the list beside Boston. A code still
|
|
288
|
+
# projects when it is all the record gives, because dropping it would
|
|
289
|
+
# lose the only statement the record made.
|
|
290
|
+
#
|
|
291
|
+
# TODO: expand a marccountry code through a registry, as LanguageCodes
|
|
292
|
+
# does for eng -> English. That needs a vendored code list.
|
|
293
|
+
def place_of_publication
|
|
294
|
+
doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
|
|
295
|
+
text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
|
|
296
|
+
next text if text
|
|
297
|
+
|
|
298
|
+
clean(place.at_xpath("mods:placeTerm", NAMESPACE)&.text)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
243
302
|
def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
|
|
244
303
|
|
|
245
304
|
# Serials. The @authority a record puts on a frequency is not projected:
|
|
@@ -251,6 +310,13 @@ module NEU
|
|
|
251
310
|
def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
|
|
252
311
|
def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
|
|
253
312
|
|
|
313
|
+
# A note about the object rather than about the work -- "Scanned at 600
|
|
314
|
+
# dpi" belongs beside the extent, not beside a content note. Projected as
|
|
315
|
+
# plain strings like its physicalDescription siblings: #notes keeps @type
|
|
316
|
+
# because the type changes what a top-level note means, and nothing here
|
|
317
|
+
# turns on it.
|
|
318
|
+
def physical_description_notes = texts_at("/mods:mods/mods:physicalDescription/mods:note")
|
|
319
|
+
|
|
254
320
|
# An LCC or DDC call number. Note this is NOT the same concept as Atlas's
|
|
255
321
|
# classification_ssim, which carries a FileSet content-type vocabulary --
|
|
256
322
|
# the name collision is accidental and the consumer has to pick a free
|
|
@@ -301,12 +367,34 @@ module NEU
|
|
|
301
367
|
end
|
|
302
368
|
|
|
303
369
|
def related_series = related_item_titles("series")
|
|
304
|
-
|
|
370
|
+
|
|
371
|
+
# The host's title plus THIS work's position within it. The host's own
|
|
372
|
+
# name, originInfo and identifier stay out: they belong to the other
|
|
373
|
+
# record, and a transcribed copy goes stale the moment that record is
|
|
374
|
+
# edited. A part is the exception, because a volume, issue and page range
|
|
375
|
+
# describe this article and no other record holds that fact.
|
|
376
|
+
#
|
|
377
|
+
# An entry survives on its part alone. Requiring a title discarded the
|
|
378
|
+
# one piece of the block that was ours along with the metadata that never
|
|
379
|
+
# was, and how to render a titleless host is the consumer's call.
|
|
380
|
+
def host_collections
|
|
381
|
+
doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
|
|
382
|
+
entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
|
|
383
|
+
entry if entry.values.any?
|
|
384
|
+
end
|
|
385
|
+
end
|
|
305
386
|
|
|
306
387
|
# relatedItem @type values that already have a field of their own, so the
|
|
307
388
|
# catch-all below does not repeat them.
|
|
308
389
|
NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
|
|
309
390
|
|
|
391
|
+
# The part detail types volume and issue already have named keys on a
|
|
392
|
+
# host entry, so #host_details does not repeat them -- the same split
|
|
393
|
+
# NAMED_RELATED_ITEM_TYPES makes for relatedItem. A caption is kept
|
|
394
|
+
# because it is the label a cataloguer wrote for the number ("chap."
|
|
395
|
+
# before "3"), which no consumer can reconstruct from an open @type.
|
|
396
|
+
NAMED_HOST_DETAIL_TYPES = %w[volume issue].freeze
|
|
397
|
+
|
|
310
398
|
# Every other relatedItem, keeping its @type. MODS also defines
|
|
311
399
|
# constituent, otherFormat, original, preceding, succeeding, isReferencedBy
|
|
312
400
|
# and reviewOf, and a record carrying any of them projected nothing at all.
|
|
@@ -387,9 +475,20 @@ module NEU
|
|
|
387
475
|
# The parts are projected as separate flat fields rather than one nested
|
|
388
476
|
# value, because the value half has three consumers that need a real date
|
|
389
477
|
# object -- a Solr sort key, a citation year and an OAI date.
|
|
478
|
+
# MODS puts seven date elements under originInfo and this reads all of
|
|
479
|
+
# them. dateCaptured is when the object was digitised and dateModified is
|
|
480
|
+
# when the resource changed -- preservation and cataloguing provenance,
|
|
481
|
+
# which a consumer may keep off a page but cannot recover from anywhere
|
|
482
|
+
# else. dateValid is the period the content holds for, and dateOther is
|
|
483
|
+
# where a date fitting no other element lands, which is where a quantity
|
|
484
|
+
# of migrated v1 date data goes.
|
|
390
485
|
def date_created_parts = date_parts("dateCreated")
|
|
391
486
|
def date_issued_parts = date_parts("dateIssued")
|
|
392
487
|
def copyright_date_parts = date_parts("copyrightDate")
|
|
488
|
+
def date_captured_parts = date_parts("dateCaptured")
|
|
489
|
+
def date_valid_parts = date_parts("dateValid")
|
|
490
|
+
def date_other_parts = date_parts("dateOther")
|
|
491
|
+
def date_modified_parts = date_parts("dateModified")
|
|
393
492
|
|
|
394
493
|
def date_created = date_created_parts[:value]
|
|
395
494
|
def date_created_precision = date_created_parts[:precision]
|
|
@@ -412,6 +511,34 @@ module NEU
|
|
|
412
511
|
def copyright_date_qualifier = copyright_date_parts[:qualifier]
|
|
413
512
|
def copyright_date_key_date = copyright_date_parts[:key_date]
|
|
414
513
|
|
|
514
|
+
def date_captured = date_captured_parts[:value]
|
|
515
|
+
def date_captured_precision = date_captured_parts[:precision]
|
|
516
|
+
def date_captured_end = date_captured_parts[:end_value]
|
|
517
|
+
def date_captured_end_precision = date_captured_parts[:end_precision]
|
|
518
|
+
def date_captured_qualifier = date_captured_parts[:qualifier]
|
|
519
|
+
def date_captured_key_date = date_captured_parts[:key_date]
|
|
520
|
+
|
|
521
|
+
def date_valid = date_valid_parts[:value]
|
|
522
|
+
def date_valid_precision = date_valid_parts[:precision]
|
|
523
|
+
def date_valid_end = date_valid_parts[:end_value]
|
|
524
|
+
def date_valid_end_precision = date_valid_parts[:end_precision]
|
|
525
|
+
def date_valid_qualifier = date_valid_parts[:qualifier]
|
|
526
|
+
def date_valid_key_date = date_valid_parts[:key_date]
|
|
527
|
+
|
|
528
|
+
def date_other = date_other_parts[:value]
|
|
529
|
+
def date_other_precision = date_other_parts[:precision]
|
|
530
|
+
def date_other_end = date_other_parts[:end_value]
|
|
531
|
+
def date_other_end_precision = date_other_parts[:end_precision]
|
|
532
|
+
def date_other_qualifier = date_other_parts[:qualifier]
|
|
533
|
+
def date_other_key_date = date_other_parts[:key_date]
|
|
534
|
+
|
|
535
|
+
def date_modified = date_modified_parts[:value]
|
|
536
|
+
def date_modified_precision = date_modified_parts[:precision]
|
|
537
|
+
def date_modified_end = date_modified_parts[:end_value]
|
|
538
|
+
def date_modified_end_precision = date_modified_parts[:end_precision]
|
|
539
|
+
def date_modified_qualifier = date_modified_parts[:qualifier]
|
|
540
|
+
def date_modified_key_date = date_modified_parts[:key_date]
|
|
541
|
+
|
|
415
542
|
# The [value, precision] pair the precision work introduced. Retained
|
|
416
543
|
# because it is the documented entry point for a caller that wants both
|
|
417
544
|
# halves and nothing else.
|
|
@@ -464,8 +591,9 @@ module NEU
|
|
|
464
591
|
edition: :many,
|
|
465
592
|
issuance: :many,
|
|
466
593
|
frequency: :many,
|
|
467
|
-
# Six rows per originInfo date
|
|
468
|
-
#
|
|
594
|
+
# Six rows per originInfo date, for each of the seven MODS defines.
|
|
595
|
+
# Flat rather than one nested value, because the value half has
|
|
596
|
+
# consumers that need a real date object.
|
|
469
597
|
date_created: :one,
|
|
470
598
|
date_created_precision: :one,
|
|
471
599
|
date_created_end: :one,
|
|
@@ -484,6 +612,30 @@ module NEU
|
|
|
484
612
|
copyright_date_end_precision: :one,
|
|
485
613
|
copyright_date_qualifier: :one,
|
|
486
614
|
copyright_date_key_date: :one,
|
|
615
|
+
date_captured: :one,
|
|
616
|
+
date_captured_precision: :one,
|
|
617
|
+
date_captured_end: :one,
|
|
618
|
+
date_captured_end_precision: :one,
|
|
619
|
+
date_captured_qualifier: :one,
|
|
620
|
+
date_captured_key_date: :one,
|
|
621
|
+
date_valid: :one,
|
|
622
|
+
date_valid_precision: :one,
|
|
623
|
+
date_valid_end: :one,
|
|
624
|
+
date_valid_end_precision: :one,
|
|
625
|
+
date_valid_qualifier: :one,
|
|
626
|
+
date_valid_key_date: :one,
|
|
627
|
+
date_other: :one,
|
|
628
|
+
date_other_precision: :one,
|
|
629
|
+
date_other_end: :one,
|
|
630
|
+
date_other_end_precision: :one,
|
|
631
|
+
date_other_qualifier: :one,
|
|
632
|
+
date_other_key_date: :one,
|
|
633
|
+
date_modified: :one,
|
|
634
|
+
date_modified_precision: :one,
|
|
635
|
+
date_modified_end: :one,
|
|
636
|
+
date_modified_end_precision: :one,
|
|
637
|
+
date_modified_qualifier: :one,
|
|
638
|
+
date_modified_key_date: :one,
|
|
487
639
|
|
|
488
640
|
# physical description
|
|
489
641
|
resource_type: :many,
|
|
@@ -492,15 +644,18 @@ module NEU
|
|
|
492
644
|
extent: :many,
|
|
493
645
|
digital_origin: :many,
|
|
494
646
|
reformatting_quality: :many,
|
|
647
|
+
physical_description_notes: :many,
|
|
495
648
|
notes: :many,
|
|
496
649
|
table_of_contents: :many,
|
|
497
650
|
|
|
498
651
|
# subjects
|
|
652
|
+
subject_headings: :many,
|
|
499
653
|
topical_subjects: :many,
|
|
500
654
|
geographic_subjects: :many,
|
|
501
655
|
temporal_subjects: :many,
|
|
502
656
|
personal_name_subjects: :many,
|
|
503
657
|
corporate_name_subjects: :many,
|
|
658
|
+
occupation_subjects: :many,
|
|
504
659
|
genre_subjects: :many,
|
|
505
660
|
geographic_code_subjects: :many,
|
|
506
661
|
title_subjects: :many,
|
|
@@ -576,14 +731,33 @@ module NEU
|
|
|
576
731
|
# One originInfo date element, read by its attributes rather than by
|
|
577
732
|
# position. A record is free to write point="end" first, and taking the
|
|
578
733
|
# first node would then invert the range.
|
|
734
|
+
#
|
|
735
|
+
# ONE DATE PER TYPE is the rule, and a repeated, non-ranged, unflagged
|
|
736
|
+
# date of the same type is discarded. A range is one date with two ends,
|
|
737
|
+
# which @point already models, and every consumer of the value -- a sort
|
|
738
|
+
# key, a citation year, an OAI date -- holds exactly one. Note the
|
|
739
|
+
# contrast with a repeated publisher, which survives because
|
|
740
|
+
# publication_information is :many; dates differ because the value is
|
|
741
|
+
# singular downstream, not because repetition went unnoticed.
|
|
579
742
|
def date_parts(element)
|
|
580
743
|
nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
|
|
581
744
|
return EMPTY_DATE if nodes.empty?
|
|
582
745
|
|
|
583
|
-
start = nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
584
|
-
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
585
746
|
finish = nodes.find { |n| attr_value(n, "point") == "end" }
|
|
586
|
-
date_entry(
|
|
747
|
+
date_entry(start_date_node(nodes), finish, nodes)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# The node the value comes from: the flagged one, then the declared
|
|
751
|
+
# start, then document order.
|
|
752
|
+
#
|
|
753
|
+
# keyDate leads because the flag is the record nominating its own
|
|
754
|
+
# principal date. Projecting the flag while reading the value from a
|
|
755
|
+
# different node made the two contradict each other -- a consumer was told
|
|
756
|
+
# the record chose this date and then handed the one before it.
|
|
757
|
+
def start_date_node(nodes)
|
|
758
|
+
nodes.find { |n| attr_value(n, "keyDate") == "yes" && attr_value(n, "point") != "end" } ||
|
|
759
|
+
nodes.find { |n| attr_value(n, "point") == "start" } ||
|
|
760
|
+
nodes.find { |n| attr_value(n, "point") != "end" }
|
|
587
761
|
end
|
|
588
762
|
|
|
589
763
|
# The end point carries its OWN precision. "1935-06" to "1940" is legal,
|
|
@@ -639,6 +813,13 @@ module NEU
|
|
|
639
813
|
# A variant title composed the way the access copy wants it: normalised
|
|
640
814
|
# first, like #access_title_parts, so a curly quote or an invisible format
|
|
641
815
|
# mark cannot reach Solr or a display template through this route either.
|
|
816
|
+
# One titleInfo composed the way the access copy wants it, shared by the
|
|
817
|
+
# subject-title axis and the assembled heading so the two cannot drift.
|
|
818
|
+
def composed_title_of(node)
|
|
819
|
+
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
820
|
+
clean(Projection.compose_title(parts))
|
|
821
|
+
end
|
|
822
|
+
|
|
642
823
|
def variant_titles(type)
|
|
643
824
|
doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
|
|
644
825
|
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
@@ -651,10 +832,100 @@ module NEU
|
|
|
651
832
|
.filter_map { |node| name_display_value_w_date(node) }
|
|
652
833
|
end
|
|
653
834
|
|
|
835
|
+
def subject_heading_parts(node)
|
|
836
|
+
node.xpath("mods:*", NAMESPACE).flat_map { |child| subject_heading_part(child) }.compact
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def subject_heading_part(child)
|
|
840
|
+
return [] if HEADING_OMITTED_CHILDREN.include?(child.name)
|
|
841
|
+
|
|
842
|
+
case child.name
|
|
843
|
+
when "name" then [name_display_value_w_date(child)]
|
|
844
|
+
when "titleInfo" then [composed_title_of(child)]
|
|
845
|
+
# Each level is its own part, so a hierarchical place reads as the steps
|
|
846
|
+
# of the heading rather than as one run-together string.
|
|
847
|
+
when "hierarchicalGeographic" then child.xpath("mods:*", NAMESPACE).map { |level| clean(level.text) }
|
|
848
|
+
else split_heading_text(clean(child.text))
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# A cataloguer who typed a whole heading into one element as "A--B--C"
|
|
853
|
+
# made the same statement as one who structured it into siblings, so both
|
|
854
|
+
# arrive here as the same parts.
|
|
855
|
+
def split_heading_text(text)
|
|
856
|
+
return [] if text.nil?
|
|
857
|
+
return [text] unless text.include?("--")
|
|
858
|
+
|
|
859
|
+
text.split("--").filter_map { |part| clean(part) }
|
|
860
|
+
end
|
|
861
|
+
|
|
654
862
|
def related_item_titles(type)
|
|
655
863
|
texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
|
|
656
864
|
end
|
|
657
865
|
|
|
866
|
+
# Kept in parts rather than composed into "24(3), pp. 210-218". The
|
|
867
|
+
# punctuation of a citation is display policy, the same call #map_data
|
|
868
|
+
# makes for cartographics. MODS leaves @unit optional, so a page extent
|
|
869
|
+
# without one is read rather than dropped.
|
|
870
|
+
#
|
|
871
|
+
# Volume, issue and the page range keep named keys because they are the
|
|
872
|
+
# citation and a consumer asks for them by name. Everything else the
|
|
873
|
+
# schema allows under part arrives structured, because detail/@type and
|
|
874
|
+
# extent/@unit are open strings -- a fixed key per type cannot cover a
|
|
875
|
+
# vocabulary the schema does not close. #date is the article's year within
|
|
876
|
+
# the host, which after the title is the most-cited element of a journal
|
|
877
|
+
# citation and was reaching no consumer at all.
|
|
878
|
+
def host_part(node)
|
|
879
|
+
part = node.at_xpath("mods:part", NAMESPACE)
|
|
880
|
+
return {} if part.nil?
|
|
881
|
+
|
|
882
|
+
pages = "mods:extent[@unit='page' or not(@unit)]"
|
|
883
|
+
{
|
|
884
|
+
volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
|
|
885
|
+
issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
|
|
886
|
+
start_page: child_text(part, "#{pages}/mods:start"),
|
|
887
|
+
end_page: child_text(part, "#{pages}/mods:end"),
|
|
888
|
+
date: child_text(part, "mods:date"),
|
|
889
|
+
text: child_text(part, "mods:text"),
|
|
890
|
+
details: host_details(part),
|
|
891
|
+
extents: host_extents(part)
|
|
892
|
+
}.reject { |_, value| value.nil? || value == [] }
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def host_details(part)
|
|
896
|
+
part.xpath("mods:detail", NAMESPACE).filter_map do |node|
|
|
897
|
+
type = clean(node["type"])
|
|
898
|
+
next if NAMED_HOST_DETAIL_TYPES.include?(type)
|
|
899
|
+
|
|
900
|
+
entry = {
|
|
901
|
+
type: type,
|
|
902
|
+
number: child_text(node, "mods:number"),
|
|
903
|
+
caption: child_text(node, "mods:caption"),
|
|
904
|
+
title: child_text(node, "mods:title")
|
|
905
|
+
}
|
|
906
|
+
entry if entry.except(:type).values.any?
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# Every extent EXCEPT the page range, which start_page and end_page hold.
|
|
911
|
+
# A unit other than page -- the minutes of a recording, the columns of a
|
|
912
|
+
# newspaper -- means nothing without its unit, so the unit travels with
|
|
913
|
+
# the numbers rather than being flattened away.
|
|
914
|
+
def host_extents(part)
|
|
915
|
+
part.xpath("mods:extent", NAMESPACE).filter_map do |node|
|
|
916
|
+
unit = clean(node["unit"])
|
|
917
|
+
next if unit.nil? || unit == "page"
|
|
918
|
+
|
|
919
|
+
{
|
|
920
|
+
unit: unit,
|
|
921
|
+
start: child_text(node, "mods:start"),
|
|
922
|
+
end: child_text(node, "mods:end"),
|
|
923
|
+
total: child_text(node, "mods:total"),
|
|
924
|
+
list: child_text(node, "mods:list")
|
|
925
|
+
}
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
658
929
|
def text_at(xpath)
|
|
659
930
|
node = doc.at_xpath(xpath, NAMESPACE)
|
|
660
931
|
node ? NEU::MODS.canonical_ws(node.text) : ""
|
|
@@ -781,14 +1052,16 @@ module NEU
|
|
|
781
1052
|
.map(&:text).join(" ")
|
|
782
1053
|
end
|
|
783
1054
|
|
|
784
|
-
def
|
|
785
|
-
node.xpath("mods:role", NAMESPACE).
|
|
786
|
-
val = role_term_value(role)
|
|
787
|
-
return val if val
|
|
788
|
-
end
|
|
789
|
-
nil
|
|
1055
|
+
def name_roles(node)
|
|
1056
|
+
node.xpath("mods:role", NAMESPACE).filter_map { |role| role_term_value(role) }
|
|
790
1057
|
end
|
|
791
1058
|
|
|
1059
|
+
# The first declared role, which is what decides whether the simple edit
|
|
1060
|
+
# form owns a name. Deliberately not #name_roles.include?("Creator"):
|
|
1061
|
+
# widening it would hand the form a name whose other roles it cannot
|
|
1062
|
+
# represent, and saving would drop them from the preservation XML.
|
|
1063
|
+
def name_role(node) = name_roles(node).first
|
|
1064
|
+
|
|
792
1065
|
# Prefer the type="text" roleTerm; fall back to the raw type="code" term
|
|
793
1066
|
# (NOT MARC-relator-translated -- see README). nil if neither is present.
|
|
794
1067
|
def role_term_value(role)
|
data/lib/neu/mods/selectors.rb
CHANGED
|
@@ -19,6 +19,15 @@ module NEU
|
|
|
19
19
|
# next title edit, leaving the record with no primary title at all. nil is
|
|
20
20
|
# the right answer instead -- MODSMerge creates a proper primary titleInfo
|
|
21
21
|
# from nil, and each variant is projected under its own field.
|
|
22
|
+
#
|
|
23
|
+
# Where a record carries two UNTYPED titleInfo and marks neither, .first
|
|
24
|
+
# decides and the second reaches no field. That is the schema's answer
|
|
25
|
+
# rather than a shortfall: @usage is fixed="primary" and exists precisely
|
|
26
|
+
# to nominate the principal title, @type is a closed enumeration of the
|
|
27
|
+
# four variants, and MODS 3.5 gives a legitimate second untyped title an
|
|
28
|
+
# @altRepGroup (one title in two scripts) or an @otherType. An unmarked
|
|
29
|
+
# duplicate carries none of those, so MODS gives it no meaning to
|
|
30
|
+
# preserve, and it stays in the preservation XML with no projected field.
|
|
22
31
|
def primary_title_info
|
|
23
32
|
doc.at_xpath("/mods:mods/mods:titleInfo[@usage='primary']", NAMESPACE) ||
|
|
24
33
|
doc.xpath("/mods:mods/mods:titleInfo", NAMESPACE).reject { |ti| variant_title?(ti) }.first
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: neu-mods
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Cliff
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|