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
-
.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.
-
.join_non_sort(non_sort, title) ⇒ Object
MODS says a nonSort carries whatever separator it needs, so the historical composition simply concatenated.
Instance Method Summary collapse
-
#abstract ⇒ Object
--- Abstract / access ---------------------------------------------------.
- #access_condition ⇒ Object
-
#access_title_parts ⇒ Object
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.
-
#date_created ⇒ Object
Parsed dateCreated, or nil if no originInfo/dateCreated, or "" if present but unparseable (mirrors Atlas's safe_date_parse rescue).
- #digital_origin ⇒ Object
- #editable_corporate_creators ⇒ Object
-
#editable_personal_creators ⇒ Object
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.
- #extent ⇒ Object
- #format ⇒ Object
- #genres ⇒ Object
- #identifiers ⇒ Object
-
#keywords ⇒ Object
The editable free-text keyword set (Cerberus simple form): topics under the attribute-free keyword subjects only.
-
#languages ⇒ Object
--- Scalars / simple arrays --------------------------------------------.
-
#names ⇒ Object
All top-level names as { name:, role: }.
- #permanent_url ⇒ Object
-
#plain_title ⇒ Object
Composed display title (the former Atlas MODSDecoration#plain_title), driven off the scoped primary title.
-
#preserved_names ⇒ Object
Names the editable form does NOT manage (authority-bearing or non-Creator) -- for read-only display ("these exist; edit via the XML tab").
- #related_series ⇒ Object
- #resource_type ⇒ Object
-
#title_parts ⇒ Object
Structured primary-title parts, byte-faithful to the document.
-
#to_h ⇒ Object
The complete read projection, keyed to Atlas's Metadata::MODS attribute names -- a drop-in source for
convert_xml_to_json. -
#topical_subjects ⇒ Object
Every
under any top-level (the access-copy projection, equivalent to Atlas's extract_topical_subjects).
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.
50 51 52 53 54 55 56 |
# File 'lib/neu/mods/projection.rb', line 50 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.
75 76 77 78 79 80 81 |
# File 'lib/neu/mods/projection.rb', line 75 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
#abstract ⇒ Object
--- Abstract / access ---------------------------------------------------
85 86 87 |
# File 'lib/neu/mods/projection.rb', line 85 def abstract join_paragraphs(abstract_nodes) end |
#access_condition ⇒ Object
89 90 91 |
# File 'lib/neu/mods/projection.rb', line 89 def access_condition join_paragraphs(doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)) end |
#access_title_parts ⇒ Object
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.
218 219 220 |
# File 'lib/neu/mods/projection.rb', line 218 def access_title_parts title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) } end |
#date_created ⇒ Object
Parsed dateCreated, or nil if no originInfo/dateCreated, or "" if present but unparseable (mirrors Atlas's safe_date_parse rescue).
176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/neu/mods/projection.rb', line 176 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_origin ⇒ Object
154 |
# File 'lib/neu/mods/projection.rb', line 154 def digital_origin = text_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin") |
#editable_corporate_creators ⇒ Object
128 129 130 |
# File 'lib/neu/mods/projection.rb', line 128 def editable_corporate_creators editable_creator_nodes("corporate").map { |node| { name: clean_part(non_date_parts_joined(node)) } } end |
#editable_personal_creators ⇒ Object
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.
122 123 124 125 126 |
# File 'lib/neu/mods/projection.rb', line 122 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 |
#extent ⇒ Object
153 |
# File 'lib/neu/mods/projection.rb', line 153 def extent = text_at("/mods:mods/mods:physicalDescription/mods:extent") |
#format ⇒ Object
152 |
# File 'lib/neu/mods/projection.rb', line 152 def format = text_at("/mods:mods/mods:physicalDescription/mods:form") |
#genres ⇒ Object
156 157 158 |
# File 'lib/neu/mods/projection.rb', line 156 def genres doc.xpath("/mods:mods/mods:genre", NAMESPACE).map { |g| clean(g.text) } end |
#identifiers ⇒ Object
165 166 167 |
# File 'lib/neu/mods/projection.rb', line 165 def identifiers doc.xpath("/mods:mods/mods:identifier", NAMESPACE).map { |i| clean(i.text) } end |
#keywords ⇒ Object
The editable free-text keyword set (Cerberus simple form): topics under the attribute-free keyword subjects only.
97 98 99 |
# File 'lib/neu/mods/projection.rb', line 97 def keywords keyword_subjects.flat_map { |s| s.xpath("mods:topic", NAMESPACE).map { |t| t.text.strip } } end |
#languages ⇒ Object
--- Scalars / simple arrays --------------------------------------------
143 144 145 146 147 148 149 |
# File 'lib/neu/mods/projection.rb', line 143 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 |
#names ⇒ Object
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).
113 114 115 116 117 |
# File 'lib/neu/mods/projection.rb', line 113 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_url ⇒ Object
169 170 171 172 |
# File 'lib/neu/mods/projection.rb', line 169 def permanent_url node = doc.at_xpath("/mods:mods/mods:identifier[@type='hdl']", NAMESPACE) node && clean(node.text) end |
#plain_title ⇒ Object
Composed display title (the former Atlas MODSDecoration#plain_title), driven off the scoped primary title.
40 41 42 |
# File 'lib/neu/mods/projection.rb', line 40 def plain_title Projection.compose_title(title_parts) end |
#preserved_names ⇒ Object
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.
135 136 137 138 139 |
# File 'lib/neu/mods/projection.rb', line 135 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 |
#related_series ⇒ Object
160 161 162 163 |
# File 'lib/neu/mods/projection.rb', line 160 def doc.xpath("/mods:mods/mods:relatedItem[@type='series']/mods:titleInfo/mods:title", NAMESPACE) .map { |t| clean(t.text) } end |
#resource_type ⇒ Object
151 |
# File 'lib/neu/mods/projection.rb', line 151 def resource_type = text_at("/mods:mods/mods:typeOfResource") |
#title_parts ⇒ Object
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.
27 28 29 30 31 32 33 34 35 36 |
# File 'lib/neu/mods/projection.rb', line 27 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_h ⇒ Object
The complete read projection, keyed to Atlas's Metadata::MODS attribute
names -- a drop-in source for convert_xml_to_json.
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/neu/mods/projection.rb', line 194 def to_h { main_title: access_title_parts, 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: , topical_subjects: topical_subjects, identifiers: identifiers, permanent_url: permanent_url, access_condition: access_condition } end |
#topical_subjects ⇒ Object
Every
103 104 105 |
# File 'lib/neu/mods/projection.rb', line 103 def topical_subjects doc.xpath("/mods:mods/mods:subject/mods:topic", NAMESPACE).map { |t| clean(t.text) } end |