neu-mods 0.3.0 → 0.5.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: 1469790b96ff15516d9766db6673159fb9ad14128e02c4399842580c198a1740
4
+ data.tar.gz: 97c442fbd0765c6de753bbb140da3fa2ac61fedcb63104d38801c569a876ddcd
5
5
  SHA512:
6
- metadata.gz: 63b83e54cdf64af06856fe1de2cb3763cc36a1f1f0a73403fb7836fac9e910dcd2f0ebd0a4fd5fb928b7714ddec20ad281ac1cc42b47a18fcda21cbc8ad8589f
7
- data.tar.gz: 3d6ecb5f48c1c4cff669add4adc7bfa2a90bf29045b17bb1b1047a58f02820c85fbe303ec0c0e7665f7045e27d03531fe32759f6bc023d91032038baf4466c65
6
+ metadata.gz: 5b2fdf0e42f5fd65fccbc732ddf4a15b3f311f4719a3a95a294517da643c8005dcbb3c25a7b3e2b1e7afcb67c1f29f9cecacf7ec157640ef3a2b2d56e13197ac
7
+ data.tar.gz: 4ffc16684b6176a667fcf62dadc6e20c5fed2733c1f6bdcd39df345fe6df77f5cd412f62fe879676f1075f7c690ca6cb368b6876bfca0e4a98477af8112731c2
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.0
1
+ 0.5.0
data/README.md CHANGED
@@ -29,9 +29,15 @@ 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)
36
+ doc.date_created_with_precision
37
+ # => [DateTime, "year"|"month"|"day"] w3cdtf YYYY, YYYY-MM
38
+ # and YYYY-MM-DD all parse; the precision says which shape
39
+ # the record declared, so display cannot invent a month or
40
+ # a day the record never claimed
35
41
  doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
36
42
 
37
43
  # Pure title composition (no document needed) — for callers that already hold
@@ -70,6 +76,19 @@ keyword-subject curated-vs-editable split. `build_*_name`'s `role:` defaults to
70
76
  the JSON/Solr access copy (dash/smart-punctuation transliteration, control
71
77
  stripping, paragraph handling). The XML preservation copy is never touched.
72
78
 
79
+ Titles and prose share the one freetext vocabulary: `to_h[:main_title]` is
80
+ normalized like `abstract`, so an invisible format mark, a Windows-1252 control
81
+ or an exotic space cannot reach Solr or a display template.
82
+
83
+ **The boundary matters.** Normalization belongs on projections that only feed
84
+ display and the index. `title_parts` is deliberately *not* normalized, because
85
+ Cerberus pre-fills its Metadata and Advanced forms from it and `MODSMerge` writes
86
+ the posted value back into the MODS XML — cleaning there would rewrite the
87
+ curator's own characters in the preservation copy on the next save. Cerberus
88
+ makes the same call for prose: its editable source is the bare `<abstract>` node,
89
+ not `doc.abstract`. Add a normalized *sibling* rather than normalizing a
90
+ projection an edit form reads.
91
+
73
92
  ## Behavior fidelity & known caveats
74
93
 
75
94
  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
  {
@@ -164,32 +171,48 @@ module NEU
164
171
  node && clean(node.text)
165
172
  end
166
173
 
167
- # Parsed dateCreated, or nil if no originInfo/dateCreated, or "" if present
168
- # but unparseable (mirrors Atlas's safe_date_parse rescue).
169
- def date_created
174
+ # The three w3cdtf date shapes a dateCreated may stop at: year, year-month,
175
+ # or a full date. Matching the shape explicitly, rather than widening
176
+ # DateTime.parse, is what lets the declared precision fall out of the parse
177
+ # instead of being guessed after it.
178
+ W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
179
+
180
+ # Parsed dateCreated paired with the granularity the record declared, as
181
+ # [value, precision]. value is nil if no originInfo/dateCreated, or "" if
182
+ # present but unparseable (mirrors Atlas's safe_date_parse rescue).
183
+ # precision is "year", "month" or "day", and nil whenever value is not a
184
+ # DateTime.
185
+ #
186
+ # The precision has to be captured here, at the only point where the shape
187
+ # is still visible: a year-only date parses to January 1st, and no consumer
188
+ # downstream can tell that month and day from a record that claimed them.
189
+ # A preservation repository must not project a precision it was not given.
190
+ def date_created_with_precision
170
191
  node = doc.at_xpath("/mods:mods/mods:originInfo/mods:dateCreated", NAMESPACE)
171
- return nil unless node
192
+ return [nil, nil] unless node
172
193
 
173
194
  str = NEU::MODS.canonical_ws(node.text)
174
- return nil if str.empty?
195
+ return [nil, nil] if str.empty?
175
196
 
176
- begin
177
- DateTime.parse(str)
178
- rescue Date::Error
179
- ""
180
- end
197
+ parse_w3cdtf(str)
181
198
  end
182
199
 
200
+ def date_created = date_created_with_precision.first
201
+ def date_created_precision = date_created_with_precision.last
202
+
183
203
  # --- Full projection -----------------------------------------------------
184
204
 
185
205
  # The complete read projection, keyed to Atlas's Metadata::MODS attribute
186
206
  # names -- a drop-in source for `convert_xml_to_json`.
187
207
  def to_h
208
+ date, date_precision = date_created_with_precision
209
+
188
210
  {
189
- main_title: title_parts.transform_values(&:to_s),
211
+ main_title: access_title_parts,
190
212
  names: names,
191
213
  languages: languages,
192
- date_created: date_created,
214
+ date_created: date,
215
+ date_created_precision: date_precision,
193
216
  resource_type: resource_type,
194
217
  genres: genres,
195
218
  format: format,
@@ -204,10 +227,38 @@ module NEU
204
227
  }
205
228
  end
206
229
 
230
+ # The title parts as the access copy wants them: normalised like the
231
+ # abstract, so a curly quote, an invisible format mark or a Windows-1252
232
+ # control cannot reach Solr or a display template. Titles and prose share
233
+ # one vocabulary -- the asymmetry where only prose was cleaned was the bug.
234
+ def access_title_parts
235
+ title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) }
236
+ end
237
+
207
238
  private
208
239
 
209
240
  # --- helpers -------------------------------------------------------------
210
241
 
242
+ # A shape-matched but impossible date (2026-13, 2026-02-30) reaches DateTime
243
+ # and raises; it falls to the "" sentinel like any other unparseable value.
244
+ # Anything outside the three shapes keeps the old permissive parse, so a
245
+ # timestamp still projects as a full date.
246
+ def parse_w3cdtf(str)
247
+ m = W3CDTF_DATE.match(str)
248
+ return [DateTime.parse(str), "day"] unless m
249
+
250
+ precision = if m[3]
251
+ "day"
252
+ elsif m[2]
253
+ "month"
254
+ else
255
+ "year"
256
+ end
257
+ [DateTime.new(m[1].to_i, (m[2] || 1).to_i, (m[3] || 1).to_i), precision]
258
+ rescue Date::Error
259
+ ["", nil]
260
+ end
261
+
211
262
  def text_at(xpath)
212
263
  node = doc.at_xpath(xpath, NAMESPACE)
213
264
  node ? NEU::MODS.canonical_ws(node.text) : ""
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.5.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-09-04 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.