neu-mods 0.1.0 → 0.2.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: ab1cb07bf4122e98017ded99c790824865f68fb8ccdfdcbb3b5fc8e7d72cbf55
4
- data.tar.gz: 6d3b467da6fde096e0ea26f9e76c447ea24c563c6cc5fd1a47e06b8f18d72aca
3
+ metadata.gz: fb4c30744c189d3e0b4bfc0f84bb75175685bcb142dd6dc35dc55cfbcbdaf0a2
4
+ data.tar.gz: a5cd73f764edf2da97e05787788124bed168c9dba8caf997307fc53591f05661
5
5
  SHA512:
6
- metadata.gz: 4c9b5a6006cf0ab3faced2d60f49f703ab482f28f2208d39c5225bd7af769c4f6bba634b0808d83d3b5ac3f2210efca07f8b1ad37976e0ed17d1175d666fcccf
7
- data.tar.gz: 2c92060d9faddce439bb9b462982fbb186f6d792f5fc86cfa9702a7cf1c2118c06a3236a5e0875c2499e5593083f531a5668c841696168d330c6527728ca56a7
6
+ metadata.gz: 785e3c7485886be0fd7946b61463e2e2b66319cf4c4f30980b6e4b2b0b8ed2ae4348be41a48caa9fbad536901a89e9c73d4d4fd005283bc2cc0dc9c9102d6ff6
7
+ data.tar.gz: 7a331ed220702dbab23bd6d16637f33a3da2f431a17939647ee7d1fd21898b9cf76a4594b26300eb01f8c9ad1b50d0cabcd298cdbff9c363a01a7f7bba6229a7
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.2.0
data/README.md CHANGED
@@ -34,12 +34,33 @@ doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the acces
34
34
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
35
35
  doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
36
36
 
37
+ # Pure title composition (no document needed) — for callers that already hold
38
+ # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
39
+ NEU::MODS.compose_title(non_sort: "", title: "What's New",
40
+ part_name: "How We Respond to Disaster", part_number: "Episode 1")
41
+ # => "What's New - How We Respond to Disaster, Episode 1" (== doc.plain_title)
42
+
37
43
  # Selectors (live nodes — for editing)
38
44
  node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
39
45
  node.content = "New Title" unless NEU::MODS.whitespace_equivalent?(node.text, "New Title")
40
46
  doc.to_xml
47
+
48
+ # Editable creators (for an "advanced metadata" form): structured read,
49
+ # node selection (for replace-on-save), and structure-aware build.
50
+ doc.editable_personal_creators # => [{ given:, family: }] (plain, Creator role)
51
+ doc.editable_corporate_creators # => [{ name: }]
52
+ doc.preserved_names # => [{ name:, role: }] (authority-bearing / non-Creator — read-only)
53
+ doc.editable_creator_nodes("personal") # => live <name> nodes to replace
54
+ doc.build_personal_name(given: "Jenny", family: "Smith") # => a plain personal <name> node
55
+ doc.build_corporate_name(name: "Northeastern University") # => a plain corporate <name> node
41
56
  ```
42
57
 
58
+ The "editable creator" set is plain names — **no `@authority`/`@authorityURI`/
59
+ `@valueURI`** — with a **Creator** role; everything else (authority-controlled or
60
+ other-role names) is `preserved_names`, shown read-only. This mirrors the
61
+ keyword-subject curated-vs-editable split. `build_*_name`'s `role:` defaults to
62
+ `"Creator"` but is parameterised, so a later role-selectable form is non-breaking.
63
+
43
64
  ## Two normalizers, two jobs
44
65
 
45
66
  - `NEU::MODS.whitespace_equivalent?` / `.canonical_ws` — the **no-op guard**: did an
@@ -31,13 +31,21 @@ module NEU
31
31
  # Composed display title (the former Atlas MODSDecoration#plain_title), driven
32
32
  # off the scoped primary title.
33
33
  def plain_title
34
- p = title_parts
35
- return "" if blank?(p[:title])
34
+ Projection.compose_title(title_parts)
35
+ end
36
36
 
37
- "#{p[:non_sort]}#{p[:title]}" +
38
- prefix(": ", p[:subtitle]) +
39
- prefix(" - ", p[:part_name]) +
40
- prefix(", ", p[:part_number])
37
+ # Pure title composition over a parts hash, factored out of #plain_title so
38
+ # callers that already hold the parts -- e.g. Atlas's access-copy model --
39
+ # can compose the display title WITHOUT re-parsing XML on the read path
40
+ # (reaching for Nokogiri in a decorator is the smell this avoids). Keys:
41
+ # :non_sort :title :subtitle :part_name :part_number (nil or "" for absent).
42
+ # Returns "" when there is no title. Exposed as NEU::MODS.compose_title.
43
+ def self.compose_title(parts)
44
+ return "" if parts[:title].to_s.strip.empty?
45
+
46
+ optional = { ": " => parts[:subtitle], " - " => parts[:part_name], ", " => parts[:part_number] }
47
+ suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
48
+ "#{parts[:non_sort]}#{parts[:title]}#{suffix}"
41
49
  end
42
50
 
43
51
  # --- Abstract / access ---------------------------------------------------
@@ -76,6 +84,28 @@ module NEU
76
84
  end
77
85
  end
78
86
 
87
+ # Editable (depositor-managed) creators: the plain names (no authority
88
+ # markers) with a Creator role, as STRUCTURED parts for form pre-fill --
89
+ # distinct from #names, which composes display strings for the access copy.
90
+ def editable_personal_creators
91
+ editable_creator_nodes("personal").map do |node|
92
+ { given: clean_part(joined_parts(node, "given")), family: clean_part(joined_parts(node, "family")) }
93
+ end
94
+ end
95
+
96
+ def editable_corporate_creators
97
+ editable_creator_nodes("corporate").map { |node| { name: clean_part(non_date_parts_joined(node)) } }
98
+ end
99
+
100
+ # Names the editable form does NOT manage (authority-bearing or non-Creator)
101
+ # -- for read-only display ("these exist; edit via the XML tab"). Composed
102
+ # display string + role, like #names but filtered to the preserved set.
103
+ def preserved_names
104
+ doc.xpath("/mods:mods/mods:name", NAMESPACE)
105
+ .reject { |node| editable_creator_name?(node) }
106
+ .map { |node| { name: name_display_value_w_date(node), role: name_role(node) } }
107
+ end
108
+
79
109
  # --- Scalars / simple arrays --------------------------------------------
80
110
 
81
111
  def languages
@@ -176,12 +206,10 @@ module NEU
176
206
  v.empty? ? nil : v
177
207
  end
178
208
 
179
- def blank?(str)
180
- str.nil? || str.strip.empty?
181
- end
182
-
183
- def prefix(sep, val)
184
- blank?(val) ? "" : "#{sep}#{val}"
209
+ # canonical_ws keeping "" for blank -- for structured form-field values
210
+ # (an empty given/family/org renders as an empty input, not a dropped key).
211
+ def clean_part(str)
212
+ NEU::MODS.canonical_ws(str)
185
213
  end
186
214
 
187
215
  def join_paragraphs(nodes)
@@ -39,6 +39,39 @@ module NEU
39
39
  node
40
40
  end
41
41
 
42
+ # The "editable creator" <name> nodes of a given @type ("personal" /
43
+ # "corporate") that the Advanced form manages: plain names (no authority
44
+ # markers) with a Creator role. The write-path counterpart to the
45
+ # editable_*_creators projections; everything else (authority-bearing or
46
+ # non-Creator) is curated and left untouched. Mirrors keyword_subjects.
47
+ def editable_creator_nodes(type)
48
+ doc.xpath("/mods:mods/mods:name[@type='#{type}']", NAMESPACE)
49
+ .select { |n| editable_creator_name?(n) }
50
+ end
51
+
52
+ # Build a plain personal-creator <name> node: namePart[@type=given]/[family]
53
+ # + a text roleTerm. No authority/valueURI (the editable set). `role` is
54
+ # parameterised (default "Creator") so a later role-selectable form is a
55
+ # non-breaking change.
56
+ def build_personal_name(given:, family:, role: "Creator")
57
+ name = build_node("name")
58
+ name["type"] = "personal"
59
+ name.add_child(name_part(given, "given")) unless given.to_s.strip.empty?
60
+ name.add_child(name_part(family, "family")) unless family.to_s.strip.empty?
61
+ name.add_child(role_node(role))
62
+ name
63
+ end
64
+
65
+ # Build a plain corporate-creator <name> node: a single namePart + a text
66
+ # roleTerm. No authority/valueURI.
67
+ def build_corporate_name(name:, role: "Creator")
68
+ node = build_node("name")
69
+ node["type"] = "corporate"
70
+ node.add_child(name_part(name)) unless name.to_s.strip.empty?
71
+ node.add_child(role_node(role))
72
+ node
73
+ end
74
+
42
75
  private
43
76
 
44
77
  def keyword_subject?(subject)
@@ -47,6 +80,28 @@ module NEU
47
80
  topics = subject.element_children
48
81
  topics.any? && topics.all? { |c| c.name == "topic" }
49
82
  end
83
+
84
+ # A name is "editable" (depositor-managed) when it carries no authority
85
+ # markers and resolves to a Creator role. Shared by editable_creator_nodes
86
+ # (write/select) and the editable_*_creators projections (read).
87
+ def editable_creator_name?(node)
88
+ %w[authority authorityURI valueURI].none? { |attr| node[attr] } &&
89
+ name_role(node) == "Creator"
90
+ end
91
+
92
+ def name_part(text, type = nil)
93
+ np = build_node("namePart", text.to_s.strip)
94
+ np["type"] = type if type
95
+ np
96
+ end
97
+
98
+ def role_node(role)
99
+ role_el = build_node("role")
100
+ term = build_node("roleTerm", role)
101
+ term["type"] = "text"
102
+ role_el.add_child(term)
103
+ role_el
104
+ end
50
105
  end
51
106
  end
52
107
  end
data/lib/neu-mods.rb CHANGED
@@ -33,5 +33,10 @@ module NEU
33
33
  # Curator-freetext normalization for the access copy (see TextNormalizer).
34
34
  def normalize(str) = TextNormalizer.normalize(str)
35
35
  def normalize_paragraphs(str) = TextNormalizer.normalize_paragraphs(str)
36
+
37
+ # Pure display-title composition over a primary-title parts hash (see
38
+ # Projection.compose_title). Lets a caller that already holds the parts --
39
+ # e.g. Atlas's access-copy model -- compose the title without parsing XML.
40
+ def compose_title(parts) = Projection.compose_title(parts)
36
41
  end
37
42
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
@@ -14,42 +14,42 @@ dependencies:
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
@@ -83,16 +83,16 @@ require_paths:
83
83
  - lib
84
84
  required_ruby_version: !ruby/object:Gem::Requirement
85
85
  requirements:
86
- - - '>='
86
+ - - ">="
87
87
  - !ruby/object:Gem::Version
88
88
  version: '3.0'
89
89
  required_rubygems_version: !ruby/object:Gem::Requirement
90
90
  requirements:
91
- - - '>='
91
+ - - ">="
92
92
  - !ruby/object:Gem::Version
93
93
  version: '0'
94
94
  requirements: []
95
- rubygems_version: 3.0.9
95
+ rubygems_version: 3.4.10
96
96
  signing_key:
97
97
  specification_version: 4
98
98
  summary: Northeastern-flavored MODS XML projection + selection for the DRS.