Module: NEU::MODS::Projection

Included in:
Document
Defined in:
lib/neu/mods/projection.rb

Overview

Node -> plain data. The read contract: what a MODS document projects to for indexing/display. Behavior-preserving with Atlas's prior mods-gem-based extraction (verified by the conformance corpus), reimplemented in Nokogiri so DRS depends on Nokogiri alone. Mixed into Document; operates on doc.

Empty-value conventions mirror Atlas: scalar fields are "" when absent (matching .text.squish on an empty node set), except permanent_url and date_created, which are nil when their node is absent. Arrays are []. Which fields are scalar and which are arrays is declared in FIELDS, not left to each method to decide.

Constant Summary collapse

TITLE_SEPARATORS =

Pure title composition over a parts hash, factored out of #plain_title so callers that already hold the parts -- e.g. Atlas's access-copy model -- can compose the display title WITHOUT re-parsing XML on the read path (reaching for Nokogiri in a decorator is the smell this avoids). Keys: :non_sort :title :subtitle :part_name :part_number (nil or "" for absent). Returns "" when there is no title. Exposed as NEU::MODS.compose_title.

nonSort, title, subtitle, partName, partNumber -- the order the librarians chose. titleInfo is an unordered choice in the schema, so no document order is available to follow and the composer has to fix one.

A period separates the title or subtitle from the parts, and one part from the next. The separator travels with its part rather than with the position, so a record giving only a partNumber still gets the period. Nothing is appended after the last part: a title is a value, not a sentence, and a trailing period reads as part of the title everywhere the value is re-used.

[[": ", :subtitle], [". ", :part_name], [". ", :part_number]].freeze
NON_SORT_BINDING =

Characters that bind a nonSort to the word after it. An elided article takes no space -- "L'Etranger", not "L' Etranger" -- and the same holds for a hyphenated prefix. U+2019 is the curly apostrophe, escaped rather than literal to keep lib/ pure ASCII (see the source-purity spec).

["'", "\u2019", "-"].freeze
HEADING_OMITTED_CHILDREN =

Neither child carries heading text: cartographics is a structured coordinate a reader reaches through #map_data, and geographicCode is a MARC code rather than a place name.

%w[cartographics geographicCode].freeze
HEADING_SEPARATOR =

What a cataloguer puts between the steps of a pre-coordinated heading. Here rather than with the consumer because the composed heading is a projected value now: a display and an index that both read it cannot separate it differently. Spaces included, which is the separator DRS has displayed for years.

" -- "
TYPELESS_NAME_SUBJECT_TYPE =

A with no @type reaches the corporate axis. The display already composes such a name, because #subject_heading_part never consulted @type, so the alternative is a heading a reader sees and no browse holds. Corporate rather than personal: MODS expects @type="personal" on a person, and the untyped subject names DRS holds are institutional.

"corporate"
MARC_COUNTRY_AUTHORITY =

Prefer the type="text" term per place, falling back to a coded one -- the pattern #role_term_value and #languages already use.

A bare marccountry code is the exception, and it drops. "mau" is not a place name, and unfiltered it reached the display and the Solr places facet as one, sitting in the list beside Boston. That is the call #geographic_code_subjects already makes for a MARC GAC code. A code under any other authority survives, because there the code may be the only statement the record made and nothing here can say it is not text.

TODO: expand a marccountry code through a registry, as LanguageCodes does for eng -> English. That needs a vendored code list, and would let this project "Massachusetts" instead of dropping the element.

"marccountry"
%w[series host].freeze
NAMED_HOST_DETAIL_TYPES =

The part detail types volume and issue already have named keys on a host entry, so #host_details does not repeat them -- the same split NAMED_RELATED_ITEM_TYPES makes for relatedItem. A caption is kept because it is the label a cataloguer wrote for the number ("chap." before "3"), which no consumer can reconstruct from an open @type.

%w[volume issue].freeze
HIERARCHICAL_GEOGRAPHIC_LEVELS =

The eleven children the XSD allows under hierarchicalGeographic, in the order MODS lists them -- broadest first, which is also the order a consumer composing a place string wants to reverse.

%i[
  continent country province region state territory county city
  city_section island area
].freeze
RECORD_INFO_PARTS =

recordInfo children. Read as a single value: the schema repeats the element, but a record with two cataloguing provenances is not a case anyone has, and an array here buys nothing.

{
  content_source: "mods:recordContentSource",
  origin: "mods:recordOrigin",
  description_standard: "mods:descriptionStandard",
  creation_date: "mods:recordCreationDate",
  change_date: "mods:recordChangeDate",
  language_of_cataloging: "mods:languageOfCataloging/mods:languageTerm"
}.freeze
W3CDTF_DATE =

The w3cdtf date shapes a date element may stop at: year, year-month, a full date, or a full date with a time. Matching the shape explicitly, rather than widening DateTime.parse, is what lets the declared precision fall out of the parse instead of being guessed after it.

A value outside these shapes is NOT a date, and #parse_w3cdtf says so rather than guessing. Ruby's DateTime.parse fills the components it cannot find from the CURRENT date, so "19uu" -- standard MARC 008 fill, which the v1 corpus carries at scale -- asserted today's date at "day" precision, and the assertion changed daily. What the record actually wrote survives in the matching *_text field instead.

/\A(\d{4})(?:-(\d{2})(?:-(\d{2})(T\S+)?)?)?\z/
ISO8601_BASIC_DATE =

ISO 8601 basic format: the same year, month and day written without the hyphens. Accepted ONLY where the record declares @encoding="iso8601", because eight bare digits are a date only because the encoding says so -- an accession number is eight digits too, and guessing is the mistake dropping the DateTime.parse fallback exists to prevent.

/\A(\d{4})(?:(\d{2})(?:(\d{2})(T\S+)?)?)?\z/
ISO8601_ENCODING =

@encoding, folded. MODS leaves the attribute an open string and records write "iso8601" and "ISO-8601" alike.

"iso8601"
EMPTY_DATE =

What #date_parts returns when the element is absent entirely, so an absent date is distinguishable from one present and unparseable.

{ value: nil, precision: nil, end_value: nil, end_precision: nil,
qualifier: nil, key_date: nil, text: nil,
display_label: nil, event_type: nil }.freeze
DATE_ELEMENTS =

Everything a record declared about one originInfo date, as { value:, precision:, end_value:, end_precision:, qualifier:, key_date:, text: }.

A date is not a scalar. Precision established that: a year-only date parses to January 1st, and no consumer downstream can tell that month and day from a record that claimed them. A range and a qualifier are the same kind of claim, and dropping them breaks the same rule -- a preservation repository must not project a value the record did not give. A ranged record was worse than that: #at_xpath took the first node, so one end of the range was PROMOTED to be the date, and the output was indistinguishable from a single certain year.

The parts are projected as separate flat fields rather than one nested value, because the value half has three consumers that need a real date object -- a Solr sort key, a citation year and an OAI date. Those three are also why the literal gets its own field rather than sharing the value: a sort key cannot hold "ca. 1920", and a display can. MODS puts seven date elements under originInfo and this reads all of them. dateCaptured is when the object was digitised and dateModified is when the resource changed -- preservation and cataloguing provenance, which a consumer may keep off a page but cannot recover from anywhere else. dateValid is the period the content holds for, and dateOther is where a date fitting no other element lands, which is where a quantity of migrated v1 date data goes. The seven date elements MODS puts under originInfo, in the order a consumer deciding a place header reads them.

%w[dateIssued dateCreated copyrightDate dateCaptured
dateValid dateOther dateModified].freeze
FIELDS =

The field registry: the single declaration of what this gem projects. Field name => cardinality, :one or :many. The projection method of the same name owns the XPath; this row says the field exists and whether it is single- or multi-valued. #to_h is derived from it, and Atlas derives its Metadata::MODS attr_json set from it, so a field cannot be projected here and go undeclared there (or the reverse).

Cardinality is the half that earns its keep. The at_xpath-versus-xpath choice here and the single-versus-array column choice in Atlas used to be made independently in two repos with nothing tying them together, which is how repeatable MODS elements ended up truncated to their first match. #cardinality_of checks each method against its row.

{
  # titles
  main_title: :one,
  main_title_display_label: :one,
  alternative_title: :many,
  uniform_title: :many,
  translated_title: :many,
  abbreviated_title: :many,

  names: :many,
  languages: :many,
  abstract: :one,
  abstract_display_label: :one,
  abstract_href: :one,

  # origin
  publication_information: :many,
  place_of_publication: :many,
  origin_agents: :many,
  edition: :many,
  issuance: :many,
  frequency: :many,
  # Seven rows per originInfo date, for each of the seven MODS defines.
  # Flat rather than one nested value, because the value half has
  # consumers that need a real date object.
  date_created: :one,
  date_created_precision: :one,
  date_created_end: :one,
  date_created_end_precision: :one,
  date_created_qualifier: :one,
  date_created_key_date: :one,
  date_created_text: :one,
  date_created_display_label: :one,
  date_created_event_type: :one,
  date_issued: :one,
  date_issued_precision: :one,
  date_issued_end: :one,
  date_issued_end_precision: :one,
  date_issued_qualifier: :one,
  date_issued_key_date: :one,
  date_issued_text: :one,
  date_issued_display_label: :one,
  date_issued_event_type: :one,
  copyright_date: :one,
  copyright_date_precision: :one,
  copyright_date_end: :one,
  copyright_date_end_precision: :one,
  copyright_date_qualifier: :one,
  copyright_date_key_date: :one,
  copyright_date_text: :one,
  copyright_date_display_label: :one,
  copyright_date_event_type: :one,
  date_captured: :one,
  date_captured_precision: :one,
  date_captured_end: :one,
  date_captured_end_precision: :one,
  date_captured_qualifier: :one,
  date_captured_key_date: :one,
  date_captured_text: :one,
  date_captured_display_label: :one,
  date_captured_event_type: :one,
  date_valid: :one,
  date_valid_precision: :one,
  date_valid_end: :one,
  date_valid_end_precision: :one,
  date_valid_qualifier: :one,
  date_valid_key_date: :one,
  date_valid_text: :one,
  date_valid_display_label: :one,
  date_valid_event_type: :one,
  date_other: :one,
  date_other_precision: :one,
  date_other_end: :one,
  date_other_end_precision: :one,
  date_other_qualifier: :one,
  date_other_key_date: :one,
  date_other_text: :one,
  date_other_display_label: :one,
  date_other_event_type: :one,
  date_modified: :one,
  date_modified_precision: :one,
  date_modified_end: :one,
  date_modified_end_precision: :one,
  date_modified_qualifier: :one,
  date_modified_key_date: :one,
  date_modified_text: :one,
  date_modified_display_label: :one,
  date_modified_event_type: :one,

  # physical description
  resource_type: :many,
  target_audience: :many,
  genres: :many,
  format: :many,
  extent: :many,
  digital_origin: :many,
  reformatting_quality: :many,
  physical_description_notes: :many,
  notes: :many,
  table_of_contents: :many,

  # subjects
  subject_headings: :many,
  topical_subjects: :many,
  geographic_subjects: :many,
  temporal_subjects: :many,
  personal_name_subjects: :many,
  corporate_name_subjects: :many,
  occupation_subjects: :many,
  genre_subjects: :many,
  geographic_code_subjects: :many,
  title_subjects: :many,
  hierarchical_geographic_subjects: :many,
  map_data: :many,

  # related items
  related_series: :many,
  host_collections: :many,
  related_items: :many,

  # identifiers and location
  identifiers: :many,
  classification: :many,
  permanent_url: :one,
  permanent_url_display_label: :one,
  record_info: :one,
  location: :many,

  # access
  access_condition: :one,
  access_condition_display_label: :one,
  access_condition_href: :one,
  use_and_reproduction: :one,
  use_and_reproduction_display_label: :one,
  use_and_reproduction_href: :one,
  restriction_on_access: :one,
  restriction_on_access_display_label: :one,
  restriction_on_access_href: :one
}.freeze
AUTHORITY_ATTRIBUTES =

The three attributes MODS uses to declare where a value came from, and the projected key each reports under. Read by #authority_of below.

{ authority: "authority", authority_uri: "authorityURI",
value_uri: "valueURI" }.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.cardinality_of(value) ⇒ Object

The cardinality a projected value actually has, for checking a value against its FIELDS row. An Array is :many and anything else is :one, so a field declared :many that forgot to switch at_xpath for xpath is caught.



948
# File 'lib/neu/mods/projection.rb', line 948

def self.cardinality_of(value) = value.is_a?(Array) ? :many : :one

.compose_title(parts) ⇒ Object



68
69
70
71
72
73
74
75
# File 'lib/neu/mods/projection.rb', line 68

def self.compose_title(parts)
  return "" if parts[:title].to_s.strip.empty?

  suffix = TITLE_SEPARATORS.filter_map do |separator, key|
    "#{separator}#{parts[key]}" unless parts[key].to_s.strip.empty?
  end.join
  "#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
end

.fold_type(str) ⇒ Object

An open-string @type reduced to its letters and digits, so casing, word separators and camelCasing cannot decide whether a field matches.



150
151
152
# File 'lib/neu/mods/projection.rb', line 150

def self.fold_type(str)
  NEU::MODS.canonical_ws(str).downcase.gsub(/[^a-z0-9]/, "")
end

.join_non_sort(non_sort, title) ⇒ Object

MODS says a nonSort carries whatever separator it needs, so the historical composition simply concatenated. That only holds while the authored trailing space survives, and it does not: #child_text canonicalizes whitespace on read, so <nonSort>The </nonSort> arrives here as "The" and the title came out as "TheHobbit". Composing the separator instead makes the output right whether or not the source kept one -- which matters, because an invisible trailing space is not something a curator, a hand-edit or a third-party producer can be relied on to preserve.

Callers that DO pass the space (Atlas's access-copy model) are unaffected: a nonSort already ending in whitespace is joined as-is.



94
95
96
97
98
99
100
# File 'lib/neu/mods/projection.rb', line 94

def self.join_non_sort(non_sort, title)
  prefix = non_sort.to_s
  return title.to_s if prefix.empty?
  return "#{prefix}#{title}" if prefix.end_with?(" ") || prefix.end_with?(*NON_SORT_BINDING)

  "#{prefix} #{title}"
end

Instance Method Details

#abbreviated_titleObject



41
# File 'lib/neu/mods/projection.rb', line 41

def abbreviated_title = variant_titles("abbreviated")

#abstractObject

--- Abstract / access ---------------------------------------------------



104
105
106
# File 'lib/neu/mods/projection.rb', line 104

def abstract
  join_paragraphs(abstract_nodes)
end

#abstract_display_labelObject

The header and the link a record attached to its abstract. Companion scalars rather than an entry, because #abstract joins every abstract element into one value and three consumers -- the OAI dc:description, the citation and description_tsim -- hold that value as a string.



112
# File 'lib/neu/mods/projection.rb', line 112

def abstract_display_label = first_attr(abstract_nodes, "displayLabel")

#abstract_hrefObject



113
# File 'lib/neu/mods/projection.rb', line 113

def abstract_href = first_href(abstract_nodes)

#access_conditionObject

Every top-level accessCondition joined, regardless of @type. Retained because it is the only projection that carries an untyped or unrecognised accessCondition, which the two typed fields below cannot see -- a consumer that renders only those needs this as its fallback.



119
120
121
# File 'lib/neu/mods/projection.rb', line 119

def access_condition
  join_paragraphs(doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE))
end

#access_condition_display_labelObject

Companion scalars for the same reason the abstract's are: each of the three fields joins several elements into one value, and a licence URI belongs beside the licence text a reader is given.



133
# File 'lib/neu/mods/projection.rb', line 133

def access_condition_display_label = first_attr(access_condition_nodes, "displayLabel")

#access_condition_hrefObject



134
# File 'lib/neu/mods/projection.rb', line 134

def access_condition_href = first_href(access_condition_nodes)

#access_title_partsObject

The title parts as the access copy wants them: normalised like the abstract, so a curly quote, an invisible format mark or a Windows-1252 control cannot reach Solr or a display template. Titles and prose share one vocabulary -- the asymmetry where only prose was cleaned was the bug.



954
955
956
# File 'lib/neu/mods/projection.rb', line 954

def access_title_parts
  title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) }
end

#alternative_names(node) ⇒ Object

mods:alternativeName, new in MODS 3.7: a second form of the same name, not a second name. Composed with the ENCLOSING name's @type, because alternativeName carries @altType rather than @type and an alternative for a personal name is still a personal name -- read from its own attributes it would compose "Doe Jane" where the name above it composes "Doe, Jane".



294
295
296
297
298
# File 'lib/neu/mods/projection.rb', line 294

def alternative_names(node)
  node.xpath("mods:alternativeName", NAMESPACE).filter_map do |alt|
    name_display_value_w_date(alt, attr_value(node, "type"))
  end
end

#alternative_titleObject

The variant titles, each composed and normalised like the main title. MODS repeats titleInfo, so a record may carry more than one of a type. These are what keeps the primary-title fallback's refusal to promote a variant from hiding anything: the variant still reaches a reader, under a label that says which kind of title it is.



38
# File 'lib/neu/mods/projection.rb', line 38

def alternative_title = variant_titles("alternative")

#classificationObject

An LCC or DDC call number. Note this is NOT the same concept as Atlas's classification_ssim, which carries a FileSet content-type vocabulary -- the name collision is accidental and the consumer has to pick a free Solr field.



484
# File 'lib/neu/mods/projection.rb', line 484

def classification = labeled_texts_at("/mods:mods/mods:classification")


715
# File 'lib/neu/mods/projection.rb', line 715

def copyright_date = copyright_date_parts[:value]


722
# File 'lib/neu/mods/projection.rb', line 722

def copyright_date_display_label = copyright_date_parts[:display_label]


717
# File 'lib/neu/mods/projection.rb', line 717

def copyright_date_end = copyright_date_parts[:end_value]


718
# File 'lib/neu/mods/projection.rb', line 718

def copyright_date_end_precision = copyright_date_parts[:end_precision]


723
# File 'lib/neu/mods/projection.rb', line 723

def copyright_date_event_type = copyright_date_parts[:event_type]


720
# File 'lib/neu/mods/projection.rb', line 720

def copyright_date_key_date = copyright_date_parts[:key_date]


689
# File 'lib/neu/mods/projection.rb', line 689

def copyright_date_parts = date_parts("copyrightDate")


716
# File 'lib/neu/mods/projection.rb', line 716

def copyright_date_precision = copyright_date_parts[:precision]


719
# File 'lib/neu/mods/projection.rb', line 719

def copyright_date_qualifier = copyright_date_parts[:qualifier]


721
# File 'lib/neu/mods/projection.rb', line 721

def copyright_date_text = copyright_date_parts[:text]


770
# File 'lib/neu/mods/projection.rb', line 770

def copyright_date_with_precision = [copyright_date, copyright_date_precision]

#corporate_name_subjectsObject



227
# File 'lib/neu/mods/projection.rb', line 227

def corporate_name_subjects = name_subjects("corporate")

#date_capturedObject



725
# File 'lib/neu/mods/projection.rb', line 725

def date_captured = date_captured_parts[:value]

#date_captured_display_labelObject



732
# File 'lib/neu/mods/projection.rb', line 732

def date_captured_display_label = date_captured_parts[:display_label]

#date_captured_endObject



727
# File 'lib/neu/mods/projection.rb', line 727

def date_captured_end = date_captured_parts[:end_value]

#date_captured_end_precisionObject



728
# File 'lib/neu/mods/projection.rb', line 728

def date_captured_end_precision = date_captured_parts[:end_precision]

#date_captured_event_typeObject



733
# File 'lib/neu/mods/projection.rb', line 733

def date_captured_event_type = date_captured_parts[:event_type]

#date_captured_key_dateObject



730
# File 'lib/neu/mods/projection.rb', line 730

def date_captured_key_date = date_captured_parts[:key_date]

#date_captured_partsObject



690
# File 'lib/neu/mods/projection.rb', line 690

def date_captured_parts = date_parts("dateCaptured")

#date_captured_precisionObject



726
# File 'lib/neu/mods/projection.rb', line 726

def date_captured_precision = date_captured_parts[:precision]

#date_captured_qualifierObject



729
# File 'lib/neu/mods/projection.rb', line 729

def date_captured_qualifier = date_captured_parts[:qualifier]

#date_captured_textObject



731
# File 'lib/neu/mods/projection.rb', line 731

def date_captured_text = date_captured_parts[:text]

#date_createdObject



695
# File 'lib/neu/mods/projection.rb', line 695

def date_created = date_created_parts[:value]

#date_created_display_labelObject



702
# File 'lib/neu/mods/projection.rb', line 702

def date_created_display_label = date_created_parts[:display_label]

#date_created_endObject



697
# File 'lib/neu/mods/projection.rb', line 697

def date_created_end = date_created_parts[:end_value]

#date_created_end_precisionObject



698
# File 'lib/neu/mods/projection.rb', line 698

def date_created_end_precision = date_created_parts[:end_precision]

#date_created_event_typeObject



703
# File 'lib/neu/mods/projection.rb', line 703

def date_created_event_type = date_created_parts[:event_type]

#date_created_key_dateObject



700
# File 'lib/neu/mods/projection.rb', line 700

def date_created_key_date = date_created_parts[:key_date]

#date_created_partsObject



687
# File 'lib/neu/mods/projection.rb', line 687

def date_created_parts = date_parts("dateCreated")

#date_created_precisionObject



696
# File 'lib/neu/mods/projection.rb', line 696

def date_created_precision = date_created_parts[:precision]

#date_created_qualifierObject



699
# File 'lib/neu/mods/projection.rb', line 699

def date_created_qualifier = date_created_parts[:qualifier]

#date_created_textObject



701
# File 'lib/neu/mods/projection.rb', line 701

def date_created_text = date_created_parts[:text]

#date_created_with_precisionObject

The [value, precision] pair the precision work introduced. Retained because it is the documented entry point for a caller that wants both halves and nothing else.



768
# File 'lib/neu/mods/projection.rb', line 768

def date_created_with_precision = [date_created, date_created_precision]

#date_issuedObject



705
# File 'lib/neu/mods/projection.rb', line 705

def date_issued = date_issued_parts[:value]

#date_issued_display_labelObject



712
# File 'lib/neu/mods/projection.rb', line 712

def date_issued_display_label = date_issued_parts[:display_label]

#date_issued_endObject



707
# File 'lib/neu/mods/projection.rb', line 707

def date_issued_end = date_issued_parts[:end_value]

#date_issued_end_precisionObject



708
# File 'lib/neu/mods/projection.rb', line 708

def date_issued_end_precision = date_issued_parts[:end_precision]

#date_issued_event_typeObject



713
# File 'lib/neu/mods/projection.rb', line 713

def date_issued_event_type = date_issued_parts[:event_type]

#date_issued_key_dateObject



710
# File 'lib/neu/mods/projection.rb', line 710

def date_issued_key_date = date_issued_parts[:key_date]

#date_issued_partsObject



688
# File 'lib/neu/mods/projection.rb', line 688

def date_issued_parts = date_parts("dateIssued")

#date_issued_precisionObject



706
# File 'lib/neu/mods/projection.rb', line 706

def date_issued_precision = date_issued_parts[:precision]

#date_issued_qualifierObject



709
# File 'lib/neu/mods/projection.rb', line 709

def date_issued_qualifier = date_issued_parts[:qualifier]

#date_issued_textObject



711
# File 'lib/neu/mods/projection.rb', line 711

def date_issued_text = date_issued_parts[:text]

#date_issued_with_precisionObject



769
# File 'lib/neu/mods/projection.rb', line 769

def date_issued_with_precision = [date_issued, date_issued_precision]

#date_modifiedObject



755
# File 'lib/neu/mods/projection.rb', line 755

def date_modified = date_modified_parts[:value]

#date_modified_display_labelObject



762
# File 'lib/neu/mods/projection.rb', line 762

def date_modified_display_label = date_modified_parts[:display_label]

#date_modified_endObject



757
# File 'lib/neu/mods/projection.rb', line 757

def date_modified_end = date_modified_parts[:end_value]

#date_modified_end_precisionObject



758
# File 'lib/neu/mods/projection.rb', line 758

def date_modified_end_precision = date_modified_parts[:end_precision]

#date_modified_event_typeObject



763
# File 'lib/neu/mods/projection.rb', line 763

def date_modified_event_type = date_modified_parts[:event_type]

#date_modified_key_dateObject



760
# File 'lib/neu/mods/projection.rb', line 760

def date_modified_key_date = date_modified_parts[:key_date]

#date_modified_partsObject



693
# File 'lib/neu/mods/projection.rb', line 693

def date_modified_parts = date_parts("dateModified")

#date_modified_precisionObject



756
# File 'lib/neu/mods/projection.rb', line 756

def date_modified_precision = date_modified_parts[:precision]

#date_modified_qualifierObject



759
# File 'lib/neu/mods/projection.rb', line 759

def date_modified_qualifier = date_modified_parts[:qualifier]

#date_modified_textObject



761
# File 'lib/neu/mods/projection.rb', line 761

def date_modified_text = date_modified_parts[:text]

#date_otherObject



745
# File 'lib/neu/mods/projection.rb', line 745

def date_other = date_other_parts[:value]

#date_other_display_labelObject



752
# File 'lib/neu/mods/projection.rb', line 752

def date_other_display_label = date_other_parts[:display_label]

#date_other_endObject



747
# File 'lib/neu/mods/projection.rb', line 747

def date_other_end = date_other_parts[:end_value]

#date_other_end_precisionObject



748
# File 'lib/neu/mods/projection.rb', line 748

def date_other_end_precision = date_other_parts[:end_precision]

#date_other_event_typeObject



753
# File 'lib/neu/mods/projection.rb', line 753

def date_other_event_type = date_other_parts[:event_type]

#date_other_key_dateObject



750
# File 'lib/neu/mods/projection.rb', line 750

def date_other_key_date = date_other_parts[:key_date]

#date_other_partsObject



692
# File 'lib/neu/mods/projection.rb', line 692

def date_other_parts = date_parts("dateOther")

#date_other_precisionObject



746
# File 'lib/neu/mods/projection.rb', line 746

def date_other_precision = date_other_parts[:precision]

#date_other_qualifierObject



749
# File 'lib/neu/mods/projection.rb', line 749

def date_other_qualifier = date_other_parts[:qualifier]

#date_other_textObject



751
# File 'lib/neu/mods/projection.rb', line 751

def date_other_text = date_other_parts[:text]

#date_validObject



735
# File 'lib/neu/mods/projection.rb', line 735

def date_valid = date_valid_parts[:value]

#date_valid_display_labelObject



742
# File 'lib/neu/mods/projection.rb', line 742

def date_valid_display_label = date_valid_parts[:display_label]

#date_valid_endObject



737
# File 'lib/neu/mods/projection.rb', line 737

def date_valid_end = date_valid_parts[:end_value]

#date_valid_end_precisionObject



738
# File 'lib/neu/mods/projection.rb', line 738

def date_valid_end_precision = date_valid_parts[:end_precision]

#date_valid_event_typeObject



743
# File 'lib/neu/mods/projection.rb', line 743

def date_valid_event_type = date_valid_parts[:event_type]

#date_valid_key_dateObject



740
# File 'lib/neu/mods/projection.rb', line 740

def date_valid_key_date = date_valid_parts[:key_date]

#date_valid_partsObject



691
# File 'lib/neu/mods/projection.rb', line 691

def date_valid_parts = date_parts("dateValid")

#date_valid_precisionObject



736
# File 'lib/neu/mods/projection.rb', line 736

def date_valid_precision = date_valid_parts[:precision]

#date_valid_qualifierObject



739
# File 'lib/neu/mods/projection.rb', line 739

def date_valid_qualifier = date_valid_parts[:qualifier]

#date_valid_textObject



741
# File 'lib/neu/mods/projection.rb', line 741

def date_valid_text = date_valid_parts[:text]

#digital_originObject



381
# File 'lib/neu/mods/projection.rb', line 381

def digital_origin = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin", from: "..")

#editable_corporate_creatorsObject



329
330
331
# File 'lib/neu/mods/projection.rb', line 329

def editable_corporate_creators
  editable_creator_nodes("corporate").map { |node| { name: clean_part(non_date_parts_joined(node)) } }
end

#editable_personal_creatorsObject

Editable (depositor-managed) creators: the plain names (no authority markers) with a Creator role, as STRUCTURED parts for form pre-fill -- distinct from #names, which composes display strings for the access copy.



323
324
325
326
327
# File 'lib/neu/mods/projection.rb', line 323

def editable_personal_creators
  editable_creator_nodes("personal").map do |node|
    { given: clean_part(joined_parts(node, "given")), family: clean_part(joined_parts(node, "family")) }
  end
end

#editionObject



401
# File 'lib/neu/mods/projection.rb', line 401

def edition = origin_texts_at("mods:edition")

#extentObject



380
# File 'lib/neu/mods/projection.rb', line 380

def extent = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:extent", from: "..")

#formatObject

MODS puts @displayLabel on physicalDescription, not on the form, extent, digitalOrigin, reformattingQuality or note inside it -- so these four take the label off their parent. from: ".." says which element the header comes from; the value still comes from the element itself.



379
# File 'lib/neu/mods/projection.rb', line 379

def format = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:form", from: "..")

#frequencyObject

Serials. The @authority a record puts on a frequency is not projected: authority handling is a question the gem defers everywhere else -- for genre, subject and name -- and answering it for one field would be inconsistent.



456
# File 'lib/neu/mods/projection.rb', line 456

def frequency = origin_texts_at("mods:frequency")

#genre_subjectsObject



234
# File 'lib/neu/mods/projection.rb', line 234

def genre_subjects = texts_at("/mods:mods/mods:subject/mods:genre")

#genresObject

A genre is a browse axis, so its entry carries the vocabulary the term came from. The other labeled fields do not: nothing gates on their vocabulary, and three more keys on fifteen fields is JSON no consumer reads.



387
# File 'lib/neu/mods/projection.rb', line 387

def genres = labeled_texts_at("/mods:mods/mods:genre", authority: true)

#geographic_code_subjectsObject

A MARC GAC code. Projected as the record wrote it: turning it into a place name needs a lookup table, which is the same call the gem already made for MARC relators -- the label vocabulary belongs to the consumer.



239
# File 'lib/neu/mods/projection.rb', line 239

def geographic_code_subjects = texts_at("/mods:mods/mods:subject/mods:geographicCode")

#geographic_subjectsObject

The other subject axes. Cerberus's IPTC ingest writes subject/geographic from the IPTC City and State fields, so this one was also being written on every batch and read back by nothing.



220
# File 'lib/neu/mods/projection.rb', line 220

def geographic_subjects = texts_at("/mods:mods/mods:subject/mods:geographic")

#hierarchical_geographic_subjectsObject

Kept structured for the reason #map_data is. Flattening country / state / city into "United States -- New York (State) -- Parksville" would make a consumer that wants the city alone unpick a sentence.

This is the axis bdr_43888.mods.xml uses INSTEAD of subject/geographic, so that record projected no place at all -- a live ingest path, not a hypothetical.



255
256
257
258
259
260
# File 'lib/neu/mods/projection.rb', line 255

def hierarchical_geographic_subjects
  doc.xpath("/mods:mods/mods:subject/mods:hierarchicalGeographic", NAMESPACE).filter_map do |node|
    entry = HIERARCHICAL_GEOGRAPHIC_LEVELS.to_h { |level| [level, child_text(node, "mods:#{camelize(level)}")] }
    entry if entry.values.any?
  end
end

#host_collectionsObject

The host's title plus THIS work's position within it. The host's own name, originInfo and identifier stay out: they belong to the other record, and a transcribed copy goes stale the moment that record is edited. A part is the exception, because a volume, issue and page range describe this article and no other record holds that fact.

An entry survives on its part alone. Requiring a title discarded the one piece of the block that was ours along with the metadata that never was, and how to render a titleless host is the consumer's call.



541
542
543
544
545
546
# File 'lib/neu/mods/projection.rb', line 541

def host_collections
  doc.xpath("/mods:mods/mods:relatedItem[@type='host']", NAMESPACE).filter_map do |node|
    entry = { title: child_text(node, "mods:titleInfo/mods:title"), **host_part(node) }
    entry.merge(qualifiers_of(node)) if entry.values.any?
  end
end

#identifiersObject

{ type:, value: }, because a DOI, an accession number and a collection id are not the same kind of thing and no consumer can tell them apart from the digits alone -- a reader shown a bare 10.1234/x cannot see it is a DOI, and a display cannot decide to linkify it. The same argument #notes already makes for its @type, and #permanent_url already proves the attribute is load-bearing by special-casing @type='hdl'. cancelled, superseded or simply wrong. Projected flat, a dead ISBN read exactly like a live one and invited a reader to use it.



584
585
586
587
588
589
590
591
592
# File 'lib/neu/mods/projection.rb', line 584

def identifiers
  doc.xpath("/mods:mods/mods:identifier", NAMESPACE).filter_map do |node|
    value = clean(node.text)
    if value
      { type: clean(node["type"]), value: value, invalid: attr_value(node, "invalid") == "yes",
        **qualifiers_of(node) }
    end
  end
end

#issuanceObject



450
# File 'lib/neu/mods/projection.rb', line 450

def issuance = origin_texts_at("mods:issuance")

#keywordsObject

The editable free-text keyword set (Cerberus simple form): topics under the attribute-free keyword subjects only.



158
159
160
# File 'lib/neu/mods/projection.rb', line 158

def keywords
  keyword_subjects.flat_map { |s| texts_under(s, "mods:topic") }
end

#languagesObject

{ term:, object_part:, script: } per language element. Prefer the type="text" term, and translate a code-only one through the ISO 639 registry. A record saying eng projects "English", so the display and the Solr language facet read the same value rather than the facet showing codes. An unrecognised code survives as itself.

An entry rather than a bare string because @objectPart changes what the record is claiming. <language objectPart="subtitles">spa says the subtitles are Spanish, and projected flat it said the resource was -- which is the case a captioned video hits every time. The script rides along for the same reason a name's role does: a consumer cannot recover it from the term.



356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/neu/mods/projection.rb', line 356

def languages
  doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
    node = language_term_node(lang)
    term = language_term_of(node)
    next unless term

    # The authority comes off the <languageTerm> the term was read from,
    # never off the <language> around it: MODS carries @authority on the
    # term, and a code-only record declares `iso639-2b` there.
    { term: term, object_part: attr_value(lang, "objectPart"), script: script_term(lang),
      **authority_of(node), **qualifiers_of(lang) }
  end
end

#locationObject

location repeats, and one location mixes kinds: a shelf mark and a URL are not interchangeable, and a consumer has to know which it holds before it can decide to linkify it. So the parts stay apart.

The shelf mark is mods:shelfLocator. There is no shelfLocation element in MODS, and the spec fixture carried the same misspelling, so the field was unconditionally nil and the spec asserted nothing.



503
504
505
506
507
508
509
510
511
512
# File 'lib/neu/mods/projection.rb', line 503

def location
  doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
    entry = {
      physical_location: child_text(node, "mods:physicalLocation"),
      shelf_location: child_text(node, "mods:shelfLocator"),
      url: child_text(node, "mods:url")
    }
    entry.merge(qualifiers_of(node)) if entry.values.any?
  end
end

#main_titleObject

Atlas names this field main_title; the registry requires a method per field name, and #access_title_parts is the descriptive name for what it returns. Kept as an alias rather than a rename so both read well.



961
# File 'lib/neu/mods/projection.rb', line 961

def main_title = access_title_parts

#main_title_display_labelObject

What the record wants the title row headed, which is almost never set -- but a record that does set it means it, and "Title" is the one header a display would otherwise never let a curator change.



966
# File 'lib/neu/mods/projection.rb', line 966

def main_title_display_label = attr_value(primary_title_info, "displayLabel")

#map_dataObject

subject/cartographics, kept structured. Composing "scale ; projection coordinates" into one string is display policy, and this gem does not own that -- a consumer that wants only the coordinates should not have to unpick a sentence to get them.



518
519
520
521
522
523
524
525
526
527
528
# File 'lib/neu/mods/projection.rb', line 518

def map_data
  doc.xpath("/mods:mods/mods:subject/mods:cartographics", NAMESPACE).filter_map do |node|
    entry = {
      scale: child_text(node, "mods:scale"),
      projection: child_text(node, "mods:projection"),
      coordinates: child_text(node, "mods:coordinates")
    }
    # cartographics carries neither attribute; the enclosing subject does.
    entry.merge(qualifiers_of(node.parent)) if entry.values.any?
  end
end

#name_entry(node) ⇒ Object

One name as the access copy wants it. affiliation is how a reader tells one J. Doe from another, and it is the field an institutional repository most wants: it repeats in the schema, so it is an array.

Added to the entry rather than as a parallel field, so a name and its affiliation cannot be zipped together wrongly by a consumer.



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/neu/mods/projection.rb', line 270

def name_entry(node)
  {
    name: name_display_value_w_date(node),
    roles: name_roles(node),
    affiliation: texts_under(node, "mods:affiliation"),
    # @usage is fixed="primary" in the schema and exists to nominate the
    # principal name. A record that sets it has said which name leads,
    # and without it a consumer grouping role-less names can only guess.
    usage: attr_value(node, "usage"),
    alternative_names: alternative_names(node),
    # The vocabulary this name was taken from, read off the <name> alone.
    # A marcrelator <roleTerm> inside it says what the person DID, not
    # which list the name came from -- see #authority_of.
    **authority_of(node),
    **qualifiers_of(node)
  }
end

#namesObject

All top-level names as { name:, roles: }. name reproduces the mods gem's display_value_w_date (including its quirks -- faithfully, so existing Solr/ display output is preserved). MODS repeats role on one name, and a cataloguer who records that a person both wrote and edited a work means both. Each term prefers the type="text" roleTerm, falling back to the raw code (NOT MARC-relator-translated -- see README).

A name with no name text drops out. A mods:name carrying only a role projected { name: nil, roles: ["edt"] }, which a display renders as a labelled empty row and which every consumer had to guard against with its own compact_blank. #preserved_names deliberately keeps it: that list tells a curator what the XML holds, so an element they need to fix has to stay visible there.



313
314
315
316
317
318
# File 'lib/neu/mods/projection.rb', line 313

def names
  doc.xpath("/mods:mods/mods:name", NAMESPACE).filter_map do |node|
    entry = name_entry(node)
    entry if entry[:name]
  end
end

#notesObject

Every top-level note, keeping its @type. The type carries meaning -- a "statement of responsibility" is not a "funding" note -- so flattening them into bare strings would repeat the accessCondition mistake.



489
490
491
492
493
494
# File 'lib/neu/mods/projection.rb', line 489

def notes
  doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
    value = clean(node.text)
    { type: clean(node["type"]), value: value, **qualifiers_of(node) } if value
  end
end

#occupation_subjectsObject

The last unprojected member of a closed set: every other subject child already has a field, so leaving this one out made "what a subject can carry" arbitrary rather than complete.



232
# File 'lib/neu/mods/projection.rb', line 232

def occupation_subjects = texts_at("/mods:mods/mods:subject/mods:occupation")

#origin_agentsObject

originInfo/agent, new in MODS 3.8: who performed the event the block records. Read through the same port as a top-level name, so a publisher recorded as an agent composes the way a creator does and carries its roles -- which is what a consumer heads the row with when the block states no displayLabel or eventType.



433
434
435
436
437
438
# File 'lib/neu/mods/projection.rb', line 433

def origin_agents
  doc.xpath("/mods:mods/mods:originInfo/mods:agent", NAMESPACE).filter_map do |node|
    entry = name_entry(node)
    entry.merge(event_type: attr_value(node.parent, "eventType")) if entry[:name]
  end
end

#permanent_urlObject



594
595
596
597
# File 'lib/neu/mods/projection.rb', line 594

def permanent_url
  node = doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE)
  node && clean(node.text)
end

#permanent_url_display_labelObject

The handle identifier carries @displayLabel="Permanent URL" in Atlas's own MODS template, so the header a reader sees is one the record states rather than one a decorator invents. No href companion: the value is the URL.



603
604
605
# File 'lib/neu/mods/projection.rb', line 603

def permanent_url_display_label
  attr_value(doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE), "displayLabel")
end

#personal_name_subjectsObject

Name subjects compose through the same display-value port as #names, so one person reads the same whether they authored the work or are its subject.



226
# File 'lib/neu/mods/projection.rb', line 226

def personal_name_subjects = name_subjects("personal")

#physical_description_notesObject

A note about the object rather than about the work -- "Scanned at 600 dpi" belongs beside the extent, not beside a content note. Projected as plain strings like its physicalDescription siblings: #notes keeps @type because the type changes what a top-level note means, and nothing here turns on it.



478
# File 'lib/neu/mods/projection.rb', line 478

def physical_description_notes = labeled_texts_at("/mods:mods/mods:physicalDescription/mods:note", from: "..")

#place_of_publicationObject



418
419
420
421
422
423
424
425
426
# File 'lib/neu/mods/projection.rb', line 418

def place_of_publication
  doc.xpath("/mods:mods/mods:originInfo/mods:place", NAMESPACE).filter_map do |place|
    value = place_term_value(place)
    next unless value

    { value: value, **origin_qualifiers_of(place.parent),
      date_elements: origin_date_elements(place.parent) }
  end
end

#place_term_value(place) ⇒ Object



440
441
442
443
444
445
446
447
448
# File 'lib/neu/mods/projection.rb', line 440

def place_term_value(place)
  text = clean(place.at_xpath("mods:placeTerm[@type='text']", NAMESPACE)&.text)
  return text if text

  code = place.at_xpath("mods:placeTerm", NAMESPACE)
  return nil if attr_value(code, "authority") == MARC_COUNTRY_AUTHORITY

  clean(code&.text)
end

#plain_titleObject

Composed display title (the former Atlas MODSDecoration#plain_title), driven off the scoped primary title.



45
46
47
# File 'lib/neu/mods/projection.rb', line 45

def plain_title
  Projection.compose_title(title_parts)
end

#preserved_namesObject

Names the editable form does NOT manage (authority-bearing or non-Creator) -- for read-only display ("these exist; edit via the XML tab"). Composed display string + roles, like #names but filtered to the preserved set.



336
337
338
339
340
# File 'lib/neu/mods/projection.rb', line 336

def preserved_names
  doc.xpath("/mods:mods/mods:name", NAMESPACE)
     .reject { |node| editable_creator_name?(node) }
     .map { |node| name_entry(node) }
end

#publication_informationObject

originInfo repeats, and so do publisher and edition within one. Cerberus's IPTC ingest writes the publisher from the IPTC Source field on every batch, so this element was being written into the preservation XML and then read back by nothing. place, edition, issuance or frequency inside it, so each of these takes its header off the parent block.



400
# File 'lib/neu/mods/projection.rb', line 400

def publication_information = origin_texts_at("mods:publisher")

#record_infoObject

Who catalogued this record, to what standard, and when. It describes the CATALOGUING rather than the resource, which is why it is one value and why a consumer is unlikely to want it beside Publisher -- but dropping a preservation repository's provenance statement on read is wrong on its face, so it is projected and the display question is the consumer's.



777
778
779
780
781
782
783
# File 'lib/neu/mods/projection.rb', line 777

def record_info
  node = doc.at_xpath("/mods:mods/mods:recordInfo", NAMESPACE)
  return nil unless node

  entry = RECORD_INFO_PARTS.transform_values { |xpath| child_text(node, xpath) }
  entry if entry.values.any?
end

#reformatting_qualityObject



469
470
471
# File 'lib/neu/mods/projection.rb', line 469

def reformatting_quality
  labeled_texts_at("/mods:mods/mods:physicalDescription/mods:reformattingQuality", from: "..")
end

Every other relatedItem, keeping its @type. MODS also defines constituent, otherFormat, original, preceding, succeeding, isReferencedBy and reviewOf, and a record carrying any of them projected nothing at all. The type rides along because "the print edition" and "reviewed in" are not the same relationship, and no consumer can recover which it holds from the title alone. An untyped relatedItem lands here with a nil type.



565
566
567
568
569
570
571
572
573
# File 'lib/neu/mods/projection.rb', line 565

def related_items
  doc.xpath("/mods:mods/mods:relatedItem", NAMESPACE).filter_map do |node|
    type = clean(node["type"])
    next if NAMED_RELATED_ITEM_TYPES.include?(type)

    title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
    { type: type, title: title, **qualifiers_of(node) } if title
  end
end


530
# File 'lib/neu/mods/projection.rb', line 530

def related_series = related_item_titles("series")

#resource_typeObject

MODS repeats typeOfResource, and repeats physicalDescription (and form and extent within one), so all four are :many. A record that is both text and a still image used to project as text alone.



373
# File 'lib/neu/mods/projection.rb', line 373

def resource_type = labeled_texts_at("/mods:mods/mods:typeOfResource")

#restriction_on_accessObject



128
# File 'lib/neu/mods/projection.rb', line 128

def restriction_on_access = access_conditions_of_type("restriction on access")

#restriction_on_access_display_labelObject



142
143
144
# File 'lib/neu/mods/projection.rb', line 142

def restriction_on_access_display_label
  first_attr(access_condition_nodes("restriction on access"), "displayLabel")
end

#restriction_on_access_hrefObject



146
# File 'lib/neu/mods/projection.rb', line 146

def restriction_on_access_href = first_href(access_condition_nodes("restriction on access"))

#subject_headingsObject

Every top-level as ONE heading, its parts in document order. A pre-coordinated heading like "Salt marshes--Massachusetts--20th century" is a single statement, and the per-axis fields below cannot say which parts belonged together: they pool every topic on the record into one list, so a fragment of a heading and a whole heading read alike.

The parts stay, because the Advanced edit form and a consumer wanting one step of a heading both ask for them. heading: is the same parts joined, and it is here rather than left to each caller because the composed heading is now BOTH the string a display renders and the string a browse index holds. Two callers joining independently is how a displayed value and an indexed value drift apart, and this join makes them the same string rather than two that happen to match.

axis: names the MODS element the heading's MAIN term came from, which is the first child that carries heading text. A consumer cannot derive it from the parts -- they are bare strings -- and it is the fact that says which browse a heading belongs to: "Salt marshes -- Massachusetts" is a topic heading with a place subdivision, not a place. Reported as the element name, so the browse vocabulary stays with the consumer.



202
203
204
205
206
207
208
209
210
211
# File 'lib/neu/mods/projection.rb', line 202

def subject_headings
  doc.xpath("/mods:mods/mods:subject", NAMESPACE).filter_map do |node|
    axis_node = heading_axis_node(node)
    parts = subject_heading_parts(node)
    next if axis_node.nil? || parts.empty?

    { parts: parts, heading: parts.join(HEADING_SEPARATOR), axis: heading_axis(axis_node),
      **authority_of(axis_node), **qualifiers_of(node) }
  end
end

#table_of_contentsObject

Read with its line breaks intact. A legacy contents list separates its entries by newline, and the whitespace collapse every other field wants ran the entries together into one line -- there the break IS the structure, not stray formatting. A "--"-separated list is unaffected.



462
463
464
465
466
467
# File 'lib/neu/mods/projection.rb', line 462

def table_of_contents
  doc.xpath("/mods:mods/mods:tableOfContents", NAMESPACE).filter_map do |node|
    lines = NEU::MODS.canonical_lines(node.text)
    labeled(lines, node) unless lines.empty?
  end
end

#target_audienceObject

Who the resource is for. The last displayed top-level element with no projection at all: a record naming its audience said so to nobody.



391
# File 'lib/neu/mods/projection.rb', line 391

def target_audience = labeled_texts_at("/mods:mods/mods:targetAudience")

#temporal_subjectsObject



221
# File 'lib/neu/mods/projection.rb', line 221

def temporal_subjects = texts_at("/mods:mods/mods:subject/mods:temporal")

#title_partsObject

Structured primary-title parts, byte-faithful to the document. nil for an absent part (the Cerberus form treats nil as "not present"); to_h coerces to "" for the Atlas main_title.

Faithful on purpose: this is what Cerberus pre-fills its edit forms from (MODSFields for the Metadata tab, load_advanced! for the Advanced tab), and MODSMerge writes back whatever the form posts. Normalising here would rewrite the curator's characters in the preservation XML on the next save. #access_title_parts is the normalised surface.



29
30
31
# File 'lib/neu/mods/projection.rb', line 29

def title_parts
  title_parts_of(primary_title_info)
end

#title_subjectsObject

A subject that is a work has a nonSort, a subTitle and part numbers like any other titleInfo, so it composes through the same port as the main title rather than taking titleInfo/title alone.



244
245
246
# File 'lib/neu/mods/projection.rb', line 244

def title_subjects
  doc.xpath("/mods:mods/mods:subject/mods:titleInfo", NAMESPACE).filter_map { |node| composed_title_of(node) }
end

#to_hObject

The complete read projection, keyed to Atlas's Metadata::MODS attribute names -- a drop-in source for convert_xml_to_json.



941
942
943
# File 'lib/neu/mods/projection.rb', line 941

def to_h
  FIELDS.keys.to_h { |field| [field, public_send(field)] }
end

#topical_subjectsObject

Every under any top-level (the access-copy projection, equivalent to Atlas's extract_topical_subjects).



215
# File 'lib/neu/mods/projection.rb', line 215

def topical_subjects = texts_at("/mods:mods/mods:subject/mods:topic")

#translated_titleObject



40
# File 'lib/neu/mods/projection.rb', line 40

def translated_title = variant_titles("translated")

#uniform_titleObject



39
# File 'lib/neu/mods/projection.rb', line 39

def uniform_title = variant_titles("uniform")

#use_and_reproductionObject

The two @type values MODS defines, projected apart. Collapsing them into one value presented an access restriction to a reader as a licence, which is the one defect in this area that misinforms someone about their rights rather than merely hiding a field.



127
# File 'lib/neu/mods/projection.rb', line 127

def use_and_reproduction = access_conditions_of_type("use and reproduction")

#use_and_reproduction_display_labelObject



136
137
138
# File 'lib/neu/mods/projection.rb', line 136

def use_and_reproduction_display_label
  first_attr(access_condition_nodes("use and reproduction"), "displayLabel")
end

#use_and_reproduction_hrefObject



140
# File 'lib/neu/mods/projection.rb', line 140

def use_and_reproduction_href = first_href(access_condition_nodes("use and reproduction"))