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 [].

Constant Summary collapse

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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.compose_title(parts) ⇒ Object

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.



43
44
45
46
47
48
49
# File 'lib/neu/mods/projection.rb', line 43

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

  optional = { ": " => parts[:subtitle], " - " => parts[:part_name], ", " => parts[:part_number] }
  suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
  "#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
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.



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

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

#abstractObject

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



78
79
80
# File 'lib/neu/mods/projection.rb', line 78

def abstract
  join_paragraphs(abstract_nodes)
end

#access_conditionObject



82
83
84
# File 'lib/neu/mods/projection.rb', line 82

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

#date_createdObject

Parsed dateCreated, or nil if no originInfo/dateCreated, or "" if present but unparseable (mirrors Atlas's safe_date_parse rescue).



169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/neu/mods/projection.rb', line 169

def date_created
  node = doc.at_xpath("/mods:mods/mods:originInfo/mods:dateCreated", NAMESPACE)
  return nil unless node

  str = NEU::MODS.canonical_ws(node.text)
  return nil if str.empty?

  begin
    DateTime.parse(str)
  rescue Date::Error
    ""
  end
end

#digital_originObject



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

def digital_origin = text_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")

#editable_corporate_creatorsObject



121
122
123
# File 'lib/neu/mods/projection.rb', line 121

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.



115
116
117
118
119
# File 'lib/neu/mods/projection.rb', line 115

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

#extentObject



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

def extent = text_at("/mods:mods/mods:physicalDescription/mods:extent")

#formatObject



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

def format = text_at("/mods:mods/mods:physicalDescription/mods:form")

#genresObject



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

def genres
  doc.xpath("/mods:mods/mods:genre", NAMESPACE).map { |g| clean(g.text) }
end

#identifiersObject



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

def identifiers
  doc.xpath("/mods:mods/mods:identifier", NAMESPACE).map { |i| clean(i.text) }
end

#keywordsObject

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



90
91
92
# File 'lib/neu/mods/projection.rb', line 90

def keywords
  keyword_subjects.flat_map { |s| s.xpath("mods:topic", NAMESPACE).map { |t| t.text.strip } }
end

#languagesObject

--- Scalars / simple arrays --------------------------------------------



136
137
138
139
140
141
142
# File 'lib/neu/mods/projection.rb', line 136

def languages
  doc.xpath("/mods:mods/mods:language", NAMESPACE).map do |lang|
    term = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE) ||
           lang.at_xpath("mods:languageTerm", NAMESPACE)
    clean(term&.text)
  end.compact
end

#namesObject

All top-level names as { name:, role: }. name reproduces the mods gem's display_value_w_date (including its quirks -- faithfully, so existing Solr/ display output is preserved). role prefers the type="text" roleTerm, falling back to the raw code (NOT MARC-relator-translated -- see README).



106
107
108
109
110
# File 'lib/neu/mods/projection.rb', line 106

def names
  doc.xpath("/mods:mods/mods:name", NAMESPACE).map do |node|
    { name: name_display_value_w_date(node), role: name_role(node) }
  end
end

#permanent_urlObject



162
163
164
165
# File 'lib/neu/mods/projection.rb', line 162

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

#plain_titleObject

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



33
34
35
# File 'lib/neu/mods/projection.rb', line 33

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 + role, like #names but filtered to the preserved set.



128
129
130
131
132
# File 'lib/neu/mods/projection.rb', line 128

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


153
154
155
156
# File 'lib/neu/mods/projection.rb', line 153

def related_series
  doc.xpath("/mods:mods/mods:relatedItem[@type='series']/mods:titleInfo/mods:title", NAMESPACE)
     .map { |t| clean(t.text) }
end

#resource_typeObject



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

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

#title_partsObject

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



20
21
22
23
24
25
26
27
28
29
# File 'lib/neu/mods/projection.rb', line 20

def title_parts
  ti = primary_title_info
  {
    non_sort: child_text(ti, "mods:nonSort"),
    subtitle: child_text(ti, "mods:subTitle"),
    title: child_text(ti, "mods:title"),
    part_name: child_text(ti, "mods:partName"),
    part_number: child_text(ti, "mods:partNumber")
  }
end

#to_hObject

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



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/neu/mods/projection.rb', line 187

def to_h
  {
    main_title: title_parts.transform_values(&:to_s),
    names: names,
    languages: languages,
    date_created: date_created,
    resource_type: resource_type,
    genres: genres,
    format: format,
    extent: extent,
    digital_origin: digital_origin,
    abstract: abstract,
    related_series: related_series,
    topical_subjects: topical_subjects,
    identifiers: identifiers,
    permanent_url: permanent_url,
    access_condition: access_condition
  }
end

#topical_subjectsObject

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



96
97
98
# File 'lib/neu/mods/projection.rb', line 96

def topical_subjects
  doc.xpath("/mods:mods/mods:subject/mods:topic", NAMESPACE).map { |t| clean(t.text) }
end