neu-mods 0.9.0 → 0.10.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 +1 -1
- data/lib/neu/mods/projection.rb +114 -16
- 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: aa2cba0771dfcd203ae956c9f5a84cb58c004df684620e378bf4034b978ee6b3
|
|
4
|
+
data.tar.gz: e3610d3984fecaebccdb88032ab07b4fab55b0e6932ee5f54218b654016ceca5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8bcbcf544da159f6e2377ae868808e73e78d254e9c597e2dcd676d140cbb6f59a8624ca698233bd0ab18866c9438ddae988a7fc0ac485ce7fa3557c34d199a76
|
|
7
|
+
data.tar.gz: de478ac9c203c1edf05fb604c9da85b05e95e133fb20231716a6ef7dd9310292dacf753f13781d253ecbd93a04aa290e2eff40dc32c5331de0e278c1b07c736f
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.10.0
|
data/README.md
CHANGED
|
@@ -80,7 +80,7 @@ doc.to_xml
|
|
|
80
80
|
# node selection (for replace-on-save), and structure-aware build.
|
|
81
81
|
doc.editable_personal_creators # => [{ given:, family: }] (plain, Creator role)
|
|
82
82
|
doc.editable_corporate_creators # => [{ name: }]
|
|
83
|
-
doc.preserved_names # => [{ name:,
|
|
83
|
+
doc.preserved_names # => [{ name:, roles: }] (authority-bearing / non-Creator — read-only)
|
|
84
84
|
doc.editable_creator_nodes("personal") # => live <name> nodes to replace
|
|
85
85
|
doc.build_personal_name(given: "Jenny", family: "Smith") # => a plain personal <name> node
|
|
86
86
|
doc.build_corporate_name(name: "Northeastern University") # => a plain corporate <name> node
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -114,6 +114,26 @@ module NEU
|
|
|
114
114
|
keyword_subjects.flat_map { |s| s.xpath("mods:topic", NAMESPACE).map { |t| t.text.strip } }
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Neither child carries heading text: cartographics is a structured
|
|
118
|
+
# coordinate a reader reaches through #map_data, and geographicCode is a
|
|
119
|
+
# MARC code rather than a place name.
|
|
120
|
+
HEADING_OMITTED_CHILDREN = %w[cartographics geographicCode].freeze
|
|
121
|
+
|
|
122
|
+
# Every top-level <subject> as ONE heading, its parts in document order.
|
|
123
|
+
# A pre-coordinated heading like "Salt marshes--Massachusetts--20th
|
|
124
|
+
# century" is a single statement, and the per-axis fields below cannot say
|
|
125
|
+
# which parts belonged together: they pool every topic on the record into
|
|
126
|
+
# one list, so a fragment of a heading and a whole heading read alike.
|
|
127
|
+
#
|
|
128
|
+
# Kept in parts rather than joined. The separator is display policy, the
|
|
129
|
+
# same call #map_data makes for cartographics.
|
|
130
|
+
def subject_headings
|
|
131
|
+
doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
|
|
132
|
+
parts = subject_heading_parts(node)
|
|
133
|
+
{ parts: parts } unless parts.empty?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
117
137
|
# Every <topic> under any top-level <subject> (the access-copy projection,
|
|
118
138
|
# equivalent to Atlas's extract_topical_subjects).
|
|
119
139
|
def topical_subjects = texts_at("/mods:mods/mods:subject/mods:topic")
|
|
@@ -130,6 +150,11 @@ module NEU
|
|
|
130
150
|
def personal_name_subjects = name_subjects("personal")
|
|
131
151
|
def corporate_name_subjects = name_subjects("corporate")
|
|
132
152
|
|
|
153
|
+
# The last unprojected member of a closed set: every other subject child
|
|
154
|
+
# already has a field, so leaving this one out made "what a subject can
|
|
155
|
+
# carry" arbitrary rather than complete.
|
|
156
|
+
def occupation_subjects = texts_at("/mods:mods/mods:subject/mods:occupation")
|
|
157
|
+
|
|
133
158
|
def genre_subjects = texts_at("/mods:mods/mods:subject/mods:genre")
|
|
134
159
|
|
|
135
160
|
# A MARC GAC code. Projected as the record wrote it: turning it into a
|
|
@@ -141,10 +166,7 @@ module NEU
|
|
|
141
166
|
# any other titleInfo, so it composes through the same port as the main
|
|
142
167
|
# title rather than taking titleInfo/title alone.
|
|
143
168
|
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
|
|
169
|
+
doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map { |node| composed_title_of(node) }
|
|
148
170
|
end
|
|
149
171
|
|
|
150
172
|
# Kept structured for the reason #map_data is. Flattening country / state
|
|
@@ -172,15 +194,17 @@ module NEU
|
|
|
172
194
|
def name_entry(node)
|
|
173
195
|
{
|
|
174
196
|
name: name_display_value_w_date(node),
|
|
175
|
-
|
|
197
|
+
roles: name_roles(node),
|
|
176
198
|
affiliation: texts_under(node, "mods:affiliation")
|
|
177
199
|
}
|
|
178
200
|
end
|
|
179
201
|
|
|
180
|
-
# All top-level names as { name:,
|
|
202
|
+
# All top-level names as { name:, roles: }. `name` reproduces the `mods` gem's
|
|
181
203
|
# display_value_w_date (including its quirks -- faithfully, so existing Solr/
|
|
182
|
-
# display output is preserved). `role`
|
|
183
|
-
#
|
|
204
|
+
# display output is preserved). MODS repeats `role` on one name, and a
|
|
205
|
+
# cataloguer who records that a person both wrote and edited a work means
|
|
206
|
+
# both. Each term prefers the type="text" roleTerm, falling back to the raw
|
|
207
|
+
# code (NOT MARC-relator-translated -- see README).
|
|
184
208
|
def names
|
|
185
209
|
doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
|
|
186
210
|
end
|
|
@@ -200,7 +224,7 @@ module NEU
|
|
|
200
224
|
|
|
201
225
|
# Names the editable form does NOT manage (authority-bearing or non-Creator)
|
|
202
226
|
# -- for read-only display ("these exist; edit via the XML tab"). Composed
|
|
203
|
-
# display string +
|
|
227
|
+
# display string + roles, like #names but filtered to the preserved set.
|
|
204
228
|
def preserved_names
|
|
205
229
|
doc.xpath("/mods:mods/mods:name", NAMESPACE)
|
|
206
230
|
.reject { |node| editable_creator_name?(node) }
|
|
@@ -251,6 +275,13 @@ module NEU
|
|
|
251
275
|
def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
|
|
252
276
|
def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
|
|
253
277
|
|
|
278
|
+
# A note about the object rather than about the work -- "Scanned at 600
|
|
279
|
+
# dpi" belongs beside the extent, not beside a content note. Projected as
|
|
280
|
+
# plain strings like its physicalDescription siblings: #notes keeps @type
|
|
281
|
+
# because the type changes what a top-level note means, and nothing here
|
|
282
|
+
# turns on it.
|
|
283
|
+
def physical_description_notes = texts_at("/mods:mods/mods:physicalDescription/mods:note")
|
|
284
|
+
|
|
254
285
|
# An LCC or DDC call number. Note this is NOT the same concept as Atlas's
|
|
255
286
|
# classification_ssim, which carries a FileSet content-type vocabulary --
|
|
256
287
|
# the name collision is accidental and the consumer has to pick a free
|
|
@@ -301,7 +332,18 @@ module NEU
|
|
|
301
332
|
end
|
|
302
333
|
|
|
303
334
|
def related_series = related_item_titles("series")
|
|
304
|
-
|
|
335
|
+
|
|
336
|
+
# The host's title plus THIS work's position within it. The host's own
|
|
337
|
+
# name, originInfo and identifier stay out: they belong to the other
|
|
338
|
+
# record, and a transcribed copy goes stale the moment that record is
|
|
339
|
+
# edited. A part is the exception, because a volume, issue and page range
|
|
340
|
+
# describe this article and no other record holds that fact.
|
|
341
|
+
def host_collections
|
|
342
|
+
doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
|
|
343
|
+
title = child_text(node, "mods:titleInfo/mods:title")
|
|
344
|
+
{ title: title, **host_part(node) } if title
|
|
345
|
+
end
|
|
346
|
+
end
|
|
305
347
|
|
|
306
348
|
# relatedItem @type values that already have a field of their own, so the
|
|
307
349
|
# catch-all below does not repeat them.
|
|
@@ -492,15 +534,18 @@ module NEU
|
|
|
492
534
|
extent: :many,
|
|
493
535
|
digital_origin: :many,
|
|
494
536
|
reformatting_quality: :many,
|
|
537
|
+
physical_description_notes: :many,
|
|
495
538
|
notes: :many,
|
|
496
539
|
table_of_contents: :many,
|
|
497
540
|
|
|
498
541
|
# subjects
|
|
542
|
+
subject_headings: :many,
|
|
499
543
|
topical_subjects: :many,
|
|
500
544
|
geographic_subjects: :many,
|
|
501
545
|
temporal_subjects: :many,
|
|
502
546
|
personal_name_subjects: :many,
|
|
503
547
|
corporate_name_subjects: :many,
|
|
548
|
+
occupation_subjects: :many,
|
|
504
549
|
genre_subjects: :many,
|
|
505
550
|
geographic_code_subjects: :many,
|
|
506
551
|
title_subjects: :many,
|
|
@@ -639,6 +684,13 @@ module NEU
|
|
|
639
684
|
# A variant title composed the way the access copy wants it: normalised
|
|
640
685
|
# first, like #access_title_parts, so a curly quote or an invisible format
|
|
641
686
|
# mark cannot reach Solr or a display template through this route either.
|
|
687
|
+
# One titleInfo composed the way the access copy wants it, shared by the
|
|
688
|
+
# subject-title axis and the assembled heading so the two cannot drift.
|
|
689
|
+
def composed_title_of(node)
|
|
690
|
+
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
691
|
+
clean(Projection.compose_title(parts))
|
|
692
|
+
end
|
|
693
|
+
|
|
642
694
|
def variant_titles(type)
|
|
643
695
|
doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
|
|
644
696
|
parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
|
|
@@ -651,10 +703,54 @@ module NEU
|
|
|
651
703
|
.filter_map { |node| name_display_value_w_date(node) }
|
|
652
704
|
end
|
|
653
705
|
|
|
706
|
+
def subject_heading_parts(node)
|
|
707
|
+
node.xpath("mods:*", NAMESPACE).flat_map { |child| subject_heading_part(child) }.compact
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def subject_heading_part(child)
|
|
711
|
+
return [] if HEADING_OMITTED_CHILDREN.include?(child.name)
|
|
712
|
+
|
|
713
|
+
case child.name
|
|
714
|
+
when "name" then [name_display_value_w_date(child)]
|
|
715
|
+
when "titleInfo" then [composed_title_of(child)]
|
|
716
|
+
# Each level is its own part, so a hierarchical place reads as the steps
|
|
717
|
+
# of the heading rather than as one run-together string.
|
|
718
|
+
when "hierarchicalGeographic" then child.xpath("mods:*", NAMESPACE).map { |level| clean(level.text) }
|
|
719
|
+
else split_heading_text(clean(child.text))
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# A cataloguer who typed a whole heading into one element as "A--B--C"
|
|
724
|
+
# made the same statement as one who structured it into siblings, so both
|
|
725
|
+
# arrive here as the same parts.
|
|
726
|
+
def split_heading_text(text)
|
|
727
|
+
return [] if text.nil?
|
|
728
|
+
return [text] unless text.include?("--")
|
|
729
|
+
|
|
730
|
+
text.split("--").filter_map { |part| clean(part) }
|
|
731
|
+
end
|
|
732
|
+
|
|
654
733
|
def related_item_titles(type)
|
|
655
734
|
texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
|
|
656
735
|
end
|
|
657
736
|
|
|
737
|
+
# Kept in parts rather than composed into "24(3), pp. 210-218". The
|
|
738
|
+
# punctuation of a citation is display policy, the same call #map_data
|
|
739
|
+
# makes for cartographics. MODS leaves @unit optional, so a page extent
|
|
740
|
+
# without one is read rather than dropped.
|
|
741
|
+
def host_part(node)
|
|
742
|
+
part = node.at_xpath("mods:part", NAMESPACE)
|
|
743
|
+
return {} if part.nil?
|
|
744
|
+
|
|
745
|
+
pages = "mods:extent[@unit='page' or not(@unit)]"
|
|
746
|
+
{
|
|
747
|
+
volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
|
|
748
|
+
issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
|
|
749
|
+
start_page: child_text(part, "#{pages}/mods:start"),
|
|
750
|
+
end_page: child_text(part, "#{pages}/mods:end")
|
|
751
|
+
}.compact
|
|
752
|
+
end
|
|
753
|
+
|
|
658
754
|
def text_at(xpath)
|
|
659
755
|
node = doc.at_xpath(xpath, NAMESPACE)
|
|
660
756
|
node ? NEU::MODS.canonical_ws(node.text) : ""
|
|
@@ -781,14 +877,16 @@ module NEU
|
|
|
781
877
|
.map(&:text).join(" ")
|
|
782
878
|
end
|
|
783
879
|
|
|
784
|
-
def
|
|
785
|
-
node.xpath("mods:role", NAMESPACE).
|
|
786
|
-
val = role_term_value(role)
|
|
787
|
-
return val if val
|
|
788
|
-
end
|
|
789
|
-
nil
|
|
880
|
+
def name_roles(node)
|
|
881
|
+
node.xpath("mods:role", NAMESPACE).filter_map { |role| role_term_value(role) }
|
|
790
882
|
end
|
|
791
883
|
|
|
884
|
+
# The first declared role, which is what decides whether the simple edit
|
|
885
|
+
# form owns a name. Deliberately not #name_roles.include?("Creator"):
|
|
886
|
+
# widening it would hand the form a name whose other roles it cannot
|
|
887
|
+
# represent, and saving would drop them from the preservation XML.
|
|
888
|
+
def name_role(node) = name_roles(node).first
|
|
889
|
+
|
|
792
890
|
# Prefer the type="text" roleTerm; fall back to the raw type="code" term
|
|
793
891
|
# (NOT MARC-relator-translated -- see README). nil if neither is present.
|
|
794
892
|
def role_term_value(role)
|
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.10.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-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|