neu-mods 0.3.0 → 0.4.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: ebf2bf82cd0b9c0e5593307ecc9a986cefcb16f4035a5004f233bdd7f9d4d8ea
4
- data.tar.gz: 7087e35df1b2f34430a53acfa49e9c97be12dd9b8bdc36b3ffa69d05e98189e0
3
+ metadata.gz: 89680abe072d9ece72ed12e15a8301aca55c7a8a3ec5f6ee9fb092ebd507eeac
4
+ data.tar.gz: d65dddefd46aa15b4408cd1ba952faf24d603edfb59b5b1b556e46ea982875d7
5
5
  SHA512:
6
- metadata.gz: 63b83e54cdf64af06856fe1de2cb3763cc36a1f1f0a73403fb7836fac9e910dcd2f0ebd0a4fd5fb928b7714ddec20ad281ac1cc42b47a18fcda21cbc8ad8589f
7
- data.tar.gz: 3d6ecb5f48c1c4cff669add4adc7bfa2a90bf29045b17bb1b1047a58f02820c85fbe303ec0c0e7665f7045e27d03531fe32759f6bc023d91032038baf4466c65
6
+ metadata.gz: 882dd1f0cd4f57a90d55c1c06ba67e3f5595e68fe8908eb40069d1dbf9215e72685ddf48c832ebe48b510aac1063cfe2ed251c7dc5dd06130b56fd357c432237
7
+ data.tar.gz: fcb62c4fe88773b8224938a35fc254a7c0eb22845e3084f257b3fa1ed8c49b95da7aa6c81d7531868cfc0d772db594bd2098884dce8f4a4bc98ea37358f359d7
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.0
1
+ 0.4.0
data/README.md CHANGED
@@ -29,6 +29,7 @@ doc = NEU::MODS::Document.parse(xml_string)
29
29
  # Projection (plain data)
30
30
  doc.plain_title # => "What's New - How We Respond to Disaster, Episode 1"
31
31
  doc.title_parts # => { non_sort:, subtitle:, title:, part_name:, part_number: }
32
+ # byte-faithful -- the edit forms pre-fill from these
32
33
  doc.abstract # => normalized, paragraph-joined String
33
34
  doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
34
35
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
@@ -70,6 +71,19 @@ keyword-subject curated-vs-editable split. `build_*_name`'s `role:` defaults to
70
71
  the JSON/Solr access copy (dash/smart-punctuation transliteration, control
71
72
  stripping, paragraph handling). The XML preservation copy is never touched.
72
73
 
74
+ Titles and prose share the one freetext vocabulary: `to_h[:main_title]` is
75
+ normalized like `abstract`, so an invisible format mark, a Windows-1252 control
76
+ or an exotic space cannot reach Solr or a display template.
77
+
78
+ **The boundary matters.** Normalization belongs on projections that only feed
79
+ display and the index. `title_parts` is deliberately *not* normalized, because
80
+ Cerberus pre-fills its Metadata and Advanced forms from it and `MODSMerge` writes
81
+ the posted value back into the MODS XML — cleaning there would rewrite the
82
+ curator's own characters in the preservation copy on the next save. Cerberus
83
+ makes the same call for prose: its editable source is the bare `<abstract>` node,
84
+ not `doc.abstract`. Add a normalized *sibling* rather than normalizing a
85
+ projection an edit form reads.
86
+
73
87
  ## Behavior fidelity & known caveats
74
88
 
75
89
  The projection is **behavior-preserving** with Atlas's prior `mods`-gem-based
@@ -37,9 +37,11 @@ module NEU
37
37
  #
38
38
  # Pipeline: force UTF-8 + scrub invalid bytes; NFC; map Unicode dashes to '-'
39
39
  # (swung-dash to '~'); transliterate the General Punctuation block (smart
40
- # quotes, ellipsis, etc.) to ASCII; strip C0/C1 controls (keeping tab/newline);
41
- # collapse horizontal-whitespace runs to one space; for paragraph fields,
42
- # collapse 2+ newlines to exactly two; strip.
40
+ # quotes, ellipsis, etc.) to ASCII; map the separator controls to a newline
41
+ # and drop what is invisible (the soft hyphen, the rest of C0/C1, keeping
42
+ # tab/newline); collapse
43
+ # horizontal-whitespace runs to one space; for paragraph fields, collapse
44
+ # 2+ newlines to exactly two; strip.
43
45
  #
44
46
  # .normalize(str) -- single-line fields (newlines -> spaces)
45
47
  # .normalize_paragraphs(str) -- fields that may carry paragraph breaks
@@ -56,7 +58,7 @@ module NEU
56
58
  # NOTE: U+2053 (swung dash) is intentionally excluded from dashes -- it is
57
59
  # named "dash" but conventionally maps to ASCII '~', not '-' (V1 prior art).
58
60
  DASH_CODEPOINTS = [
59
- 0x002D, 0x00AD, 0x058A, 0x05BE, 0x1400, 0x1806,
61
+ 0x002D, 0x058A, 0x05BE, 0x1400, 0x1806,
60
62
  0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015,
61
63
  0x2043, 0x207B, 0x208B, 0x2212,
62
64
  0x2E17, 0x2E1A, 0x2E3A, 0x2E3B, 0x2E40,
@@ -67,9 +69,28 @@ module NEU
67
69
 
68
70
  SWUNG_DASH_RE = Regexp.new(format('\\u%04X', 0x2053)).freeze
69
71
 
70
- # C0 (U+0000..U+0008, U+000B..U+001F) and C1 (U+007F..U+009F). U+0009 (tab)
71
- # and U+000A (newline) are preserved.
72
- CONTROL_CODEPOINTS = ((0x0000..0x0008).to_a + (0x000B..0x001F).to_a + (0x007F..0x009F).to_a).freeze
72
+ # U+00AD is a hint about where a word may break, not a dash: it renders as
73
+ # nothing, and Solr discards it, so "co<00AD>operation" already matches a
74
+ # search for "cooperation". Mapping it to an ASCII hyphen instead would
75
+ # index "co" and "operation" as two tokens and lose the word, so it is
76
+ # dropped and kept out of DASH_CODEPOINTS.
77
+ SOFT_HYPHEN_RE = Regexp.new(format('\\u%04X', 0x00AD)).freeze
78
+
79
+ # U+000B (vertical tab) and U+000C (form feed) separate words rather than
80
+ # meaning nothing: Word writes a manual line break as U+000B and a page
81
+ # break as U+000C. They map to a newline, because deleting one runs the
82
+ # words either side of it together -- normalize_paragraphs then reads that
83
+ # newline as the soft wrap the line break was, and normalize turns it into
84
+ # a space.
85
+ SEPARATOR_CONTROL_CODEPOINTS = [0x000B, 0x000C].freeze
86
+ SEPARATOR_CONTROL_RE = char_class(SEPARATOR_CONTROL_CODEPOINTS).freeze
87
+
88
+ # C0 (U+0000..U+0008, U+000D..U+001F) and C1 (U+007F..U+009F) -- what is
89
+ # left once the separators above are accounted for, and none of it carries
90
+ # meaning in curator text. U+0009 (tab) and U+000A (newline) are preserved.
91
+ # U+000D is not: dropping it reduces a CRLF line ending to the single
92
+ # newline it stands for.
93
+ CONTROL_CODEPOINTS = ((0x0000..0x0008).to_a + (0x000D..0x001F).to_a + (0x007F..0x009F).to_a).freeze
73
94
  CONTROL_RE = char_class(CONTROL_CODEPOINTS).freeze
74
95
 
75
96
  HORIZONTAL_WS_CODEPOINTS = [
@@ -136,6 +157,8 @@ module NEU
136
157
  s = s.gsub(DASH_RE, "-")
137
158
  s = s.gsub(SWUNG_DASH_RE, "~")
138
159
  s = s.gsub(GENERAL_PUNCTUATION_RE) { |c| GENERAL_PUNCTUATION.fetch(c, c) }
160
+ s = s.gsub(SEPARATOR_CONTROL_RE, "\n")
161
+ s = s.gsub(SOFT_HYPHEN_RE, "")
139
162
  s.gsub(CONTROL_RE, "")
140
163
  end
141
164
  end
@@ -15,8 +15,15 @@ module NEU
15
15
  module Projection
16
16
  # --- Title ---------------------------------------------------------------
17
17
 
18
- # Structured primary-title parts. nil for an absent part (the Cerberus form
19
- # treats nil as "not present"); to_h coerces to "" for the Atlas main_title.
18
+ # Structured primary-title parts, byte-faithful to the document. nil for an
19
+ # absent part (the Cerberus form treats nil as "not present"); to_h coerces
20
+ # to "" for the Atlas main_title.
21
+ #
22
+ # Faithful on purpose: this is what Cerberus pre-fills its edit forms from
23
+ # (MODSFields for the Metadata tab, load_advanced! for the Advanced tab),
24
+ # and MODSMerge writes back whatever the form posts. Normalising here would
25
+ # rewrite the curator's characters in the preservation XML on the next save.
26
+ # #access_title_parts is the normalised surface.
20
27
  def title_parts
21
28
  ti = primary_title_info
22
29
  {
@@ -186,7 +193,7 @@ module NEU
186
193
  # names -- a drop-in source for `convert_xml_to_json`.
187
194
  def to_h
188
195
  {
189
- main_title: title_parts.transform_values(&:to_s),
196
+ main_title: access_title_parts,
190
197
  names: names,
191
198
  languages: languages,
192
199
  date_created: date_created,
@@ -204,6 +211,14 @@ module NEU
204
211
  }
205
212
  end
206
213
 
214
+ # The title parts as the access copy wants them: normalised like the
215
+ # abstract, so a curly quote, an invisible format mark or a Windows-1252
216
+ # control cannot reach Solr or a display template. Titles and prose share
217
+ # one vocabulary -- the asymmetry where only prose was cleaned was the bug.
218
+ def access_title_parts
219
+ title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) }
220
+ end
221
+
207
222
  private
208
223
 
209
224
  # --- helpers -------------------------------------------------------------
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neu-mods
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.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-08-03 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -92,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
92
  - !ruby/object:Gem::Version
93
93
  version: '0'
94
94
  requirements: []
95
- rubygems_version: 3.4.10
95
+ rubygems_version: 3.2.33
96
96
  signing_key:
97
97
  specification_version: 4
98
98
  summary: Northeastern-flavored MODS XML projection + selection for the DRS.