neu-mods 0.2.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 +4 -4
- data/.version +1 -1
- data/README.md +14 -0
- data/lib/neu/mods/canonicalize.rb +30 -7
- data/lib/neu/mods/projection.rb +44 -4
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89680abe072d9ece72ed12e15a8301aca55c7a8a3ec5f6ee9fb092ebd507eeac
|
|
4
|
+
data.tar.gz: d65dddefd46aa15b4408cd1ba952faf24d603edfb59b5b1b556e46ea982875d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 882dd1f0cd4f57a90d55c1c06ba67e3f5595e68fe8908eb40069d1dbf9215e72685ddf48c832ebe48b510aac1063cfe2ed251c7dc5dd06130b56fd357c432237
|
|
7
|
+
data.tar.gz: fcb62c4fe88773b8224938a35fc254a7c0eb22845e3084f257b3fa1ed8c49b95da7aa6c81d7531868cfc0d772db594bd2098884dce8f4a4bc98ea37358f359d7
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
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;
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
71
|
-
# and
|
|
72
|
-
|
|
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
|
data/lib/neu/mods/projection.rb
CHANGED
|
@@ -15,8 +15,15 @@ module NEU
|
|
|
15
15
|
module Projection
|
|
16
16
|
# --- Title ---------------------------------------------------------------
|
|
17
17
|
|
|
18
|
-
# Structured primary-title parts. nil for an
|
|
19
|
-
# treats nil as "not present"); to_h coerces
|
|
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
|
{
|
|
@@ -45,7 +52,32 @@ module NEU
|
|
|
45
52
|
|
|
46
53
|
optional = { ": " => parts[:subtitle], " - " => parts[:part_name], ", " => parts[:part_number] }
|
|
47
54
|
suffix = optional.filter_map { |sep, val| "#{sep}#{val}" unless val.to_s.strip.empty? }.join
|
|
48
|
-
"#{parts[:non_sort]
|
|
55
|
+
"#{join_non_sort(parts[:non_sort], parts[:title])}#{suffix}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Characters that bind a nonSort to the word after it. An elided article
|
|
59
|
+
# takes no space -- "L'Etranger", not "L' Etranger" -- and the same holds
|
|
60
|
+
# for a hyphenated prefix. U+2019 is the curly apostrophe, escaped rather
|
|
61
|
+
# than literal to keep lib/ pure ASCII (see the source-purity spec).
|
|
62
|
+
NON_SORT_BINDING = ["'", "\u2019", "-"].freeze
|
|
63
|
+
|
|
64
|
+
# MODS says a nonSort carries whatever separator it needs, so the historical
|
|
65
|
+
# composition simply concatenated. That only holds while the authored
|
|
66
|
+
# trailing space survives, and it does not: #child_text canonicalizes
|
|
67
|
+
# whitespace on read, so `<nonSort>The </nonSort>` arrives here as "The" and
|
|
68
|
+
# the title came out as "TheHobbit". Composing the separator instead makes
|
|
69
|
+
# the output right whether or not the source kept one -- which matters,
|
|
70
|
+
# because an invisible trailing space is not something a curator, a
|
|
71
|
+
# hand-edit or a third-party producer can be relied on to preserve.
|
|
72
|
+
#
|
|
73
|
+
# Callers that DO pass the space (Atlas's access-copy model) are unaffected:
|
|
74
|
+
# a nonSort already ending in whitespace is joined as-is.
|
|
75
|
+
def self.join_non_sort(non_sort, title)
|
|
76
|
+
prefix = non_sort.to_s
|
|
77
|
+
return title.to_s if prefix.empty?
|
|
78
|
+
return "#{prefix}#{title}" if prefix.end_with?(" ") || prefix.end_with?(*NON_SORT_BINDING)
|
|
79
|
+
|
|
80
|
+
"#{prefix} #{title}"
|
|
49
81
|
end
|
|
50
82
|
|
|
51
83
|
# --- Abstract / access ---------------------------------------------------
|
|
@@ -161,7 +193,7 @@ module NEU
|
|
|
161
193
|
# names -- a drop-in source for `convert_xml_to_json`.
|
|
162
194
|
def to_h
|
|
163
195
|
{
|
|
164
|
-
main_title:
|
|
196
|
+
main_title: access_title_parts,
|
|
165
197
|
names: names,
|
|
166
198
|
languages: languages,
|
|
167
199
|
date_created: date_created,
|
|
@@ -179,6 +211,14 @@ module NEU
|
|
|
179
211
|
}
|
|
180
212
|
end
|
|
181
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
|
+
|
|
182
222
|
private
|
|
183
223
|
|
|
184
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.
|
|
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-
|
|
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.
|
|
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.
|