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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/README.md +1 -1
  4. data/lib/neu/mods/projection.rb +114 -16
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5416d755319f464e252ef4fa07d0372efb11e1379d76ad6155b78f42a61c221a
4
- data.tar.gz: ddfcf9eb792155c216bf9710ef6cfcc379f914791a782da30577158bb02a1da7
3
+ metadata.gz: aa2cba0771dfcd203ae956c9f5a84cb58c004df684620e378bf4034b978ee6b3
4
+ data.tar.gz: e3610d3984fecaebccdb88032ab07b4fab55b0e6932ee5f54218b654016ceca5
5
5
  SHA512:
6
- metadata.gz: d465b12175d8b15f7da7d7c355af0246294b3e7df0c276be775b6ccf41c14840f716fa7624ed86a54bff69da56625041cf7ca1f245e9ff6bb8f4035b44f70799
7
- data.tar.gz: ce373c9bb16d1a7e6142a4e0556e669ddb07d0bbe77db172f7954d4d54eb298b4a688c5cf3a60bd5237611670bbfb162c67d7d9c1bd5377a6579d0c78b9a9030
6
+ metadata.gz: 8bcbcf544da159f6e2377ae868808e73e78d254e9c597e2dcd676d140cbb6f59a8624ca698233bd0ab18866c9438ddae988a7fc0ac485ce7fa3557c34d199a76
7
+ data.tar.gz: de478ac9c203c1edf05fb604c9da85b05e95e133fb20231716a6ef7dd9310292dacf753f13781d253ecbd93a04aa290e2eff40dc32c5331de0e278c1b07c736f
data/.version CHANGED
@@ -1 +1 @@
1
- 0.9.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:, role: }] (authority-bearing / non-Creator — read-only)
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
@@ -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 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
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
- role: name_role(node),
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:, role: }. `name` reproduces the `mods` gem's
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` prefers the type="text" roleTerm,
183
- # falling back to the raw code (NOT MARC-relator-translated -- see README).
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 + role, like #names but filtered to the preserved set.
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
- def host_collections = related_item_titles("host")
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 name_role(node)
785
- node.xpath("mods:role", NAMESPACE).each do |role|
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.9.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-04 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri