neu-mods 0.8.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 +8 -1
  4. data/lib/neu/mods/projection.rb +209 -12
  5. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c0467914a611aa9d46065edcd2c0a18cd0decd642e769b70ecfaecc18e7ef1e
4
- data.tar.gz: f97ba11b67874fefcb98f9de2af47eda370bb61f679e9b10994cf465b31876f2
3
+ metadata.gz: aa2cba0771dfcd203ae956c9f5a84cb58c004df684620e378bf4034b978ee6b3
4
+ data.tar.gz: e3610d3984fecaebccdb88032ab07b4fab55b0e6932ee5f54218b654016ceca5
5
5
  SHA512:
6
- metadata.gz: 94029ec3b5d70d835677c4ad49cfc02bf7a501c782a91adb38c3a5cf6310ee727ffcef7736975526b699c796ff574ab3be5645ecaa7d576494b9b28c88c67ac1
7
- data.tar.gz: 3b5c3b2f36af51e6af7424ff5ecdbf365a6c56358bf3aec3cff370b2473ad334cfaed9c20eae4aa2025ab583eb9d0ec9d7439d94fb7d8687e80dd301a64c7c38
6
+ metadata.gz: 8bcbcf544da159f6e2377ae868808e73e78d254e9c597e2dcd676d140cbb6f59a8624ca698233bd0ab18866c9438ddae988a7fc0ac485ce7fa3557c34d199a76
7
+ data.tar.gz: de478ac9c203c1edf05fb604c9da85b05e95e133fb20231716a6ef7dd9310292dacf753f13781d253ecbd93a04aa290e2eff40dc32c5331de0e278c1b07c736f
data/.version CHANGED
@@ -1 +1 @@
1
- 0.8.0
1
+ 0.10.0
data/README.md CHANGED
@@ -50,6 +50,13 @@ doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
50
50
  # every relatedItem that is not a series or a host
51
51
  doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
52
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
53
60
  doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
54
61
 
55
62
  # The field registry -- the single declaration of what this gem projects.
@@ -73,7 +80,7 @@ doc.to_xml
73
80
  # node selection (for replace-on-save), and structure-aware build.
74
81
  doc.editable_personal_creators # => [{ given:, family: }] (plain, Creator role)
75
82
  doc.editable_corporate_creators # => [{ name: }]
76
- doc.preserved_names # => [{ name:, role: }] (authority-bearing / non-Creator — read-only)
83
+ doc.preserved_names # => [{ name:, roles: }] (authority-bearing / non-Creator — read-only)
77
84
  doc.editable_creator_nodes("personal") # => live <name> nodes to replace
78
85
  doc.build_personal_name(given: "Jenny", family: "Smith") # => a plain personal <name> node
79
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,39 @@ 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
+
158
+ def genre_subjects = texts_at("/mods:mods/mods:subject/mods:genre")
159
+
160
+ # A MARC GAC code. Projected as the record wrote it: turning it into a
161
+ # place name needs a lookup table, which is the same call the gem already
162
+ # made for MARC relators -- the label vocabulary belongs to the consumer.
163
+ def geographic_code_subjects = texts_at("/mods:mods/mods:subject/mods:geographicCode")
164
+
165
+ # A subject that is a work has a nonSort, a subTitle and part numbers like
166
+ # any other titleInfo, so it composes through the same port as the main
167
+ # title rather than taking titleInfo/title alone.
168
+ def title_subjects
169
+ doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map { |node| composed_title_of(node) }
170
+ end
171
+
172
+ # Kept structured for the reason #map_data is. Flattening country / state
173
+ # / city into "United States -- New York (State) -- Parksville" would make
174
+ # a consumer that wants the city alone unpick a sentence.
175
+ #
176
+ # This is the axis bdr_43888.mods.xml uses INSTEAD of subject/geographic,
177
+ # so that record projected no place at all -- a live ingest path, not a
178
+ # hypothetical.
179
+ def hierarchical_geographic_subjects
180
+ doc.xpath("/mods:mods/mods:subject/mods:hierarchicalGeographic", NAMESPACE).filter_map do |node|
181
+ entry = HIERARCHICAL_GEOGRAPHIC_LEVELS.to_h { |level| [level, child_text(node, "mods:#{camelize(level)}")] }
182
+ entry if entry.values.any?
183
+ end
184
+ end
185
+
133
186
  # --- Names ---------------------------------------------------------------
134
187
 
135
188
  # One name as the access copy wants it. `affiliation` is how a reader
@@ -141,15 +194,17 @@ module NEU
141
194
  def name_entry(node)
142
195
  {
143
196
  name: name_display_value_w_date(node),
144
- role: name_role(node),
197
+ roles: name_roles(node),
145
198
  affiliation: texts_under(node, "mods:affiliation")
146
199
  }
147
200
  end
148
201
 
149
- # 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
150
203
  # display_value_w_date (including its quirks -- faithfully, so existing Solr/
151
- # display output is preserved). `role` prefers the type="text" roleTerm,
152
- # 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).
153
208
  def names
154
209
  doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
155
210
  end
@@ -169,7 +224,7 @@ module NEU
169
224
 
170
225
  # Names the editable form does NOT manage (authority-bearing or non-Creator)
171
226
  # -- for read-only display ("these exist; edit via the XML tab"). Composed
172
- # display string + role, like #names but filtered to the preserved set.
227
+ # display string + roles, like #names but filtered to the preserved set.
173
228
  def preserved_names
174
229
  doc.xpath("/mods:mods/mods:name", NAMESPACE)
175
230
  .reject { |node| editable_creator_name?(node) }
@@ -208,6 +263,30 @@ module NEU
208
263
  # back by nothing.
209
264
  def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
210
265
  def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
266
+ def place_of_publication = texts_at("/mods:mods/mods:originInfo/mods:place/mods:placeTerm")
267
+ def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
268
+
269
+ # Serials. The @authority a record puts on a frequency is not projected:
270
+ # authority handling is a question the gem defers everywhere else -- for
271
+ # genre, subject and name -- and answering it for one field would be
272
+ # inconsistent.
273
+ def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
274
+
275
+ def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
276
+ def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
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
+
285
+ # An LCC or DDC call number. Note this is NOT the same concept as Atlas's
286
+ # classification_ssim, which carries a FileSet content-type vocabulary --
287
+ # the name collision is accidental and the consumer has to pick a free
288
+ # Solr field.
289
+ def classification = texts_at("/mods:mods/mods:classification")
211
290
 
212
291
  # Every top-level note, keeping its @type. The type carries meaning -- a
213
292
  # "statement of responsibility" is not a "funding" note -- so flattening
@@ -253,7 +332,18 @@ module NEU
253
332
  end
254
333
 
255
334
  def related_series = related_item_titles("series")
256
- 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
257
347
 
258
348
  # relatedItem @type values that already have a field of their own, so the
259
349
  # catch-all below does not repeat them.
@@ -297,6 +387,26 @@ module NEU
297
387
  # or a full date. Matching the shape explicitly, rather than widening
298
388
  # DateTime.parse, is what lets the declared precision fall out of the parse
299
389
  # instead of being guessed after it.
390
+ # The eleven children the XSD allows under hierarchicalGeographic, in the
391
+ # order MODS lists them -- broadest first, which is also the order a
392
+ # consumer composing a place string wants to reverse.
393
+ HIERARCHICAL_GEOGRAPHIC_LEVELS = %i[
394
+ continent country province region state territory county city
395
+ city_section island area
396
+ ].freeze
397
+
398
+ # recordInfo children. Read as a single value: the schema repeats the
399
+ # element, but a record with two cataloguing provenances is not a case
400
+ # anyone has, and an array here buys nothing.
401
+ RECORD_INFO_PARTS = {
402
+ content_source: "mods:recordContentSource",
403
+ origin: "mods:recordOrigin",
404
+ description_standard: "mods:descriptionStandard",
405
+ creation_date: "mods:recordCreationDate",
406
+ change_date: "mods:recordChangeDate",
407
+ language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
408
+ }.freeze
409
+
300
410
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
301
411
 
302
412
  # What #date_parts returns when the element is absent entirely, so an
@@ -351,6 +461,19 @@ module NEU
351
461
  def date_issued_with_precision = [date_issued, date_issued_precision]
352
462
  def copyright_date_with_precision = [copyright_date, copyright_date_precision]
353
463
 
464
+ # Who catalogued this record, to what standard, and when. It describes the
465
+ # CATALOGUING rather than the resource, which is why it is one value and
466
+ # why a consumer is unlikely to want it beside Publisher -- but dropping a
467
+ # preservation repository's provenance statement on read is wrong on its
468
+ # face, so it is projected and the display question is the consumer's.
469
+ def record_info
470
+ node = doc.at_xpath("/mods:mods/mods:recordInfo", NAMESPACE)
471
+ return nil unless node
472
+
473
+ entry = RECORD_INFO_PARTS.transform_values { |xpath| child_text(node, xpath) }
474
+ entry if entry.values.any?
475
+ end
476
+
354
477
  # --- Full projection -----------------------------------------------------
355
478
 
356
479
  # The field registry: the single declaration of what this gem projects.
@@ -379,7 +502,10 @@ module NEU
379
502
 
380
503
  # origin
381
504
  publication_information: :many,
505
+ place_of_publication: :many,
382
506
  edition: :many,
507
+ issuance: :many,
508
+ frequency: :many,
383
509
  # Six rows per originInfo date. Flat rather than one nested value,
384
510
  # because the value half has consumers that need a real date object.
385
511
  date_created: :one,
@@ -407,14 +533,23 @@ module NEU
407
533
  format: :many,
408
534
  extent: :many,
409
535
  digital_origin: :many,
536
+ reformatting_quality: :many,
537
+ physical_description_notes: :many,
410
538
  notes: :many,
539
+ table_of_contents: :many,
411
540
 
412
541
  # subjects
542
+ subject_headings: :many,
413
543
  topical_subjects: :many,
414
544
  geographic_subjects: :many,
415
545
  temporal_subjects: :many,
416
546
  personal_name_subjects: :many,
417
547
  corporate_name_subjects: :many,
548
+ occupation_subjects: :many,
549
+ genre_subjects: :many,
550
+ geographic_code_subjects: :many,
551
+ title_subjects: :many,
552
+ hierarchical_geographic_subjects: :many,
418
553
  map_data: :many,
419
554
 
420
555
  # related items
@@ -424,7 +559,9 @@ module NEU
424
559
 
425
560
  # identifiers and location
426
561
  identifiers: :many,
562
+ classification: :many,
427
563
  permanent_url: :one,
564
+ record_info: :one,
428
565
  location: :many,
429
566
 
430
567
  # access
@@ -547,6 +684,13 @@ module NEU
547
684
  # A variant title composed the way the access copy wants it: normalised
548
685
  # first, like #access_title_parts, so a curly quote or an invisible format
549
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
+
550
694
  def variant_titles(type)
551
695
  doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
552
696
  parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
@@ -559,10 +703,54 @@ module NEU
559
703
  .filter_map { |node| name_display_value_w_date(node) }
560
704
  end
561
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
+
562
733
  def related_item_titles(type)
563
734
  texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
564
735
  end
565
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
+
566
754
  def text_at(xpath)
567
755
  node = doc.at_xpath(xpath, NAMESPACE)
568
756
  node ? NEU::MODS.canonical_ws(node.text) : ""
@@ -575,6 +763,13 @@ module NEU
575
763
  # [nil], and every consumer of that array has to guard for it.
576
764
  # #texts_at scoped to a node rather than the document, for a repeatable
577
765
  # child of one element.
766
+ # :city_section -> "citySection". The level names are snake_case in the
767
+ # projection and camelCase in the schema.
768
+ def camelize(level)
769
+ head, *rest = level.to_s.split("_")
770
+ [head, *rest.map(&:capitalize)].join
771
+ end
772
+
578
773
  def texts_under(node, xpath)
579
774
  node.xpath(xpath, NAMESPACE).filter_map { |child| clean(child.text) }
580
775
  end
@@ -682,14 +877,16 @@ module NEU
682
877
  .map(&:text).join(" ")
683
878
  end
684
879
 
685
- def name_role(node)
686
- node.xpath("mods:role", NAMESPACE).each do |role|
687
- val = role_term_value(role)
688
- return val if val
689
- end
690
- nil
880
+ def name_roles(node)
881
+ node.xpath("mods:role", NAMESPACE).filter_map { |role| role_term_value(role) }
691
882
  end
692
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
+
693
890
  # Prefer the type="text" roleTerm; fall back to the raw type="code" term
694
891
  # (NOT MARC-relator-translated -- see README). nil if neither is present.
695
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.8.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