neu-mods 0.1.1 → 0.3.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: 4271827888541fa8ae2c61c52a6d3d73b79c7ee3ae3ae114ba391e3aae5e16a5
4
- data.tar.gz: a59905f08b0092b40ade775cf95d846941d210b922aca213b5f1ee7b70ad864d
3
+ metadata.gz: ebf2bf82cd0b9c0e5593307ecc9a986cefcb16f4035a5004f233bdd7f9d4d8ea
4
+ data.tar.gz: 7087e35df1b2f34430a53acfa49e9c97be12dd9b8bdc36b3ffa69d05e98189e0
5
5
  SHA512:
6
- metadata.gz: 25775e1c92de8bbc6f730980b1cbf78a5f0c78668ab901fb3c84f62e6ab15986bab6270ef37f511dfac367caa275d901b0cde87490a5b7d6f3338fd75d99415a
7
- data.tar.gz: 61726ef704b127856f0fc1f2febe3b35681ffe48b7822a7cbbe1d30a4c8b094071bd681c57a1b3639c09f0ad1f085d4d3d543608fe86a7d2c558fa84ec913dc2
6
+ metadata.gz: 63b83e54cdf64af06856fe1de2cb3763cc36a1f1f0a73403fb7836fac9e910dcd2f0ebd0a4fd5fb928b7714ddec20ad281ac1cc42b47a18fcda21cbc8ad8589f
7
+ data.tar.gz: 3d6ecb5f48c1c4cff669add4adc7bfa2a90bf29045b17bb1b1047a58f02820c85fbe303ec0c0e7665f7045e27d03531fe32759f6bc023d91032038baf4466c65
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.1
1
+ 0.3.0
data/README.md CHANGED
@@ -44,8 +44,23 @@ NEU::MODS.compose_title(non_sort: "", title: "What's New",
44
44
  node = doc.primary_title_info.at_xpath("mods:title", NEU::MODS::NAMESPACE)
45
45
  node.content = "New Title" unless NEU::MODS.whitespace_equivalent?(node.text, "New Title")
46
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
47
56
  ```
48
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
+
49
64
  ## Two normalizers, two jobs
50
65
 
51
66
  - `NEU::MODS.whitespace_equivalent?` / `.canonical_ws` — the **no-op guard**: did an
@@ -45,7 +45,32 @@ module NEU
45
45
 
46
46
  optional = { ": " => parts[:subtitle], " - " => parts[:part_name], ", " => parts[:part_number] }
47
47
  suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
48
- "#{parts[:non_sort]}#{parts[:title]}#{suffix}"
48
+ "#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
49
+ end
50
+
51
+ # Characters that bind a nonSort to the word after it. An elided article
52
+ # takes no space -- "L'Etranger", not "L' Etranger" -- and the same holds
53
+ # for a hyphenated prefix. U+2019 is the curly apostrophe, escaped rather
54
+ # than literal to keep lib/ pure ASCII (see the source-purity spec).
55
+ NON_SORT_BINDING = ["'", "\u2019", "-"].freeze
56
+
57
+ # MODS says a nonSort carries whatever separator it needs, so the historical
58
+ # composition simply concatenated. That only holds while the authored
59
+ # trailing space survives, and it does not: #child_text canonicalizes
60
+ # whitespace on read, so `<nonSort>The </nonSort>` arrives here as "The" and
61
+ # the title came out as "TheHobbit". Composing the separator instead makes
62
+ # the output right whether or not the source kept one -- which matters,
63
+ # because an invisible trailing space is not something a curator, a
64
+ # hand-edit or a third-party producer can be relied on to preserve.
65
+ #
66
+ # Callers that DO pass the space (Atlas's access-copy model) are unaffected:
67
+ # a nonSort already ending in whitespace is joined as-is.
68
+ def self.join_non_sort(non_sort, title)
69
+ prefix = non_sort.to_s
70
+ return title.to_s if prefix.empty?
71
+ return "#{prefix}#{title}" if prefix.end_with?(" ") || prefix.end_with?(*NON_SORT_BINDING)
72
+
73
+ "#{prefix} #{title}"
49
74
  end
50
75
 
51
76
  # --- Abstract / access ---------------------------------------------------
@@ -84,6 +109,28 @@ module NEU
84
109
  end
85
110
  end
86
111
 
112
+ # Editable (depositor-managed) creators: the plain names (no authority
113
+ # markers) with a Creator role, as STRUCTURED parts for form pre-fill --
114
+ # distinct from #names, which composes display strings for the access copy.
115
+ def editable_personal_creators
116
+ editable_creator_nodes("personal").map do |node|
117
+ { given: clean_part(joined_parts(node, "given")), family: clean_part(joined_parts(node, "family")) }
118
+ end
119
+ end
120
+
121
+ def editable_corporate_creators
122
+ editable_creator_nodes("corporate").map { |node| { name: clean_part(non_date_parts_joined(node)) } }
123
+ end
124
+
125
+ # Names the editable form does NOT manage (authority-bearing or non-Creator)
126
+ # -- for read-only display ("these exist; edit via the XML tab"). Composed
127
+ # display string + role, like #names but filtered to the preserved set.
128
+ def preserved_names
129
+ doc.xpath("/mods:mods/mods:name", NAMESPACE)
130
+ .reject { |node| editable_creator_name?(node) }
131
+ .map { |node| { name: name_display_value_w_date(node), role: name_role(node) } }
132
+ end
133
+
87
134
  # --- Scalars / simple arrays --------------------------------------------
88
135
 
89
136
  def languages
@@ -184,6 +231,12 @@ module NEU
184
231
  v.empty? ? nil : v
185
232
  end
186
233
 
234
+ # canonical_ws keeping "" for blank -- for structured form-field values
235
+ # (an empty given/family/org renders as an empty input, not a dropped key).
236
+ def clean_part(str)
237
+ NEU::MODS.canonical_ws(str)
238
+ end
239
+
187
240
  def join_paragraphs(nodes)
188
241
  nodes.map { |n| NEU::MODS.normalize_paragraphs(n.text) }.reject(&:empty?).join("\n\n")
189
242
  end
@@ -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
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.1.1
4
+ version: 0.3.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-06-09 00:00:00.000000000 Z
11
+ date: 2026-08-03 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
@@ -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.