neu-mods 0.10.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa2cba0771dfcd203ae956c9f5a84cb58c004df684620e378bf4034b978ee6b3
4
- data.tar.gz: e3610d3984fecaebccdb88032ab07b4fab55b0e6932ee5f54218b654016ceca5
3
+ metadata.gz: 66a8dc19c0e19ccd43f170c117414ddb1df82c3cec889a83c27c6416f778173b
4
+ data.tar.gz: 445176fc7abc0748d47eddae0ee99137321c743b53a9de3432bb5e45a418ea68
5
5
  SHA512:
6
- metadata.gz: 8bcbcf544da159f6e2377ae868808e73e78d254e9c597e2dcd676d140cbb6f59a8624ca698233bd0ab18866c9438ddae988a7fc0ac485ce7fa3557c34d199a76
7
- data.tar.gz: de478ac9c203c1edf05fb604c9da85b05e95e133fb20231716a6ef7dd9310292dacf753f13781d253ecbd93a04aa290e2eff40dc32c5331de0e278c1b07c736f
6
+ metadata.gz: 1f453251ae9fd5c98737370fcd26e69d85cc8cf615d4e16bf1fd2b0e0f6cb80a23a9b6819fb1f364044161725a9f828c8df3776b760b5b59f24ac0589ed956b3
7
+ data.tar.gz: 7a04ed96b243039b19457a7df660966f9cd73ec1de92585403fa02a2c0711a80455029d1107cb4d7f3efac9d39b3acf0069bd1ce00031856fff2ce0b350558e6
data/.version CHANGED
@@ -1 +1 @@
1
- 0.10.0
1
+ 0.12.0
data/README.md CHANGED
@@ -27,24 +27,53 @@ 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, Episode 1"
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
34
- doc.languages # => ["English"] a code-only <languageTerm>eng</> included
34
+ doc.languages # => [{ term: "English", object_part: nil, script: nil }, ...]
35
+ # a code-only <languageTerm>eng</> is read through the
36
+ # ISO 639 registry. @objectPart rides along because
37
+ # objectPart="subtitles" says the SUBTITLES are Spanish,
38
+ # not the resource
35
39
  doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
36
40
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
37
41
  doc.date_created_parts
38
42
  # => { value:, precision:, end_value:, end_precision:,
39
- # qualifier:, key_date: } everything the record
43
+ # qualifier:, key_date:, text: } everything the record
40
44
  # declared about one date. w3cdtf YYYY, YYYY-MM and
41
45
  # YYYY-MM-DD all parse, and the precision says which
42
46
  # shape it gave, so display cannot invent a month or a
43
47
  # day. The points are read by @point, not by document
44
48
  # order, and the end carries its OWN precision.
45
- # Same for date_issued_parts and copyright_date_parts;
46
- # each part is also a reader of its own, e.g.
49
+ # A keyDate="yes" node chooses the value, ahead of
50
+ # @point and document order; one date per type is the
51
+ # rule, so an unflagged repeat is discarded.
52
+ # A value that is not a w3cdtf date projects NO date
53
+ # and keeps its literal in :text -- "19uu", "ca. 1920"
54
+ # and "undated" are statements a cataloguer made, and
55
+ # guessing a date for them is worse than either losing
56
+ # them or showing them as written.
57
+ # Same for the other six originInfo dates --
58
+ # date_issued, copyright_date, date_captured,
59
+ # date_valid, date_other and date_modified. Each part is
60
+ # also a reader of its own, e.g.
47
61
  # doc.date_created_qualifier.
62
+ doc.place_of_publication
63
+ # => ["Boston"] the type="text" placeTerm wins, and a
64
+ # bare marccountry code drops rather than reaching a
65
+ # places facet as a place name. A bare code under any
66
+ # other authority still projects
67
+ doc.host_collections
68
+ # => [{ title:, volume:, issue:, start_page:, end_page:,
69
+ # date:, text:, details: [...], extents: [...] }, ...]
70
+ # this work's position in its host. The entry survives on
71
+ # its part alone, so a host with no titleInfo is kept
72
+ doc.identifiers # => [{ type: "isbn", value: "...", invalid: false }, ...]
73
+ # @invalid means cancelled or superseded, so it travels
74
+ doc.table_of_contents
75
+ # => ["Ch 1\nCh 2"] line breaks kept: in a contents list
76
+ # the break is the structure, not stray formatting
48
77
  doc.notes # => [{ type: "funding", value: "..." }, ...]
49
78
  doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
50
79
  # every relatedItem that is not a series or a host
@@ -69,7 +98,9 @@ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
69
98
  # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
70
99
  NEU::MODS.compose_title(non_sort: "", title: "What's New",
71
100
  part_name: "How We Respond to Disaster", part_number: "Episode 1")
72
- # => "What's New - How We Respond to Disaster, Episode 1" (== doc.plain_title)
101
+ # => "What's New, Episode 1 - How We Respond to Disaster" (== doc.plain_title)
102
+ # The part NUMBER precedes the part NAME: "Part 2. The Marshes" is the
103
+ # cataloguing convention, and titleInfo is an unordered choice in the schema.
73
104
 
74
105
  # Selectors (live nodes — for editing)
75
106
  node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
@@ -18,6 +18,15 @@ module NEU
18
18
  str.to_s.tr(NBSP, " ").gsub(/\s+/, " ").strip
19
19
  end
20
20
 
21
+ # canonical_ws per line, keeping the line breaks. For a field where a
22
+ # newline is structure rather than formatting -- tableOfContents, where
23
+ # the break separates one entry from the next. Blank lines drop, so a
24
+ # double-spaced list does not project empty entries.
25
+ def canonical_lines(str)
26
+ str.to_s.tr(NBSP, " ").split("\n").map { |line| line.gsub(/\s+/, " ").strip }
27
+ .reject(&:empty?).join("\n")
28
+ end
29
+
21
30
  # Treat values differing only by insignificant whitespace (NBSP vs space,
22
31
  # collapsible runs, leading/trailing) as equal.
23
32
  def whitespace_equivalent?(current, incoming)
@@ -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], " - " => parts[:part_name], ", " => parts[:part_number] }
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
@@ -106,6 +113,12 @@ module NEU
106
113
  def use_and_reproduction = access_conditions_of_type("use and reproduction")
107
114
  def restriction_on_access = access_conditions_of_type("restriction on access")
108
115
 
116
+ # An open-string @type reduced to its letters and digits, so casing, word
117
+ # separators and camelCasing cannot decide whether a field matches.
118
+ def self.fold_type(str)
119
+ NEU::MODS.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
120
+ end
121
+
109
122
  # --- Subjects ------------------------------------------------------------
110
123
 
111
124
  # The editable free-text keyword set (Cerberus simple form): topics under the
@@ -205,8 +218,18 @@ module NEU
205
218
  # cataloguer who records that a person both wrote and edited a work means
206
219
  # both. Each term prefers the type="text" roleTerm, falling back to the raw
207
220
  # code (NOT MARC-relator-translated -- see README).
221
+ #
222
+ # A name with no name text drops out. A mods:name carrying only a role
223
+ # projected { name: nil, roles: ["edt"] }, which a display renders as a
224
+ # labelled empty row and which every consumer had to guard against with
225
+ # its own compact_blank. #preserved_names deliberately keeps it: that list
226
+ # tells a curator what the XML holds, so an element they need to fix has
227
+ # to stay visible there.
208
228
  def names
209
- doc.xpath("/mods:mods/mods:name", NAMESPACE).map { |node| name_entry(node) }
229
+ doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
230
+ entry = name_entry(node)
231
+ entry if entry[:name]
232
+ end
210
233
  end
211
234
 
212
235
  # Editable (depositor-managed) creators: the plain names (no authority
@@ -233,17 +256,24 @@ module NEU
233
256
 
234
257
  # --- Scalars / simple arrays --------------------------------------------
235
258
 
236
- # Prefer the type="text" term, and translate a code-only one through the
237
- # ISO 639 registry. A record saying `eng` projects "English", so the
238
- # display and the Solr language facet read the same value rather than the
239
- # facet showing codes. An unrecognised code survives as itself.
259
+ # { term:, object_part:, script: } per language element. Prefer the
260
+ # type="text" term, and translate a code-only one through the ISO 639
261
+ # registry. A record saying `eng` projects "English", so the display and
262
+ # the Solr language facet read the same value rather than the facet
263
+ # showing codes. An unrecognised code survives as itself.
264
+ #
265
+ # An entry rather than a bare string because @objectPart changes what the
266
+ # record is claiming. `<language objectPart="subtitles">spa` says the
267
+ # subtitles are Spanish, and projected flat it said the resource was --
268
+ # which is the case a captioned video hits every time. The script rides
269
+ # along for the same reason a name's role does: a consumer cannot
270
+ # recover it from the term.
240
271
  def languages
241
272
  doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
242
- text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
243
- next clean(text.text) if text
273
+ term = language_term(lang)
274
+ next unless term
244
275
 
245
- code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
246
- code && LanguageCodes.term(code)
276
+ { term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang) }
247
277
  end
248
278
  end
249
279
 
@@ -263,7 +293,34 @@ module NEU
263
293
  # back by nothing.
264
294
  def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
265
295
  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")
296
+
297
+ # Prefer the type="text" term per place, falling back to a coded one --
298
+ # the pattern #role_term_value and #languages already use.
299
+ #
300
+ # A bare marccountry code is the exception, and it drops. "mau" is not a
301
+ # place name, and unfiltered it reached the display and the Solr places
302
+ # facet as one, sitting in the list beside Boston. That is the call
303
+ # #geographic_code_subjects already makes for a MARC GAC code. A code
304
+ # under any other authority survives, because there the code may be the
305
+ # only statement the record made and nothing here can say it is not text.
306
+ #
307
+ # TODO: expand a marccountry code through a registry, as LanguageCodes
308
+ # does for eng -> English. That needs a vendored code list, and would let
309
+ # this project "Massachusetts" instead of dropping the element.
310
+ MARC_COUNTRY_AUTHORITY = "marccountry"
311
+
312
+ def place_of_publication
313
+ doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
314
+ text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
315
+ next text if text
316
+
317
+ code = place.at_xpath("mods:placeTerm", NAMESPACE)
318
+ next if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY
319
+
320
+ clean(code&.text)
321
+ end
322
+ end
323
+
267
324
  def issuance = texts_at("/mods:mods/mods:originInfo/mods:issuance")
268
325
 
269
326
  # Serials. The @authority a record puts on a frequency is not projected:
@@ -272,7 +329,17 @@ module NEU
272
329
  # inconsistent.
273
330
  def frequency = texts_at("/mods:mods/mods:originInfo/mods:frequency")
274
331
 
275
- def table_of_contents = texts_at("/mods:mods/mods:tableOfContents")
332
+ # Read with its line breaks intact. A legacy contents list separates its
333
+ # entries by newline, and the whitespace collapse every other field wants
334
+ # ran the entries together into one line -- there the break IS the
335
+ # structure, not stray formatting. A "--"-separated list is unaffected.
336
+ def table_of_contents
337
+ doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
338
+ lines = NEU::MODS.canonical_lines(node.text)
339
+ lines unless lines.empty?
340
+ end
341
+ end
342
+
276
343
  def reformatting_quality = texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality")
277
344
 
278
345
  # A note about the object rather than about the work -- "Scanned at 600
@@ -338,10 +405,14 @@ module NEU
338
405
  # record, and a transcribed copy goes stale the moment that record is
339
406
  # edited. A part is the exception, because a volume, issue and page range
340
407
  # describe this article and no other record holds that fact.
408
+ #
409
+ # An entry survives on its part alone. Requiring a title discarded the
410
+ # one piece of the block that was ours along with the metadata that never
411
+ # was, and how to render a titleless host is the consumer's call.
341
412
  def host_collections
342
413
  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
414
+ entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
415
+ entry if entry.values.any?
345
416
  end
346
417
  end
347
418
 
@@ -349,6 +420,13 @@ module NEU
349
420
  # catch-all below does not repeat them.
350
421
  NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
351
422
 
423
+ # The part detail types volume and issue already have named keys on a
424
+ # host entry, so #host_details does not repeat them -- the same split
425
+ # NAMED_RELATED_ITEM_TYPES makes for relatedItem. A caption is kept
426
+ # because it is the label a cataloguer wrote for the number ("chap."
427
+ # before "3"), which no consumer can reconstruct from an open @type.
428
+ NAMED_HOST_DETAIL_TYPES = %w[volume issue].freeze
429
+
352
430
  # Every other relatedItem, keeping its @type. MODS also defines
353
431
  # constituent, otherFormat, original, preceding, succeeding, isReferencedBy
354
432
  # and reviewOf, and a record carrying any of them projected nothing at all.
@@ -371,10 +449,13 @@ module NEU
371
449
  # is a DOI, and a display cannot decide to linkify it. The same argument
372
450
  # #notes already makes for its @type, and #permanent_url already proves
373
451
  # the attribute is load-bearing by special-casing @type='hdl'.
452
+ # @invalid rides along because in MODS it means the identifier is
453
+ # cancelled, superseded or simply wrong. Projected flat, a dead ISBN read
454
+ # exactly like a live one and invited a reader to use it.
374
455
  def identifiers
375
456
  doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
376
457
  value = clean(node.text)
377
- { type: clean(node["type"]), value: value } if value
458
+ { type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes" } if value
378
459
  end
379
460
  end
380
461
 
@@ -383,10 +464,6 @@ module NEU
383
464
  node && clean(node.text)
384
465
  end
385
466
 
386
- # The three w3cdtf date shapes a dateCreated may stop at: year, year-month,
387
- # or a full date. Matching the shape explicitly, rather than widening
388
- # DateTime.parse, is what lets the declared precision fall out of the parse
389
- # instead of being guessed after it.
390
467
  # The eleven children the XSD allows under hierarchicalGeographic, in the
391
468
  # order MODS lists them -- broadest first, which is also the order a
392
469
  # consumer composing a place string wants to reverse.
@@ -407,15 +484,27 @@ module NEU
407
484
  language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
408
485
  }.freeze
409
486
 
410
- W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
487
+ # The w3cdtf date shapes a date element may stop at: year, year-month, a
488
+ # full date, or a full date with a time. Matching the shape explicitly,
489
+ # rather than widening DateTime.parse, is what lets the declared precision
490
+ # fall out of the parse instead of being guessed after it.
491
+ #
492
+ # A value outside these shapes is NOT a date, and #parse_w3cdtf says so
493
+ # rather than guessing. Ruby's DateTime.parse fills the components it
494
+ # cannot find from the CURRENT date, so "19uu" -- standard MARC 008 fill,
495
+ # which the v1 corpus carries at scale -- asserted today's date at "day"
496
+ # precision, and the assertion changed daily. What the record actually
497
+ # wrote survives in the matching *_text field instead.
498
+ W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
411
499
 
412
500
  # What #date_parts returns when the element is absent entirely, so an
413
501
  # absent date is distinguishable from one present and unparseable.
414
- EMPTY_DATE = { value: nil, precision: nil, end_value: nil,
415
- end_precision: nil, qualifier: nil, key_date: nil }.freeze
502
+ EMPTY_DATE = { value: nil, precision: nil, end_value: nil, end_precision: nil,
503
+ qualifier: nil, key_date: nil, text: nil }.freeze
416
504
 
417
505
  # Everything a record declared about one originInfo date, as
418
- # { value:, precision:, end_value:, end_precision:, qualifier:, key_date: }.
506
+ # { value:, precision:, end_value:, end_precision:, qualifier:, key_date:,
507
+ # text: }.
419
508
  #
420
509
  # A date is not a scalar. Precision established that: a year-only date
421
510
  # parses to January 1st, and no consumer downstream can tell that month
@@ -428,10 +517,23 @@ module NEU
428
517
  #
429
518
  # The parts are projected as separate flat fields rather than one nested
430
519
  # value, because the value half has three consumers that need a real date
431
- # object -- a Solr sort key, a citation year and an OAI date.
520
+ # object -- a Solr sort key, a citation year and an OAI date. Those three
521
+ # are also why the literal gets its own field rather than sharing the
522
+ # value: a sort key cannot hold "ca. 1920", and a display can.
523
+ # MODS puts seven date elements under originInfo and this reads all of
524
+ # them. dateCaptured is when the object was digitised and dateModified is
525
+ # when the resource changed -- preservation and cataloguing provenance,
526
+ # which a consumer may keep off a page but cannot recover from anywhere
527
+ # else. dateValid is the period the content holds for, and dateOther is
528
+ # where a date fitting no other element lands, which is where a quantity
529
+ # of migrated v1 date data goes.
432
530
  def date_created_parts = date_parts("dateCreated")
433
531
  def date_issued_parts = date_parts("dateIssued")
434
532
  def copyright_date_parts = date_parts("copyrightDate")
533
+ def date_captured_parts = date_parts("dateCaptured")
534
+ def date_valid_parts = date_parts("dateValid")
535
+ def date_other_parts = date_parts("dateOther")
536
+ def date_modified_parts = date_parts("dateModified")
435
537
 
436
538
  def date_created = date_created_parts[:value]
437
539
  def date_created_precision = date_created_parts[:precision]
@@ -439,6 +541,7 @@ module NEU
439
541
  def date_created_end_precision = date_created_parts[:end_precision]
440
542
  def date_created_qualifier = date_created_parts[:qualifier]
441
543
  def date_created_key_date = date_created_parts[:key_date]
544
+ def date_created_text = date_created_parts[:text]
442
545
 
443
546
  def date_issued = date_issued_parts[:value]
444
547
  def date_issued_precision = date_issued_parts[:precision]
@@ -446,6 +549,7 @@ module NEU
446
549
  def date_issued_end_precision = date_issued_parts[:end_precision]
447
550
  def date_issued_qualifier = date_issued_parts[:qualifier]
448
551
  def date_issued_key_date = date_issued_parts[:key_date]
552
+ def date_issued_text = date_issued_parts[:text]
449
553
 
450
554
  def copyright_date = copyright_date_parts[:value]
451
555
  def copyright_date_precision = copyright_date_parts[:precision]
@@ -453,6 +557,39 @@ module NEU
453
557
  def copyright_date_end_precision = copyright_date_parts[:end_precision]
454
558
  def copyright_date_qualifier = copyright_date_parts[:qualifier]
455
559
  def copyright_date_key_date = copyright_date_parts[:key_date]
560
+ def copyright_date_text = copyright_date_parts[:text]
561
+
562
+ def date_captured = date_captured_parts[:value]
563
+ def date_captured_precision = date_captured_parts[:precision]
564
+ def date_captured_end = date_captured_parts[:end_value]
565
+ def date_captured_end_precision = date_captured_parts[:end_precision]
566
+ def date_captured_qualifier = date_captured_parts[:qualifier]
567
+ def date_captured_key_date = date_captured_parts[:key_date]
568
+ def date_captured_text = date_captured_parts[:text]
569
+
570
+ def date_valid = date_valid_parts[:value]
571
+ def date_valid_precision = date_valid_parts[:precision]
572
+ def date_valid_end = date_valid_parts[:end_value]
573
+ def date_valid_end_precision = date_valid_parts[:end_precision]
574
+ def date_valid_qualifier = date_valid_parts[:qualifier]
575
+ def date_valid_key_date = date_valid_parts[:key_date]
576
+ def date_valid_text = date_valid_parts[:text]
577
+
578
+ def date_other = date_other_parts[:value]
579
+ def date_other_precision = date_other_parts[:precision]
580
+ def date_other_end = date_other_parts[:end_value]
581
+ def date_other_end_precision = date_other_parts[:end_precision]
582
+ def date_other_qualifier = date_other_parts[:qualifier]
583
+ def date_other_key_date = date_other_parts[:key_date]
584
+ def date_other_text = date_other_parts[:text]
585
+
586
+ def date_modified = date_modified_parts[:value]
587
+ def date_modified_precision = date_modified_parts[:precision]
588
+ def date_modified_end = date_modified_parts[:end_value]
589
+ def date_modified_end_precision = date_modified_parts[:end_precision]
590
+ def date_modified_qualifier = date_modified_parts[:qualifier]
591
+ def date_modified_key_date = date_modified_parts[:key_date]
592
+ def date_modified_text = date_modified_parts[:text]
456
593
 
457
594
  # The [value, precision] pair the precision work introduced. Retained
458
595
  # because it is the documented entry point for a caller that wants both
@@ -506,26 +643,58 @@ module NEU
506
643
  edition: :many,
507
644
  issuance: :many,
508
645
  frequency: :many,
509
- # Six rows per originInfo date. Flat rather than one nested value,
510
- # because the value half has consumers that need a real date object.
646
+ # Seven rows per originInfo date, for each of the seven MODS defines.
647
+ # Flat rather than one nested value, because the value half has
648
+ # consumers that need a real date object.
511
649
  date_created: :one,
512
650
  date_created_precision: :one,
513
651
  date_created_end: :one,
514
652
  date_created_end_precision: :one,
515
653
  date_created_qualifier: :one,
516
654
  date_created_key_date: :one,
655
+ date_created_text: :one,
517
656
  date_issued: :one,
518
657
  date_issued_precision: :one,
519
658
  date_issued_end: :one,
520
659
  date_issued_end_precision: :one,
521
660
  date_issued_qualifier: :one,
522
661
  date_issued_key_date: :one,
662
+ date_issued_text: :one,
523
663
  copyright_date: :one,
524
664
  copyright_date_precision: :one,
525
665
  copyright_date_end: :one,
526
666
  copyright_date_end_precision: :one,
527
667
  copyright_date_qualifier: :one,
528
668
  copyright_date_key_date: :one,
669
+ copyright_date_text: :one,
670
+ date_captured: :one,
671
+ date_captured_precision: :one,
672
+ date_captured_end: :one,
673
+ date_captured_end_precision: :one,
674
+ date_captured_qualifier: :one,
675
+ date_captured_key_date: :one,
676
+ date_captured_text: :one,
677
+ date_valid: :one,
678
+ date_valid_precision: :one,
679
+ date_valid_end: :one,
680
+ date_valid_end_precision: :one,
681
+ date_valid_qualifier: :one,
682
+ date_valid_key_date: :one,
683
+ date_valid_text: :one,
684
+ date_other: :one,
685
+ date_other_precision: :one,
686
+ date_other_end: :one,
687
+ date_other_end_precision: :one,
688
+ date_other_qualifier: :one,
689
+ date_other_key_date: :one,
690
+ date_other_text: :one,
691
+ date_modified: :one,
692
+ date_modified_precision: :one,
693
+ date_modified_end: :one,
694
+ date_modified_end_precision: :one,
695
+ date_modified_qualifier: :one,
696
+ date_modified_key_date: :one,
697
+ date_modified_text: :one,
529
698
 
530
699
  # physical description
531
700
  resource_type: :many,
@@ -598,37 +767,62 @@ module NEU
598
767
 
599
768
  # --- helpers -------------------------------------------------------------
600
769
 
601
- # A shape-matched but impossible date (2026-13, 2026-02-30) reaches DateTime
602
- # and raises; it falls to the "" sentinel like any other unparseable value.
603
- # Anything outside the three shapes keeps the old permissive parse, so a
604
- # timestamp still projects as a full date.
770
+ # [DateTime, precision] for a w3cdtf date, or nil for a string that is not
771
+ # one. A shape-matched but impossible date (2026-13, 2026-02-30) reaches
772
+ # DateTime, raises, and is nil like any other unreadable value; the caller
773
+ # keeps its literal text.
774
+ #
775
+ # A full timestamp goes through DateTime.parse rather than being rebuilt,
776
+ # so the time of day a dateModified declares survives. Its precision is
777
+ # "day" because that is the finest granularity a consumer renders.
605
778
  def parse_w3cdtf(str)
606
779
  m = W3CDTF_DATE.match(str)
607
- return [DateTime.parse(str), "day"] unless m
608
-
609
- precision = if m[3]
610
- "day"
611
- elsif m[2]
612
- "month"
613
- else
614
- "year"
615
- end
616
- [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
780
+ return nil unless m
781
+ return [DateTime.parse(str), "day"] if m[4]
782
+
783
+ [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), w3cdtf_precision(m)]
617
784
  rescue Date::Error
618
- ["", nil]
785
+ nil
786
+ end
787
+
788
+ # The granularity the record stopped at, which is the whole point of
789
+ # matching the shape rather than widening the parse.
790
+ def w3cdtf_precision(match)
791
+ return "day" if match[3]
792
+
793
+ match[2] ? "month" : "year"
619
794
  end
620
795
 
621
796
  # One originInfo date element, read by its attributes rather than by
622
797
  # position. A record is free to write point="end" first, and taking the
623
798
  # first node would then invert the range.
799
+ #
800
+ # ONE DATE PER TYPE is the rule, and a repeated, non-ranged, unflagged
801
+ # date of the same type is discarded. A range is one date with two ends,
802
+ # which @point already models, and every consumer of the value -- a sort
803
+ # key, a citation year, an OAI date -- holds exactly one. Note the
804
+ # contrast with a repeated publisher, which survives because
805
+ # publication_information is :many; dates differ because the value is
806
+ # singular downstream, not because repetition went unnoticed.
624
807
  def date_parts(element)
625
808
  nodes = doc.xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
626
809
  return EMPTY_DATE if nodes.empty?
627
810
 
628
- start = nodes.find { |n| attr_value(n, "point") == "start" } ||
629
- nodes.find { |n| attr_value(n, "point") != "end" }
630
811
  finish = nodes.find { |n| attr_value(n, "point") == "end" }
631
- date_entry(start, finish, nodes)
812
+ date_entry(start_date_node(nodes), finish, nodes)
813
+ end
814
+
815
+ # The node the value comes from: the flagged one, then the declared
816
+ # start, then document order.
817
+ #
818
+ # keyDate leads because the flag is the record nominating its own
819
+ # principal date. Projecting the flag while reading the value from a
820
+ # different node made the two contradict each other -- a consumer was told
821
+ # the record chose this date and then handed the one before it.
822
+ def start_date_node(nodes)
823
+ nodes.find { |n| attr_value(n, "keyDate") == "yes" && attr_value(n, "point") != "end" } ||
824
+ nodes.find { |n| attr_value(n, "point") == "start" } ||
825
+ nodes.find { |n| attr_value(n, "point") != "end" }
632
826
  end
633
827
 
634
828
  # The end point carries its OWN precision. "1935-06" to "1940" is legal,
@@ -641,7 +835,7 @@ module NEU
641
835
  # itself: MODS enumerates approximate, inferred and questionable, but the
642
836
  # record still said something.
643
837
  def date_entry(start, finish, nodes)
644
- value, precision = node_date(start)
838
+ value, precision, text = node_date(start)
645
839
  end_value, end_precision = node_date(finish)
646
840
  {
647
841
  value: value,
@@ -649,17 +843,28 @@ module NEU
649
843
  end_value: end_value,
650
844
  end_precision: end_precision,
651
845
  qualifier: attr_value(start, "qualifier") || attr_value(finish, "qualifier"),
652
- key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" }
846
+ key_date: nodes.any? { |n| attr_value(n, "keyDate") == "yes" },
847
+ text: text
653
848
  }
654
849
  end
655
850
 
851
+ # [value, precision, text]. A node whose text is not a w3cdtf date yields
852
+ # no value and keeps its literal instead: "ca. 1920", "19th century" and
853
+ # "1918-1921" in one element are all real statements a cataloguer made,
854
+ # and a preservation repository must neither invent a date for them nor
855
+ # delete them. Only the start node's literal is kept -- the observed
856
+ # corpus writes an unreadable date as one element, and an end point that
857
+ # needs its own literal has never been seen.
656
858
  def node_date(node)
657
- return [nil, nil] unless node
859
+ return [nil, nil, nil] unless node
658
860
 
659
861
  str = NEU::MODS.canonical_ws(node.text)
660
- return [nil, nil] if str.empty?
862
+ return [nil, nil, nil] if str.empty?
863
+
864
+ parsed = parse_w3cdtf(str)
865
+ return [parsed[0], parsed[1], nil] if parsed
661
866
 
662
- parse_w3cdtf(str)
867
+ [nil, nil, str]
663
868
  end
664
869
 
665
870
  def attr_value(node, name)
@@ -738,6 +943,14 @@ module NEU
738
943
  # punctuation of a citation is display policy, the same call #map_data
739
944
  # makes for cartographics. MODS leaves @unit optional, so a page extent
740
945
  # without one is read rather than dropped.
946
+ #
947
+ # Volume, issue and the page range keep named keys because they are the
948
+ # citation and a consumer asks for them by name. Everything else the
949
+ # schema allows under part arrives structured, because detail/@type and
950
+ # extent/@unit are open strings -- a fixed key per type cannot cover a
951
+ # vocabulary the schema does not close. #date is the article's year within
952
+ # the host, which after the title is the most-cited element of a journal
953
+ # citation and was reaching no consumer at all.
741
954
  def host_part(node)
742
955
  part = node.at_xpath("mods:part", NAMESPACE)
743
956
  return {} if part.nil?
@@ -747,8 +960,46 @@ module NEU
747
960
  volume: child_text(part, "mods:detail[@type='volume']/mods:number"),
748
961
  issue: child_text(part, "mods:detail[@type='issue']/mods:number"),
749
962
  start_page: child_text(part, "#{pages}/mods:start"),
750
- end_page: child_text(part, "#{pages}/mods:end")
751
- }.compact
963
+ end_page: child_text(part, "#{pages}/mods:end"),
964
+ date: child_text(part, "mods:date"),
965
+ text: child_text(part, "mods:text"),
966
+ details: host_details(part),
967
+ extents: host_extents(part)
968
+ }.reject { |_, value| value.nil? || value == [] }
969
+ end
970
+
971
+ def host_details(part)
972
+ part.xpath("mods:detail", NAMESPACE).filter_map do |node|
973
+ type = clean(node["type"])
974
+ next if NAMED_HOST_DETAIL_TYPES.include?(type)
975
+
976
+ entry = {
977
+ type: type,
978
+ number: child_text(node, "mods:number"),
979
+ caption: child_text(node, "mods:caption"),
980
+ title: child_text(node, "mods:title")
981
+ }
982
+ entry if entry.except(:type).values.any?
983
+ end
984
+ end
985
+
986
+ # Every extent EXCEPT the page range, which start_page and end_page hold.
987
+ # A unit other than page -- the minutes of a recording, the columns of a
988
+ # newspaper -- means nothing without its unit, so the unit travels with
989
+ # the numbers rather than being flattened away.
990
+ def host_extents(part)
991
+ part.xpath("mods:extent", NAMESPACE).filter_map do |node|
992
+ unit = clean(node["unit"])
993
+ next if unit.nil? || unit == "page"
994
+
995
+ {
996
+ unit: unit,
997
+ start: child_text(node, "mods:start"),
998
+ end: child_text(node, "mods:end"),
999
+ total: child_text(node, "mods:total"),
1000
+ list: child_text(node, "mods:list")
1001
+ }
1002
+ end
752
1003
  end
753
1004
 
754
1005
  def text_at(xpath)
@@ -778,6 +1029,26 @@ module NEU
778
1029
  doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
779
1030
  end
780
1031
 
1032
+ # The language of one element: the text term, or a code read through the
1033
+ # ISO 639 registry.
1034
+ def language_term(lang)
1035
+ text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
1036
+ return clean(text.text) if text
1037
+
1038
+ code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
1039
+ code && LanguageCodes.term(code)
1040
+ end
1041
+
1042
+ # A scriptTerm, text form preferred. No registry expands a code here: the
1043
+ # ISO 15924 list is not vendored, and inventing a half-translation would
1044
+ # be worse than handing the consumer what the record wrote.
1045
+ def script_term(lang)
1046
+ text = lang.at_xpath("mods:scriptTerm[@type='text']", NAMESPACE)
1047
+ return clean(text.text) if text
1048
+
1049
+ clean(lang.at_xpath("mods:scriptTerm", NAMESPACE)&.text)
1050
+ end
1051
+
781
1052
  def child_text(parent, xpath)
782
1053
  return nil unless parent
783
1054
 
@@ -802,12 +1073,20 @@ module NEU
802
1073
  NEU::MODS.canonical_ws(str)
803
1074
  end
804
1075
 
805
- # The schema leaves accessCondition/@type an open string, so match on the
806
- # canonicalised, case-folded value rather than in the XPath: real records
807
- # carry "Use and Reproduction" as readily as the MODS-recommended casing.
1076
+ # The schema leaves accessCondition/@type an open string, so match on a
1077
+ # folded key rather than in the XPath. Real records carry "Use and
1078
+ # Reproduction", "useAndReproduction" and "restriction-on-access" as
1079
+ # readily as the MODS-recommended casing, and an unmatched
1080
+ # restrictionOnAccess fell through to the generic #access_condition --
1081
+ # which is the same defect the two typed fields exist to prevent, reached
1082
+ # by a different route: a restriction presented to a reader as a licence.
1083
+ #
1084
+ # A genuinely unrecognised type still falls through, which is what
1085
+ # #access_condition is for.
808
1086
  def access_conditions_of_type(type)
1087
+ wanted = Projection.fold_type(type)
809
1088
  nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
810
- .select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
1089
+ .select { |node| Projection.fold_type(node["type"]) == wanted }
811
1090
  join_paragraphs(nodes)
812
1091
  end
813
1092
 
@@ -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
data/lib/neu-mods.rb CHANGED
@@ -35,6 +35,7 @@ module NEU
35
35
 
36
36
  # Whitespace no-op guard (see Canonicalize).
37
37
  def canonical_ws(str) = Canonicalize.canonical_ws(str)
38
+ def canonical_lines(str) = Canonicalize.canonical_lines(str)
38
39
  def whitespace_equivalent?(current, incoming) = Canonicalize.whitespace_equivalent?(current, incoming)
39
40
 
40
41
  # Curator-freetext normalization for the access copy (see TextNormalizer).
metadata CHANGED
@@ -1,55 +1,55 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.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-06 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - '>='
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.13'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - '>='
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.13'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rspec
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ~>
32
32
  - !ruby/object:Gem::Version
33
33
  version: '3.12'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ~>
39
39
  - !ruby/object:Gem::Version
40
40
  version: '3.12'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rubocop
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ~>
46
46
  - !ruby/object:Gem::Version
47
47
  version: '1.60'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ~>
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.60'
55
55
  description: 'Nokogiri-native, dependency-light reading/projection contract over MODS
@@ -62,7 +62,7 @@ executables: []
62
62
  extensions: []
63
63
  extra_rdoc_files: []
64
64
  files:
65
- - ".version"
65
+ - .version
66
66
  - Gemfile
67
67
  - README.md
68
68
  - Rakefile
@@ -85,16 +85,16 @@ require_paths:
85
85
  - lib
86
86
  required_ruby_version: !ruby/object:Gem::Requirement
87
87
  requirements:
88
- - - ">="
88
+ - - '>='
89
89
  - !ruby/object:Gem::Version
90
90
  version: '3.0'
91
91
  required_rubygems_version: !ruby/object:Gem::Requirement
92
92
  requirements:
93
- - - ">="
93
+ - - '>='
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 3.2.33
97
+ rubygems_version: 3.0.9
98
98
  signing_key:
99
99
  specification_version: 4
100
100
  summary: Northeastern-flavored MODS XML projection + selection for the DRS.