neu-mods 0.5.0 → 0.7.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: 1469790b96ff15516d9766db6673159fb9ad14128e02c4399842580c198a1740
4
- data.tar.gz: 97c442fbd0765c6de753bbb140da3fa2ac61fedcb63104d38801c569a876ddcd
3
+ metadata.gz: afd81033ef60fdd497880f538d3730d1518a37b362307028a165a1f93105bdd2
4
+ data.tar.gz: 3f6e1aa77c231d6622dceb4d4ae66d500329776659bf4feb2cb4ea8ded455932
5
5
  SHA512:
6
- metadata.gz: 5b2fdf0e42f5fd65fccbc732ddf4a15b3f311f4719a3a95a294517da643c8005dcbb3c25a7b3e2b1e7afcb67c1f29f9cecacf7ec157640ef3a2b2d56e13197ac
7
- data.tar.gz: 4ffc16684b6176a667fcf62dadc6e20c5fed2733c1f6bdcd39df345fe6df77f5cd412f62fe879676f1075f7c690ca6cb368b6876bfca0e4a98477af8112731c2
6
+ metadata.gz: 6b643273728275f712a266a33a64cf34bbe078fa405ca29ffd59af52f0e9ee7c9102f3867f6d4129bcbb5b050d28c9a00995190ec5568fea6761ee1af87254c4
7
+ data.tar.gz: b86248e24362d8e1436c47c4a0decf5254207ad587791f8fb0434ac7560e4d7a79e3e429464d89ad50dcc92d208e63ad456235a0c94a6c328b7b6efc0951d22f
data/.version CHANGED
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.7.0
data/README.md CHANGED
@@ -31,15 +31,29 @@ 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
32
  # byte-faithful -- the edit forms pre-fill from these
33
33
  doc.abstract # => normalized, paragraph-joined String
34
+ doc.languages # => ["English"] a code-only <languageTerm>eng</> included
34
35
  doc.topical_subjects # => ["Civil society", ...] (every <topic>, for the access copy)
35
36
  doc.keywords # => [...] (only the editable attribute-free keyword subjects)
36
37
  doc.date_created_with_precision
37
38
  # => [DateTime, "year"|"month"|"day"] w3cdtf YYYY, YYYY-MM
38
39
  # and YYYY-MM-DD all parse; the precision says which shape
39
40
  # the record declared, so display cannot invent a month or
40
- # a day the record never claimed
41
+ # a day the record never claimed. Same for
42
+ # date_issued_with_precision and
43
+ # copyright_date_with_precision.
44
+ doc.notes # => [{ type: "funding", value: "..." }, ...]
45
+ doc.related_items # => [{ type: "otherFormat", title: "..." }, ...]
46
+ # every relatedItem that is not a series or a host
47
+ doc.location # => [{ physical_location:, shelf_location:, url: }, ...]
48
+ doc.map_data # => [{ scale:, projection:, coordinates: }, ...]
41
49
  doc.to_h # => full projection, keyed to Atlas's Metadata::MODS attributes
42
50
 
51
+ # The field registry -- the single declaration of what this gem projects.
52
+ # name => :one or :many. to_h is derived from it, and a consumer builds its own
53
+ # schema from it rather than re-listing the field set by hand. Cardinality
54
+ # follows what MODS marks repeatable, so a field can never silently truncate.
55
+ NEU::MODS::FIELDS # => { main_title: :one, names: :many, ... }
56
+
43
57
  # Pure title composition (no document needed) — for callers that already hold
44
58
  # the parts (e.g. Atlas's access-copy model) and must not re-parse XML on read.
45
59
  NEU::MODS.compose_title(non_sort: "", title: "What's New",
@@ -99,11 +113,24 @@ intentional notes:
99
113
  its quirks* (e.g. multiple `given` nameParts concatenate with no separator),
100
114
  to preserve existing Solr/display output. Cleanups are a deliberate future
101
115
  contract change, not a silent one.
102
- - **Roles & languages** read the `type="text"` term and fall back to the **raw
103
- code** they are *not* MARC-relator / ISO-639 translated. Records carrying
104
- text forms (the norm) are unaffected; code-only records would differ. Vendoring
105
- those lookup tables (or depending on `iso-639`) is deferred to keep the gem
106
- Nokogiri-only and small.
116
+ - **Languages are translated; roles are not.** Both read the `type="text"` term
117
+ first. A code-only `languageTerm` is then translated through the vendored ISO
118
+ 639 registry (`lib/neu/mods/data/iso639-2.txt`, from the Library of Congress),
119
+ so `eng` projects `English`. That happens here rather than in a consumer's
120
+ display layer because otherwise Solr indexes `eng` while the page shows
121
+ `English`, and the language facet reads in codes.
122
+ A code-only `roleTerm` stays raw. A MARC relator is a display *label*, and the
123
+ label vocabulary belongs to the consumer — Cerberus's edit form and Atlas's
124
+ display word the same role differently. An unrecognised language code also
125
+ stays raw, since the record still said something.
126
+ - **`description` is not projected.** MODS has no element of that name, and the
127
+ two candidates — an `abstract` variant and `physicalDescription/note` —
128
+ describe different things. Projecting a guess would put wrong data in the
129
+ field rather than leave an empty one, so it waits on a decision.
130
+ - **A date range is not projected.** `dateCreated`, `dateIssued` and
131
+ `copyrightDate` each project one value with its precision. A record that
132
+ ranges a date uses `point="start"` / `point="end"`, which needs a shape of its
133
+ own rather than a second array.
107
134
 
108
135
  ## Source convention
109
136
 
@@ -0,0 +1,487 @@
1
+ aar||aa|Afar|afar
2
+ abk||ab|Abkhazian|abkhaze
3
+ ace|||Achinese|aceh
4
+ ach|||Acoli|acoli
5
+ ada|||Adangme|adangme
6
+ ady|||Adyghe; Adygei|adyghé
7
+ afa|||Afro-Asiatic languages|afro-asiatiques, langues
8
+ afh|||Afrihili|afrihili
9
+ afr||af|Afrikaans|afrikaans
10
+ ain|||Ainu|aïnou
11
+ aka||ak|Akan|akan
12
+ akk|||Akkadian|akkadien
13
+ alb|sqi|sq|Albanian|albanais
14
+ ale|||Aleut|aléoute
15
+ alg|||Algonquian languages|algonquines, langues
16
+ alt|||Southern Altai|altai du Sud
17
+ amh||am|Amharic|amharique
18
+ ang|||English, Old (ca.450-1100)|anglo-saxon (ca.450-1100)
19
+ anp|||Angika|angika
20
+ apa|||Apache languages|apaches, langues
21
+ ara||ar|Arabic|arabe
22
+ arc|||Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)|araméen d'empire (700-300 BCE)
23
+ arg||an|Aragonese|aragonais
24
+ arm|hye|hy|Armenian|arménien
25
+ arn|||Mapudungun; Mapuche|mapudungun; mapuche; mapuce
26
+ arp|||Arapaho|arapaho
27
+ art|||Artificial languages|artificielles, langues
28
+ arw|||Arawak|arawak
29
+ asm||as|Assamese|assamais
30
+ ast|||Asturian; Bable; Leonese; Asturleonese|asturien; bable; léonais; asturoléonais
31
+ ath|||Athapascan languages|athapascanes, langues
32
+ aus|||Australian languages|australiennes, langues
33
+ ava||av|Avaric|avar
34
+ ave||ae|Avestan|avestique
35
+ awa|||Awadhi|awadhi
36
+ aym||ay|Aymara|aymara
37
+ aze||az|Azerbaijani|azéri
38
+ bad|||Banda languages|banda, langues
39
+ bai|||Bamileke languages|bamiléké, langues
40
+ bak||ba|Bashkir|bachkir
41
+ bal|||Baluchi|baloutchi
42
+ bam||bm|Bambara|bambara
43
+ ban|||Balinese|balinais
44
+ baq|eus|eu|Basque|basque
45
+ bas|||Basa|basa
46
+ bat|||Baltic languages|baltes, langues
47
+ bej|||Beja; Bedawiyet|bedja
48
+ bel||be|Belarusian|biélorusse
49
+ bem|||Bemba|bemba
50
+ ben||bn|Bengali|bengali
51
+ ber|||Berber languages|berbères, langues
52
+ bho|||Bhojpuri|bhojpuri
53
+ bih|||Bihari languages|langues biharis
54
+ bik|||Bikol|bikol
55
+ bin|||Bini; Edo|bini; edo
56
+ bis||bi|Bislama|bichlamar
57
+ bla|||Siksika|blackfoot
58
+ bnt|||Bantu languages|bantou, langues
59
+ bos||bs|Bosnian|bosniaque
60
+ bra|||Braj|braj
61
+ bre||br|Breton|breton
62
+ btk|||Batak languages|batak, langues
63
+ bua|||Buriat|bouriate
64
+ bug|||Buginese|bugi
65
+ bul||bg|Bulgarian|bulgare
66
+ bur|mya|my|Burmese|birman
67
+ byn|||Blin; Bilin|blin; bilen
68
+ cad|||Caddo|caddo
69
+ cai|||Central American Indian languages|amérindiennes de L'Amérique centrale, langues
70
+ car|||Galibi Carib|karib; galibi; carib
71
+ cat||ca|Catalan; Valencian|catalan; valencien
72
+ cau|||Caucasian languages|caucasiennes, langues
73
+ ceb|||Cebuano|cebuano
74
+ cel|||Celtic languages|celtiques, langues; celtes, langues
75
+ cha||ch|Chamorro|chamorro
76
+ chb|||Chibcha|chibcha
77
+ che||ce|Chechen|tchétchène
78
+ chg|||Chagatai|djaghataï
79
+ chi|zho|zh|Chinese|chinois
80
+ chk|||Chuukese|chuuk
81
+ chm|||Mari|mari
82
+ chn|||Chinook jargon|chinook, jargon
83
+ cho|||Choctaw|choctaw
84
+ chp|||Chipewyan; Dene Suline|chipewyan
85
+ chr|||Cherokee|cherokee
86
+ chu||cu|Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic|slavon d'église; vieux slave; slavon liturgique; vieux bulgare
87
+ chv||cv|Chuvash|tchouvache
88
+ chy|||Cheyenne|cheyenne
89
+ cmc|||Chamic languages|chames, langues
90
+ cnr|||Montenegrin|monténégrin
91
+ cop|||Coptic|copte
92
+ cor||kw|Cornish|cornique
93
+ cos||co|Corsican|corse
94
+ cpe|||Creoles and pidgins, English based|créoles et pidgins basés sur l'anglais
95
+ cpf|||Creoles and pidgins, French-based|créoles et pidgins basés sur le français
96
+ cpp|||Creoles and pidgins, Portuguese-based|créoles et pidgins basés sur le portugais
97
+ cre||cr|Cree|cree
98
+ crh|||Crimean Tatar; Crimean Turkish|tatar de Crimé
99
+ crp|||Creoles and pidgins|créoles et pidgins
100
+ csb|||Kashubian|kachoube
101
+ cus|||Cushitic languages|couchitiques, langues
102
+ cze|ces|cs|Czech|tchèque
103
+ dak|||Dakota|dakota
104
+ dan||da|Danish|danois
105
+ dar|||Dargwa|dargwa
106
+ day|||Land Dayak languages|dayak, langues
107
+ del|||Delaware|delaware
108
+ den|||Slave (Athapascan)|esclave (athapascan)
109
+ dgr|||Tlicho; Dogrib|tlicho; dogrib
110
+ din|||Dinka|dinka
111
+ div||dv|Divehi; Dhivehi; Maldivian|maldivien
112
+ doi|||Dogri|dogri
113
+ dra|||Dravidian languages|dravidiennes, langues
114
+ dsb|||Lower Sorbian|bas-sorabe
115
+ dua|||Duala|douala
116
+ dum|||Dutch, Middle (ca.1050-1350)|néerlandais moyen (ca. 1050-1350)
117
+ dut|nld|nl|Dutch; Flemish|néerlandais; flamand
118
+ dyu|||Dyula|dioula
119
+ dzo||dz|Dzongkha|dzongkha
120
+ efi|||Efik|efik
121
+ egy|||Egyptian (Ancient)|égyptien
122
+ eka|||Ekajuk|ekajuk
123
+ elx|||Elamite|élamite
124
+ eng||en|English|anglais
125
+ enm|||English, Middle (1100-1500)|anglais moyen (1100-1500)
126
+ epo||eo|Esperanto|espéranto
127
+ est||et|Estonian|estonien
128
+ ewe||ee|Ewe|éwé
129
+ ewo|||Ewondo|éwondo
130
+ fan|||Fang|fang
131
+ fao||fo|Faroese|féroïen
132
+ fat|||Fanti|fanti
133
+ fij||fj|Fijian|fidjien
134
+ fil|||Filipino; Pilipino|filipino; pilipino
135
+ fin||fi|Finnish|finnois
136
+ fiu|||Finno-Ugrian languages|finno-ougriennes, langues
137
+ fon|||Fon|fon
138
+ fre|fra|fr|French|français
139
+ frm|||French, Middle (ca.1400-1600)|français moyen (1400-1600)
140
+ fro|||French, Old (842-ca.1400)|français ancien (842-ca.1400)
141
+ frr|||Northern Frisian|frison septentrional
142
+ frs|||Eastern Frisian|frison oriental
143
+ fry||fy|Western Frisian|frison occidental
144
+ ful||ff|Fulah|peul
145
+ fur|||Friulian|frioulan
146
+ gaa|||Ga|ga
147
+ gay|||Gayo|gayo
148
+ gba|||Gbaya|gbaya
149
+ gem|||Germanic languages|germaniques, langues
150
+ geo|kat|ka|Georgian|géorgien
151
+ ger|deu|de|German|allemand
152
+ gez|||Geez|guèze
153
+ gil|||Gilbertese|kiribati
154
+ gla||gd|Gaelic; Scottish Gaelic|gaélique; gaélique écossais
155
+ gle||ga|Irish|irlandais
156
+ glg||gl|Galician|galicien
157
+ glv||gv|Manx|manx; mannois
158
+ gmh|||German, Middle High (ca.1050-1500)|allemand, moyen haut (ca. 1050-1500)
159
+ goh|||German, Old High (ca.750-1050)|allemand, vieux haut (ca. 750-1050)
160
+ gon|||Gondi|gond
161
+ gor|||Gorontalo|gorontalo
162
+ got|||Gothic|gothique
163
+ grb|||Grebo|grebo
164
+ grc|||Greek, Ancient (to 1453)|grec ancien (jusqu'à 1453)
165
+ gre|ell|el|Modern Greek (1453-)|grec moderne (1453-)
166
+ grn||gn|Guarani|guarani
167
+ gsw|||Swiss German; Alemannic; Alsatian|suisse alémanique; alémanique; alsacien
168
+ guj||gu|Gujarati|goudjrati
169
+ gwi|||Gwich'in|gwich'in
170
+ hai|||Haida|haida
171
+ hat||ht|Haitian; Haitian Creole|haïtien; créole haïtien
172
+ hau||ha|Hausa|haoussa
173
+ haw|||Hawaiian|hawaïen
174
+ heb||he|Hebrew|hébreu
175
+ her||hz|Herero|herero
176
+ hil|||Hiligaynon|hiligaynon
177
+ him|||Himachali languages; Western Pahari languages|langues himachalis; langues paharis occidentales
178
+ hin||hi|Hindi|hindi
179
+ hit|||Hittite|hittite
180
+ hmn|||Hmong; Mong|hmong
181
+ hmo||ho|Hiri Motu|hiri motu
182
+ hrv||hr|Croatian|croate
183
+ hsb|||Upper Sorbian|haut-sorabe
184
+ hun||hu|Hungarian|hongrois
185
+ hup|||Hupa|hupa
186
+ iba|||Iban|iban
187
+ ibo||ig|Igbo|igbo
188
+ ice|isl|is|Icelandic|islandais
189
+ ido||io|Ido|ido
190
+ iii||ii|Sichuan Yi; Nuosu|yi de Sichuan
191
+ ijo|||Ijo languages|ijo, langues
192
+ iku||iu|Inuktitut|inuktitut
193
+ ile||ie|Interlingue; Occidental|interlingue
194
+ ilo|||Iloko|ilocano
195
+ ina||ia|Interlingua (International Auxiliary Language Association)|interlingua (langue auxiliaire internationale)
196
+ inc|||Indic languages|indo-aryennes, langues
197
+ ind||id|Indonesian|indonésien
198
+ ine|||Indo-European languages|indo-européennes, langues
199
+ inh|||Ingush|ingouche
200
+ ipk||ik|Inupiaq|inupiaq
201
+ ira|||Iranian languages|iraniennes, langues
202
+ iro|||Iroquoian languages|iroquoises, langues
203
+ ita||it|Italian|italien
204
+ jav||jv|Javanese|javanais
205
+ jbo|||Lojban|lojban
206
+ jpn||ja|Japanese|japonais
207
+ jpr|||Judeo-Persian|judéo-persan
208
+ jrb|||Judeo-Arabic|judéo-arabe
209
+ kaa|||Kara-Kalpak|karakalpak
210
+ kab|||Kabyle|kabyle
211
+ kac|||Kachin; Jingpho|kachin; jingpho
212
+ kal||kl|Kalaallisut; Greenlandic|groenlandais
213
+ kam|||Kamba|kamba
214
+ kan||kn|Kannada|kannada
215
+ kar|||Karen languages|karen, langues
216
+ kas||ks|Kashmiri|kashmiri
217
+ kau||kr|Kanuri|kanouri
218
+ kaw|||Kawi|kawi
219
+ kaz||kk|Kazakh|kazakh
220
+ kbd|||Kabardian|kabardien
221
+ kha|||Khasi|khasi
222
+ khi|||Khoisan languages|khoïsan, langues
223
+ khm||km|Central Khmer|khmer central
224
+ kho|||Khotanese; Sakan|khotanais; sakan
225
+ kik||ki|Kikuyu; Gikuyu|kikuyu
226
+ kin||rw|Kinyarwanda|rwanda
227
+ kir||ky|Kirghiz; Kyrgyz|kirghiz
228
+ kmb|||Kimbundu|kimbundu
229
+ kok|||Konkani|konkani
230
+ kom||kv|Komi|kom
231
+ kon||kg|Kongo|kongo
232
+ kor||ko|Korean|coréen
233
+ kos|||Kosraean|kosrae
234
+ kpe|||Kpelle|kpellé
235
+ krc|||Karachay-Balkar|karatchai balkar
236
+ krl|||Karelian|carélien
237
+ kro|||Kru languages|krou, langues
238
+ kru|||Kurukh|kurukh
239
+ kua||kj|Kuanyama; Kwanyama|kuanyama; kwanyama
240
+ kum|||Kumyk|koumyk
241
+ kur||ku|Kurdish|kurde
242
+ kut|||Kutenai|kutenai
243
+ lad|||Ladino|judéo-espagnol
244
+ lah|||Lahnda|lahnda
245
+ lam|||Lamba|lamba
246
+ lao||lo|Lao|lao
247
+ lat||la|Latin|latin
248
+ lav||lv|Latvian|letton
249
+ lez|||Lezghian|lezghien
250
+ lim||li|Limburgan; Limburger; Limburgish|limbourgeois
251
+ lin||ln|Lingala|lingala
252
+ lit||lt|Lithuanian|lituanien
253
+ lol|||Mongo|mongo
254
+ loz|||Lozi|lozi
255
+ ltz||lb|Luxembourgish; Letzeburgesch|luxembourgeois
256
+ lua|||Luba-Lulua|luba-lulua
257
+ lub||lu|Luba-Katanga|luba-katanga
258
+ lug||lg|Ganda|ganda
259
+ lui|||Luiseno|luiseno
260
+ lun|||Lunda|lunda
261
+ luo|||Luo (Kenya and Tanzania)|luo (Kenya et Tanzanie)
262
+ lus|||Lushai|lushai
263
+ mac|mkd|mk|Macedonian|macédonien
264
+ mad|||Madurese|madourais
265
+ mag|||Magahi|magahi
266
+ mah||mh|Marshallese|marshall
267
+ mai|||Maithili|maithili
268
+ mak|||Makasar|makassar
269
+ mal||ml|Malayalam|malayalam
270
+ man|||Mandingo|mandingue
271
+ mao|mri|mi|Maori|maori
272
+ map|||Austronesian languages|austronésiennes, langues
273
+ mar||mr|Marathi|marathe
274
+ mas|||Masai|massaï
275
+ may|msa|ms|Malay|malais
276
+ mdf|||Moksha|moksa
277
+ mdr|||Mandar|mandar
278
+ men|||Mende|mendé
279
+ mga|||Irish, Middle (900-1200)|irlandais moyen (900-1200)
280
+ mic|||Mi'kmaq; Micmac|mi'kmaq; micmac
281
+ min|||Minangkabau|minangkabau
282
+ mis|||Uncoded languages|langues non codées
283
+ mkh|||Mon-Khmer languages|môn-khmer, langues
284
+ mlg||mg|Malagasy|malgache
285
+ mlt||mt|Maltese|maltais
286
+ mnc|||Manchu|mandchou
287
+ mni|||Manipuri|manipuri
288
+ mno|||Manobo languages|manobo, langues
289
+ moh|||Mohawk|mohawk
290
+ mon||mn|Mongolian|mongol
291
+ mos|||Mossi|moré
292
+ mul|||Multiple languages|multilingue
293
+ mun|||Munda languages|mounda, langues
294
+ mus|||Creek|muskogee
295
+ mwl|||Mirandese|mirandais
296
+ mwr|||Marwari|marvari
297
+ myn|||Mayan languages|maya, langues
298
+ myv|||Erzya|erza
299
+ nah|||Nahuatl languages|nahuatl, langues
300
+ nai|||North American Indian languages|nord-amérindiennes, langues
301
+ nap|||Neapolitan|napolitain
302
+ nau||na|Nauru|nauruan
303
+ nav||nv|Navajo; Navaho|navaho
304
+ nbl||nr|South Ndebele|ndébélé du Sud
305
+ nde||nd|North Ndebele|ndébélé du Nord
306
+ ndo||ng|Ndonga|ndonga
307
+ nds|||Low German; Low Saxon; German, Low; Saxon, Low|bas allemand; bas saxon; allemand, bas; saxon, bas
308
+ nep||ne|Nepali|népalais
309
+ new|||Nepal Bhasa; Newar; Newari|nepal bhasa; néwar; newari
310
+ nia|||Nias|nias
311
+ nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues
312
+ niu|||Niuean|niué
313
+ nno||nn|Norwegian Nynorsk|norvégien nynorsk
314
+ nob||nb|Norwegian Bokmål|norvégien bokmål
315
+ nog|||Nogai|nogaï; nogay
316
+ non|||Norse, Old|norrois, vieux
317
+ nor||no|Norwegian|norvégien
318
+ nqo|||N'Ko|n'ko
319
+ nso|||Pedi; Sepedi; Northern Sotho|pedi; sepedi; sotho du Nord
320
+ nub|||Nubian languages|nubiennes, langues
321
+ nwc|||Classical Newari; Old Newari; Classical Nepal Bhasa|newari classique
322
+ nya||ny|Chichewa; Chewa; Nyanja|chichewa; chewa; nyanja
323
+ nym|||Nyamwezi|nyamwezi
324
+ nyn|||Nyankole|nyankolé
325
+ nyo|||Nyoro|nyoro
326
+ nzi|||Nzima|nzema
327
+ oci||oc|Occitan (post 1500)|occitan (après 1500)
328
+ oji||oj|Ojibwa|ojibwa
329
+ ori||or|Oriya|oriya
330
+ orm||om|Oromo|galla
331
+ osa|||Osage|osage
332
+ oss||os|Ossetian; Ossetic|ossète
333
+ ota|||Turkish, Ottoman (1500-1928)|turc ottoman (1500-1928)
334
+ oto|||Otomian languages|otomi, langues
335
+ paa|||Papuan languages|papoues, langues
336
+ pag|||Pangasinan|pangasinan
337
+ pal|||Pahlavi|pahlavi
338
+ pam|||Pampanga; Kapampangan|pampangan
339
+ pan||pa|Panjabi; Punjabi|pendjabi
340
+ pap|||Papiamento|papiamento
341
+ pau|||Palauan|palau
342
+ peo|||Persian, Old (ca.600-400 B.C.)|perse, vieux (ca. 600-400 av. J.-C.)
343
+ per|fas|fa|Persian|persan
344
+ phi|||Philippine languages|philippines, langues
345
+ phn|||Phoenician|phénicien
346
+ pli||pi|Pali|pali
347
+ pol||pl|Polish|polonais
348
+ pon|||Pohnpeian|pohnpei
349
+ por||pt|Portuguese|portugais
350
+ pra|||Prakrit languages|prâkrit, langues
351
+ pro|||Provençal, Old (to 1500); Occitan, Old (to 1500)|provençal ancien (jusqu'à 1500); occitan ancien (jusqu'à 1500)
352
+ pus||ps|Pushto; Pashto|pachto
353
+ qaa-qtz|||Reserved for local use|réservée à l'usage local
354
+ que||qu|Quechua|quechua
355
+ raj|||Rajasthani|rajasthani
356
+ rap|||Rapanui|rapanui
357
+ rar|||Rarotongan; Cook Islands Maori|rarotonga; maori des îles Cook
358
+ roa|||Romance languages|romanes, langues
359
+ roh||rm|Romansh|romanche
360
+ rom|||Romany|tsigane
361
+ rum|ron|ro|Romanian; Moldavian; Moldovan|roumain; moldave
362
+ run||rn|Rundi|rundi
363
+ rup|||Aromanian; Arumanian; Macedo-Romanian|aroumain; macédo-roumain
364
+ rus||ru|Russian|russe
365
+ sad|||Sandawe|sandawe
366
+ sag||sg|Sango|sango
367
+ sah|||Yakut|iakoute
368
+ sai|||South American Indian languages|sud-amérindiennes, langues
369
+ sal|||Salishan languages|salishennes, langues
370
+ sam|||Samaritan Aramaic|samaritain
371
+ san||sa|Sanskrit|sanskrit
372
+ sas|||Sasak|sasak
373
+ sat|||Santali|santal
374
+ scn|||Sicilian|sicilien
375
+ sco|||Scots|écossais
376
+ sel|||Selkup|selkoupe
377
+ sem|||Semitic languages|sémitiques, langues
378
+ sga|||Irish, Old (to 900)|irlandais ancien (jusqu'à 900)
379
+ sgn|||Sign Languages|langues des signes
380
+ shn|||Shan|chan
381
+ sid|||Sidamo|sidamo
382
+ sin||si|Sinhala; Sinhalese|singhalais
383
+ sio|||Siouan languages|sioux, langues
384
+ sit|||Sino-Tibetan languages|sino-tibétaines, langues
385
+ sla|||Slavic languages|slaves, langues
386
+ slo|slk|sk|Slovak|slovaque
387
+ slv||sl|Slovenian|slovène
388
+ sma|||Southern Sami|sami du Sud
389
+ sme||se|Northern Sami|sami du Nord
390
+ smi|||Sami languages|sames, langues
391
+ smj|||Lule Sami|sami de Lule
392
+ smn|||Inari Sami|sami d'Inari
393
+ smo||sm|Samoan|samoan
394
+ sms|||Skolt Sami|sami skolt
395
+ sna||sn|Shona|shona
396
+ snd||sd|Sindhi|sindhi
397
+ snk|||Soninke|soninké
398
+ sog|||Sogdian|sogdien
399
+ som||so|Somali|somali
400
+ son|||Songhai languages|songhai, langues
401
+ sot||st|Sotho, Southern|sotho du Sud
402
+ spa||es|Spanish; Castilian|espagnol; castillan
403
+ srd||sc|Sardinian|sarde
404
+ srn|||Sranan Tongo|sranan tongo
405
+ srp||sr|Serbian|serbe
406
+ srr|||Serer|sérère
407
+ ssa|||Nilo-Saharan languages|nilo-sahariennes, langues
408
+ ssw||ss|Swati|swati
409
+ suk|||Sukuma|sukuma
410
+ sun||su|Sundanese|soundanais
411
+ sus|||Susu|soussou
412
+ sux|||Sumerian|sumérien
413
+ swa||sw|Swahili|swahili
414
+ swe||sv|Swedish|suédois
415
+ syc|||Classical Syriac|syriaque classique
416
+ syr|||Syriac|syriaque
417
+ tah||ty|Tahitian|tahitien
418
+ tai|||Tai languages|tai, langues
419
+ tam||ta|Tamil|tamoul
420
+ tat||tt|Tatar|tatar
421
+ tel||te|Telugu|télougou
422
+ tem|||Timne|temne
423
+ ter|||Tereno|tereno
424
+ tet|||Tetum|tetum
425
+ tgk||tg|Tajik|tadjik
426
+ tgl||tl|Tagalog|tagalog
427
+ tha||th|Thai|thaï
428
+ tib|bod|bo|Tibetan|tibétain
429
+ tig|||Tigre|tigré
430
+ tir||ti|Tigrinya|tigrigna
431
+ tiv|||Tiv|tiv
432
+ tkl|||Tokelau|tokelau
433
+ tlh|||Klingon; tlhIngan-Hol|klingon
434
+ tli|||Tlingit|tlingit
435
+ tmh|||Tamashek|tamacheq
436
+ tog|||Tonga (Nyasa)|tonga (Nyasa)
437
+ ton||to|Tonga (Tonga Islands)|tongan (Îles Tonga)
438
+ tpi|||Tok Pisin|tok pisin
439
+ tsi|||Tsimshian|tsimshian
440
+ tsn||tn|Tswana|tswana
441
+ tso||ts|Tsonga|tsonga
442
+ tuk||tk|Turkmen|turkmène
443
+ tum|||Tumbuka|tumbuka
444
+ tup|||Tupi languages|tupi, langues
445
+ tur||tr|Turkish|turc
446
+ tut|||Altaic languages|altaïques, langues
447
+ tvl|||Tuvalu|tuvalu
448
+ twi||tw|Twi|twi
449
+ tyv|||Tuvinian|touva
450
+ udm|||Udmurt|oudmourte
451
+ uga|||Ugaritic|ougaritique
452
+ uig||ug|Uighur; Uyghur|ouïgour
453
+ ukr||uk|Ukrainian|ukrainien
454
+ umb|||Umbundu|umbundu
455
+ und|||Undetermined|indéterminée
456
+ urd||ur|Urdu|ourdou
457
+ uzb||uz|Uzbek|ouszbek
458
+ vai|||Vai|vaï
459
+ ven||ve|Venda|venda
460
+ vie||vi|Vietnamese|vietnamien
461
+ vol||vo|Volapük|volapük
462
+ vot|||Votic|vote
463
+ wak|||Wakashan languages|wakashanes, langues
464
+ wal|||Wolaitta; Wolaytta|wolaitta; wolaytta
465
+ war|||Waray|waray
466
+ was|||Washo|washo
467
+ wel|cym|cy|Welsh|gallois
468
+ wen|||Sorbian languages|sorabes, langues
469
+ wln||wa|Walloon|wallon
470
+ wol||wo|Wolof|wolof
471
+ xal|||Kalmyk; Oirat|kalmouk; oïrat
472
+ xho||xh|Xhosa|xhosa
473
+ yao|||Yao|yao
474
+ yap|||Yapese|yapois
475
+ yid||yi|Yiddish|yiddish
476
+ yor||yo|Yoruba|yoruba
477
+ ypk|||Yupik languages|yupik, langues
478
+ zap|||Zapotec|zapotèque
479
+ zbl|||Blissymbols; Blissymbolics; Bliss|symboles Bliss; Bliss
480
+ zen|||Zenaga|zenaga
481
+ zgh|||Standard Moroccan Tamazight|amazighe standard marocain
482
+ zha||za|Zhuang; Chuang|zhuang; chuang
483
+ znd|||Zande languages|zandé, langues
484
+ zul||zu|Zulu|zoulou
485
+ zun|||Zuni|zuni
486
+ zxx|||No linguistic content; Not applicable|pas de contenu linguistique; non applicable
487
+ zza|||Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki|zaza; dimili; dimli; kirdki; kirmanjki; zazaki
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NEU
4
+ module MODS
5
+ # ISO 639 code -> English language name, for projecting a code-only
6
+ # languageTerm as something a reader (and a Solr facet) can use.
7
+ #
8
+ # This is the one place a code is translated, on purpose. The alternative --
9
+ # mapping in each consumer's display layer -- leaves Solr indexing "eng"
10
+ # while the page shows "English", so the language facet reads in codes. One
11
+ # table, read once, keeps the projection, the display and the index agreed.
12
+ #
13
+ # Roles are deliberately NOT translated here; a MARC relator is a display
14
+ # label, and the label vocabulary belongs to the consumer (see README).
15
+ module LanguageCodes
16
+ # The Library of Congress ISO 639-2 registry, vendored byte-for-byte from
17
+ # https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt so its
18
+ # provenance is a diff rather than a claim. Pipe-delimited:
19
+ # alpha3-bibliographic | alpha3-terminologic | alpha2 | English | French.
20
+ # It lives outside lib/*.rb because the English names carry non-ASCII and
21
+ # the source-purity spec holds the Ruby files to ASCII.
22
+ REGISTRY_PATH = File.expand_path("data/iso639-2.txt", __dir__)
23
+
24
+ # Every alpha-3 bibliographic, alpha-3 terminologic and alpha-2 code that
25
+ # the registry defines, each pointing at its English name. MODS records
26
+ # declare authority="iso639-2b" most often, but iso639-1 and the
27
+ # terminologic codes are equally valid, and a lookup table costs the same
28
+ # whether it holds one form or all three.
29
+ TERMS = File.readlines(REGISTRY_PATH, encoding: "bom|utf-8").each_with_object({}) do |line, terms|
30
+ bibliographic, terminologic, alpha2, english = line.chomp.split("|")
31
+ next if english.to_s.empty?
32
+
33
+ # The registry lists synonyms in preference order, so "Spanish;
34
+ # Castilian" becomes "Spanish". Rendering every synonym would make a
35
+ # facet value nobody would click.
36
+ name = english.split(";").first.strip
37
+ [bibliographic, terminologic, alpha2].each do |code|
38
+ terms[code] = name unless code.to_s.empty?
39
+ end
40
+ end.freeze
41
+
42
+ # The English name for a code, or the code itself when the registry does
43
+ # not define it. Never returns nil for a present value: an unrecognised
44
+ # code is still what the record says, and dropping it would lose data.
45
+ def self.term(code)
46
+ key = code.to_s.strip
47
+ return key if key.empty?
48
+
49
+ TERMS.fetch(key.downcase, key)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -12,6 +12,8 @@ module NEU
12
12
  # Empty-value conventions mirror Atlas: scalar fields are "" when absent
13
13
  # (matching `.text.squish` on an empty node set), except `permanent_url` and
14
14
  # `date_created`, which are nil when their node is absent. Arrays are [].
15
+ # Which fields are scalar and which are arrays is declared in FIELDS, not
16
+ # left to each method to decide.
15
17
  module Projection
16
18
  # --- Title ---------------------------------------------------------------
17
19
 
@@ -25,16 +27,19 @@ module NEU
25
27
  # rewrite the curator's characters in the preservation XML on the next save.
26
28
  # #access_title_parts is the normalised surface.
27
29
  def title_parts
28
- ti = primary_title_info
29
- {
30
- non_sort: child_text(ti, "mods:nonSort"),
31
- subtitle: child_text(ti, "mods:subTitle"),
32
- title: child_text(ti, "mods:title"),
33
- part_name: child_text(ti, "mods:partName"),
34
- part_number: child_text(ti, "mods:partNumber")
35
- }
30
+ title_parts_of(primary_title_info)
36
31
  end
37
32
 
33
+ # The variant titles, each composed and normalised like the main title.
34
+ # MODS repeats titleInfo, so a record may carry more than one of a type.
35
+ # These are what keeps the primary-title fallback's refusal to promote a
36
+ # variant from hiding anything: the variant still reaches a reader, under
37
+ # a label that says which kind of title it is.
38
+ def alternative_title = variant_titles("alternative")
39
+ def uniform_title = variant_titles("uniform")
40
+ def translated_title = variant_titles("translated")
41
+ def abbreviated_title = variant_titles("abbreviated")
42
+
38
43
  # Composed display title (the former Atlas MODSDecoration#plain_title), driven
39
44
  # off the scoped primary title.
40
45
  def plain_title
@@ -86,10 +91,21 @@ module NEU
86
91
  join_paragraphs(abstract_nodes)
87
92
  end
88
93
 
94
+ # Every top-level accessCondition joined, regardless of @type. Retained
95
+ # because it is the only projection that carries an untyped or
96
+ # unrecognised accessCondition, which the two typed fields below cannot
97
+ # see -- a consumer that renders only those needs this as its fallback.
89
98
  def access_condition
90
99
  join_paragraphs(doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE))
91
100
  end
92
101
 
102
+ # The two @type values MODS defines, projected apart. Collapsing them into
103
+ # one value presented an access *restriction* to a reader as a *licence*,
104
+ # which is the one defect in this area that misinforms someone about their
105
+ # rights rather than merely hiding a field.
106
+ def use_and_reproduction = access_conditions_of_type("use and reproduction")
107
+ def restriction_on_access = access_conditions_of_type("restriction on access")
108
+
93
109
  # --- Subjects ------------------------------------------------------------
94
110
 
95
111
  # The editable free-text keyword set (Cerberus simple form): topics under the
@@ -104,6 +120,18 @@ module NEU
104
120
  doc.xpath("/mods:mods/mods:subject/mods:topic", NAMESPACE).map { |t| clean(t.text) }
105
121
  end
106
122
 
123
+ # The other subject axes. Cerberus's IPTC ingest writes subject/geographic
124
+ # from the IPTC City and State fields, so this one was also being written
125
+ # on every batch and read back by nothing.
126
+ def geographic_subjects = texts_at("/mods:mods/mods:subject/mods:geographic")
127
+ def temporal_subjects = texts_at("/mods:mods/mods:subject/mods:temporal")
128
+
129
+ # Name subjects compose through the same display-value port as #names, so
130
+ # one person reads the same whether they authored the work or are its
131
+ # subject.
132
+ def personal_name_subjects = name_subjects("personal")
133
+ def corporate_name_subjects = name_subjects("corporate")
134
+
107
135
  # --- Names ---------------------------------------------------------------
108
136
 
109
137
  # All top-level names as { name:, role: }. `name` reproduces the `mods` gem's
@@ -140,26 +168,99 @@ module NEU
140
168
 
141
169
  # --- Scalars / simple arrays --------------------------------------------
142
170
 
171
+ # Prefer the type="text" term, and translate a code-only one through the
172
+ # ISO 639 registry. A record saying `eng` projects "English", so the
173
+ # display and the Solr language facet read the same value rather than the
174
+ # facet showing codes. An unrecognised code survives as itself.
143
175
  def languages
144
- doc.xpath("/mods:mods/mods:language", NAMESPACE).map do |lang|
145
- term = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE) ||
146
- lang.at_xpath("mods:languageTerm", NAMESPACE)
147
- clean(term&.text)
148
- end.compact
176
+ doc.xpath("/mods:mods/mods:language", NAMESPACE).filter_map do |lang|
177
+ text = lang.at_xpath("mods:languageTerm[@type='text']", NAMESPACE)
178
+ next clean(text.text) if text
179
+
180
+ code = clean(lang.at_xpath("mods:languageTerm", NAMESPACE)&.text)
181
+ code && LanguageCodes.term(code)
182
+ end
149
183
  end
150
184
 
151
- def resource_type = text_at("/mods:mods/mods:typeOfResource")
152
- def format = text_at("/mods:mods/mods:physicalDescription/mods:form")
153
- def extent = text_at("/mods:mods/mods:physicalDescription/mods:extent")
154
- def digital_origin = text_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
185
+ # MODS repeats typeOfResource, and repeats physicalDescription (and form and
186
+ # extent within one), so all four are :many. A record that is both text and
187
+ # a still image used to project as text alone.
188
+ def resource_type = texts_at("/mods:mods/mods:typeOfResource")
189
+ def format = texts_at("/mods:mods/mods:physicalDescription/mods:form")
190
+ def extent = texts_at("/mods:mods/mods:physicalDescription/mods:extent")
191
+ def digital_origin = texts_at("/mods:mods/mods:physicalDescription/mods:digitalOrigin")
155
192
 
156
193
  def genres
157
194
  doc.xpath("/mods:mods/mods:genre", NAMESPACE).map { |g| clean(g.text) }
158
195
  end
159
196
 
160
- def related_series
161
- doc.xpath("/mods:mods/mods:relatedItem[@type='series']/mods:titleInfo/mods:title", NAMESPACE)
162
- .map { |t| clean(t.text) }
197
+ # originInfo repeats, and so do publisher and edition within one. Cerberus's
198
+ # IPTC ingest writes the publisher from the IPTC Source field on every batch,
199
+ # so this element was being written into the preservation XML and then read
200
+ # back by nothing.
201
+ def publication_information = texts_at("/mods:mods/mods:originInfo/mods:publisher")
202
+ def edition = texts_at("/mods:mods/mods:originInfo/mods:edition")
203
+
204
+ # Every top-level note, keeping its @type. The type carries meaning -- a
205
+ # "statement of responsibility" is not a "funding" note -- so flattening
206
+ # them into bare strings would repeat the accessCondition mistake.
207
+ def notes
208
+ doc.xpath("/mods:mods/mods:note", NAMESPACE).filter_map do |node|
209
+ value = clean(node.text)
210
+ { type: clean(node["type"]), value: value } if value
211
+ end
212
+ end
213
+
214
+ # location repeats, and one location mixes kinds: a shelf mark and a URL
215
+ # are not interchangeable, and a consumer has to know which it holds
216
+ # before it can decide to linkify it. So the parts stay apart.
217
+ def location
218
+ doc.xpath("/mods:mods/mods:location", NAMESPACE).filter_map do |node|
219
+ entry = {
220
+ physical_location: child_text(node, "mods:physicalLocation"),
221
+ shelf_location: child_text(node, "mods:shelfLocation"),
222
+ url: child_text(node, "mods:url")
223
+ }
224
+ entry if entry.values.any?
225
+ end
226
+ end
227
+
228
+ # subject/cartographics, kept structured. Composing "scale ; projection
229
+ # coordinates" into one string is display policy, and this gem does not own
230
+ # that -- a consumer that wants only the coordinates should not have to
231
+ # unpick a sentence to get them.
232
+ def map_data
233
+ doc.xpath("/mods:mods/mods:subject/mods:cartographics", NAMESPACE).filter_map do |node|
234
+ entry = {
235
+ scale: child_text(node, "mods:scale"),
236
+ projection: child_text(node, "mods:projection"),
237
+ coordinates: child_text(node, "mods:coordinates")
238
+ }
239
+ entry if entry.values.any?
240
+ end
241
+ end
242
+
243
+ def related_series = related_item_titles("series")
244
+ def host_collections = related_item_titles("host")
245
+
246
+ # relatedItem @type values that already have a field of their own, so the
247
+ # catch-all below does not repeat them.
248
+ NAMED_RELATED_ITEM_TYPES = %w[series host].freeze
249
+
250
+ # Every other relatedItem, keeping its @type. MODS also defines
251
+ # constituent, otherFormat, original, preceding, succeeding, isReferencedBy
252
+ # and reviewOf, and a record carrying any of them projected nothing at all.
253
+ # The type rides along because "the print edition" and "reviewed in" are
254
+ # not the same relationship, and no consumer can recover which it holds
255
+ # from the title alone. An untyped relatedItem lands here with a nil type.
256
+ def related_items
257
+ doc.xpath("/mods:mods/mods:relatedItem", NAMESPACE).filter_map do |node|
258
+ type = clean(node["type"])
259
+ next if NAMED_RELATED_ITEM_TYPES.include?(type)
260
+
261
+ title = clean(node.at_xpath("mods:titleInfo/mods:title", NAMESPACE)&.text)
262
+ { type: type, title: title } if title
263
+ end
163
264
  end
164
265
 
165
266
  def identifiers
@@ -177,56 +278,112 @@ module NEU
177
278
  # instead of being guessed after it.
178
279
  W3CDTF_DATE = /\A(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\z/
179
280
 
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.
281
+ # The three originInfo dates, each paired with the granularity the record
282
+ # declared, as [value, precision]. value is nil if the element is absent,
283
+ # or "" if present but unparseable (mirrors Atlas's safe_date_parse
284
+ # rescue). precision is "year", "month" or "day", and nil whenever value
285
+ # is not a DateTime.
185
286
  #
186
287
  # The precision has to be captured here, at the only point where the shape
187
288
  # is still visible: a year-only date parses to January 1st, and no consumer
188
289
  # downstream can tell that month and day from a record that claimed them.
189
290
  # A preservation repository must not project a precision it was not given.
190
- def date_created_with_precision
191
- node = doc.at_xpath("/mods:mods/mods:originInfo/mods:dateCreated", NAMESPACE)
192
- return [nil, nil] unless node
193
-
194
- str = NEU::MODS.canonical_ws(node.text)
195
- return [nil, nil] if str.empty?
196
-
197
- parse_w3cdtf(str)
198
- end
291
+ #
292
+ # These stay :one where the sibling originInfo elements are :many. MODS
293
+ # repeats them, but a date carries a precision alongside it, and an array
294
+ # of values paired with an array of precisions is a shape no consumer
295
+ # wants. A record that ranges its dates uses point="start"/"end", which is
296
+ # its own projection question rather than a cardinality one.
297
+ def date_created_with_precision = date_with_precision("dateCreated")
298
+ def date_issued_with_precision = date_with_precision("dateIssued")
299
+ def copyright_date_with_precision = date_with_precision("copyrightDate")
199
300
 
200
301
  def date_created = date_created_with_precision.first
201
302
  def date_created_precision = date_created_with_precision.last
303
+ def date_issued = date_issued_with_precision.first
304
+ def date_issued_precision = date_issued_with_precision.last
305
+ def copyright_date = copyright_date_with_precision.first
306
+ def copyright_date_precision = copyright_date_with_precision.last
202
307
 
203
308
  # --- Full projection -----------------------------------------------------
204
309
 
310
+ # The field registry: the single declaration of what this gem projects.
311
+ # Field name => cardinality, :one or :many. The projection method of the
312
+ # same name owns the XPath; this row says the field exists and whether it
313
+ # is single- or multi-valued. #to_h is derived from it, and Atlas derives
314
+ # its Metadata::MODS attr_json set from it, so a field cannot be projected
315
+ # here and go undeclared there (or the reverse).
316
+ #
317
+ # Cardinality is the half that earns its keep. The at_xpath-versus-xpath
318
+ # choice here and the single-versus-array column choice in Atlas used to be
319
+ # made independently in two repos with nothing tying them together, which is
320
+ # how repeatable MODS elements ended up truncated to their first match.
321
+ # #cardinality_of checks each method against its row.
322
+ FIELDS = {
323
+ # titles
324
+ main_title: :one,
325
+ alternative_title: :many,
326
+ uniform_title: :many,
327
+ translated_title: :many,
328
+ abbreviated_title: :many,
329
+
330
+ names: :many,
331
+ languages: :many,
332
+ abstract: :one,
333
+
334
+ # origin
335
+ publication_information: :many,
336
+ edition: :many,
337
+ date_created: :one,
338
+ date_created_precision: :one,
339
+ date_issued: :one,
340
+ date_issued_precision: :one,
341
+ copyright_date: :one,
342
+ copyright_date_precision: :one,
343
+
344
+ # physical description
345
+ resource_type: :many,
346
+ genres: :many,
347
+ format: :many,
348
+ extent: :many,
349
+ digital_origin: :many,
350
+ notes: :many,
351
+
352
+ # subjects
353
+ topical_subjects: :many,
354
+ geographic_subjects: :many,
355
+ temporal_subjects: :many,
356
+ personal_name_subjects: :many,
357
+ corporate_name_subjects: :many,
358
+ map_data: :many,
359
+
360
+ # related items
361
+ related_series: :many,
362
+ host_collections: :many,
363
+ related_items: :many,
364
+
365
+ # identifiers and location
366
+ identifiers: :many,
367
+ permanent_url: :one,
368
+ location: :many,
369
+
370
+ # access
371
+ access_condition: :one,
372
+ use_and_reproduction: :one,
373
+ restriction_on_access: :one
374
+ }.freeze
375
+
205
376
  # The complete read projection, keyed to Atlas's Metadata::MODS attribute
206
377
  # names -- a drop-in source for `convert_xml_to_json`.
207
378
  def to_h
208
- date, date_precision = date_created_with_precision
209
-
210
- {
211
- main_title: access_title_parts,
212
- names: names,
213
- languages: languages,
214
- date_created: date,
215
- date_created_precision: date_precision,
216
- resource_type: resource_type,
217
- genres: genres,
218
- format: format,
219
- extent: extent,
220
- digital_origin: digital_origin,
221
- abstract: abstract,
222
- related_series: related_series,
223
- topical_subjects: topical_subjects,
224
- identifiers: identifiers,
225
- permanent_url: permanent_url,
226
- access_condition: access_condition
227
- }
379
+ FIELDS.keys.to_h { |field| [field, public_send(field)] }
228
380
  end
229
381
 
382
+ # The cardinality a projected value actually has, for checking a value
383
+ # against its FIELDS row. An Array is :many and anything else is :one, so a
384
+ # field declared :many that forgot to switch at_xpath for xpath is caught.
385
+ def self.cardinality_of(value) = value.is_a?(Array) ? :many : :one
386
+
230
387
  # The title parts as the access copy wants them: normalised like the
231
388
  # abstract, so a curly quote, an invisible format mark or a Windows-1252
232
389
  # control cannot reach Solr or a display template. Titles and prose share
@@ -235,6 +392,11 @@ module NEU
235
392
  title_parts.transform_values { |value| NEU::MODS.normalize(value.to_s) }
236
393
  end
237
394
 
395
+ # Atlas names this field main_title; the registry requires a method per
396
+ # field name, and #access_title_parts is the descriptive name for what it
397
+ # returns. Kept as an alias rather than a rename so both read well.
398
+ def main_title = access_title_parts
399
+
238
400
  private
239
401
 
240
402
  # --- helpers -------------------------------------------------------------
@@ -259,11 +421,58 @@ module NEU
259
421
  ["", nil]
260
422
  end
261
423
 
424
+ def date_with_precision(element)
425
+ node = doc.at_xpath("/mods:mods/mods:originInfo/mods:#{element}", NAMESPACE)
426
+ return [nil, nil] unless node
427
+
428
+ str = NEU::MODS.canonical_ws(node.text)
429
+ return [nil, nil] if str.empty?
430
+
431
+ parse_w3cdtf(str)
432
+ end
433
+
434
+ # Byte-faithful title parts off any titleInfo node, shared by #title_parts
435
+ # (which Cerberus pre-fills its edit forms from) and the variant titles.
436
+ def title_parts_of(node)
437
+ {
438
+ non_sort: child_text(node, "mods:nonSort"),
439
+ subtitle: child_text(node, "mods:subTitle"),
440
+ title: child_text(node, "mods:title"),
441
+ part_name: child_text(node, "mods:partName"),
442
+ part_number: child_text(node, "mods:partNumber")
443
+ }
444
+ end
445
+
446
+ # A variant title composed the way the access copy wants it: normalised
447
+ # first, like #access_title_parts, so a curly quote or an invisible format
448
+ # mark cannot reach Solr or a display template through this route either.
449
+ def variant_titles(type)
450
+ doc.xpath("/mods:mods/mods:titleInfo[@type='#{type}']", NAMESPACE).filter_map do |node|
451
+ parts = title_parts_of(node).transform_values { |value| NEU::MODS.normalize(value.to_s) }
452
+ clean(Projection.compose_title(parts))
453
+ end
454
+ end
455
+
456
+ def name_subjects(type)
457
+ doc.xpath("/mods:mods/mods:subject/mods:name[@type='#{type}']", NAMESPACE)
458
+ .filter_map { |node| name_display_value_w_date(node) }
459
+ end
460
+
461
+ def related_item_titles(type)
462
+ texts_at("/mods:mods/mods:relatedItem[@type='#{type}']/mods:titleInfo/mods:title")
463
+ end
464
+
262
465
  def text_at(xpath)
263
466
  node = doc.at_xpath(xpath, NAMESPACE)
264
467
  node ? NEU::MODS.canonical_ws(node.text) : ""
265
468
  end
266
469
 
470
+ # The :many counterpart of #text_at. Blank members drop out rather than
471
+ # arriving as nil, so a consumer mapping over the array cannot trip on one.
472
+ def texts_at(xpath)
473
+ doc.xpath(xpath, NAMESPACE).filter_map { |node| clean(node.text) }
474
+ end
475
+
267
476
  def child_text(parent, xpath)
268
477
  return nil unless parent
269
478
 
@@ -288,6 +497,15 @@ module NEU
288
497
  NEU::MODS.canonical_ws(str)
289
498
  end
290
499
 
500
+ # The schema leaves accessCondition/@type an open string, so match on the
501
+ # canonicalised, case-folded value rather than in the XPath: real records
502
+ # carry "Use and Reproduction" as readily as the MODS-recommended casing.
503
+ def access_conditions_of_type(type)
504
+ nodes = doc.xpath("/mods:mods/mods:accessCondition", NAMESPACE)
505
+ .select { |node| NEU::MODS.canonical_ws(node["type"].to_s).downcase == type }
506
+ join_paragraphs(nodes)
507
+ end
508
+
291
509
  def join_paragraphs(nodes)
292
510
  nodes.map { |n| NEU::MODS.normalize_paragraphs(n.text) }.reject(&:empty?).join("\n\n")
293
511
  end
@@ -8,12 +8,28 @@ module NEU
8
8
  # definition is the point: the node an editor changes is provably the node the
9
9
  # projection reads. Mixed into Document; operates on `doc`.
10
10
  module Selectors
11
- # Top-level primary titleInfo, falling back to the first top-level titleInfo.
12
- # Scoped to direct children of <mods:mods> so a relatedItem's nested
13
- # titleInfo (e.g. a series title) is never matched.
11
+ # Top-level primary titleInfo, falling back to the first top-level titleInfo
12
+ # that is NOT a variant. Scoped to direct children of <mods:mods> so a
13
+ # relatedItem's nested titleInfo (e.g. a series title) is never matched.
14
+ #
15
+ # MODS does not require usage="primary", so the fallback fires often, and
16
+ # an unfiltered one let document order decide the record's title. It also
17
+ # decided which node an edit form wrote to: MODSMerge overwrites the node
18
+ # this returns, so an alternative title reached here was destroyed on the
19
+ # next title edit, leaving the record with no primary title at all. nil is
20
+ # the right answer instead -- MODSMerge creates a proper primary titleInfo
21
+ # from nil, and each variant is projected under its own field.
14
22
  def primary_title_info
15
23
  doc.at_xpath("/mods:mods/mods:titleInfo[@usage='primary']", NAMESPACE) ||
16
- doc.at_xpath("/mods:mods/mods:titleInfo", NAMESPACE)
24
+ doc.xpath("/mods:mods/mods:titleInfo", NAMESPACE).reject { |ti| variant_title?(ti) }.first
25
+ end
26
+
27
+ # MODS enumerates titleInfo/@type as exactly abbreviated, translated,
28
+ # alternative and uniform -- every one of them a variant. So the presence
29
+ # of any @type marks a variant, which also keeps an unrecognised or
30
+ # misspelled value out of the write path rather than guessing at it.
31
+ def variant_title?(node)
32
+ !NEU::MODS.canonical_ws(node["type"].to_s).empty?
17
33
  end
18
34
 
19
35
  # All top-level <abstract> elements (MODS permits several).
data/lib/neu-mods.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "neu/mods/version"
4
4
  require_relative "neu/mods/canonicalize"
5
+ require_relative "neu/mods/language_codes"
5
6
  require_relative "neu/mods/selectors"
6
7
  require_relative "neu/mods/projection"
7
8
  require_relative "neu/mods/document"
@@ -24,6 +25,12 @@ module NEU
24
25
  # The MODS v3 namespace, as a Nokogiri xpath namespace hash.
25
26
  NAMESPACE = { "mods" => "http://www.loc.gov/mods/v3" }.freeze
26
27
 
28
+ # The projected field set and its cardinality (see Projection::FIELDS),
29
+ # surfaced here so a consumer deriving its own schema from it -- Atlas's
30
+ # Metadata::MODS attr_json set -- reads the shared contract off the top-level
31
+ # module instead of reaching into a mixin.
32
+ FIELDS = Projection::FIELDS
33
+
27
34
  module_function
28
35
 
29
36
  # Whitespace no-op guard (see Canonicalize).
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
@@ -68,7 +68,9 @@ files:
68
68
  - Rakefile
69
69
  - lib/neu-mods.rb
70
70
  - lib/neu/mods/canonicalize.rb
71
+ - lib/neu/mods/data/iso639-2.txt
71
72
  - lib/neu/mods/document.rb
73
+ - lib/neu/mods/language_codes.rb
72
74
  - lib/neu/mods/projection.rb
73
75
  - lib/neu/mods/selectors.rb
74
76
  - lib/neu/mods/version.rb