cocina_display 1.7.0 → 1.8.1

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: 46cfe681a820ad5986d0503157b5add9a8cb089af31e3afaba0ed2964d33bda6
4
- data.tar.gz: b60af2ecc9cfdc031e57ae98cb1093aa3b111a5c14dda40b71814e07a9f2d941
3
+ metadata.gz: 9b7ec735c0ad257e8313d365e8900790b9acc115f98607a61a3b4eb293a2cb9a
4
+ data.tar.gz: a9761a59329eb8095c055063ef63b5b9e9c99d1d754cc8af3b20d95f59ec1fa7
5
5
  SHA512:
6
- metadata.gz: 2c010edefb699c21c82a3c426befcb16e4fa0e72aeeda6e77c2ed8eba9fa1aa6e365e889fda6f94f6d118a3bde2a968e4ece610f918995721c14a1fc6fc5b723
7
- data.tar.gz: fa61cbcd25787c2ec552fff17f5282e425ff5b46d03f0b60476c017faab41f7f059bd7630e54c2fc753a6330cf87a41c12bda20fbd3bd602e567264cfa6b133b
6
+ metadata.gz: 1e522347186bddba536dd3139b6ce540a06724879d300caa296f96dbd29c866bc087756e3b95beb4b8b20541141b41ae60b1b892dcf49b30ffe8f26e3de67102
7
+ data.tar.gz: e87f404d0eaa5aaf3e7fbac2ff2effdfef1a644fa7ae02b4f29f9207804249eabcd25f9c992892a839a634750817d76c78e9e8f388a79a3e855f8cb5b782e23d
@@ -1,7 +1,8 @@
1
1
  # Map of language codes to language names used in Searchworks, ported from stanford-mods gem.
2
2
  # See: https://github.com/solrmarc/stanford-solr-marc/blob/master/stanford-sw/translation_maps/language_map.properties
3
3
 
4
- aaa: Afar
4
+ #???: null
5
+ aar: Afar
5
6
  abk: Abkhaz
6
7
  ace: Achinese
7
8
  ach: Acoli
@@ -11,7 +12,8 @@ afa: Afroasiatic (Other)
11
12
  afh: Afrihili (Artificial language)
12
13
  afr: Afrikaans
13
14
  ain: Ainu
14
- ajm: Aljamia
15
+ # ajm is deprecated
16
+ ajm: Aljamía
15
17
  aka: Akan
16
18
  akk: Akkadian
17
19
  alb: Albanian
@@ -24,12 +26,13 @@ anp: Angika
24
26
  apa: Apache languages
25
27
  ara: Arabic
26
28
  arc: Aramaic
27
- arg: Aragonese Spanish
29
+ arg: Aragonese
28
30
  arm: Armenian
29
31
  arn: Mapuche
30
32
  arp: Arapaho
31
33
  art: Artificial (Other)
32
34
  arw: Arawak
35
+ # ase from iso639-3
33
36
  ase: American Sign Language
34
37
  asm: Assamese
35
38
  ast: Bable
@@ -40,7 +43,7 @@ ave: Avestan
40
43
  awa: Awadhi
41
44
  aym: Aymara
42
45
  aze: Azerbaijani
43
- bad: Banda
46
+ bad: Banda languages
44
47
  bai: Bamileke languages
45
48
  bak: Bashkir
46
49
  bal: Baluchi
@@ -55,7 +58,7 @@ bem: Bemba
55
58
  ben: Bengali
56
59
  ber: Berber (Other)
57
60
  bho: Bhojpuri
58
- bih: Bihari
61
+ bih: Bihari (Other)
59
62
  bik: Bikol
60
63
  bin: Edo
61
64
  bis: Bislama
@@ -72,6 +75,7 @@ bur: Burmese
72
75
  byn: Bilin
73
76
  cad: Caddo
74
77
  cai: Central American Indian (Other)
78
+ # cam is deprecated
75
79
  cam: Khmer
76
80
  car: Carib
77
81
  cat: Catalan
@@ -83,7 +87,7 @@ chb: Chibcha
83
87
  che: Chechen
84
88
  chg: Chagatai
85
89
  chi: Chinese
86
- chk: Truk
90
+ chk: Chuukese
87
91
  chm: Mari
88
92
  chn: Chinook jargon
89
93
  cho: Choctaw
@@ -93,6 +97,7 @@ chu: Church Slavic
93
97
  chv: Chuvash
94
98
  chy: Cheyenne
95
99
  cmc: Chamic languages
100
+ cnr: Montenegrin
96
101
  cop: Coptic
97
102
  cor: Cornish
98
103
  cos: Corsican
@@ -110,8 +115,8 @@ dan: Danish
110
115
  dar: Dargwa
111
116
  day: Dayak
112
117
  del: Delaware
113
- den: Slave
114
- dgr: Dogrib
118
+ den: Slavey
119
+ dgr: Tlicho
115
120
  din: Dinka
116
121
  div: Divehi
117
122
  doi: Dogri
@@ -124,20 +129,25 @@ dyu: Dyula
124
129
  dzo: Dzongkha
125
130
  efi: Efik
126
131
  egy: Egyptian
132
+ # per RFC 5646 and ISO 15924
127
133
  egy-Egyd: Egyptian, Demotic
128
134
  eka: Ekajuk
129
135
  elx: Elamite
130
136
  eng: English
131
137
  enm: English, Middle (1100-1500)
132
138
  epo: Esperanto
139
+ # esk is deprecated
133
140
  esk: Eskimo languages
141
+ # esp is deprecated
134
142
  esp: Esperanto
135
143
  est: Estonian
144
+ # eth is deprecated
136
145
  eth: Ethiopic
137
146
  ewe: Ewe
138
147
  ewo: Ewondo
139
148
  fan: Fang
140
149
  fao: Faroese
150
+ # far is deprecated
141
151
  far: Faroese
142
152
  fat: Fanti
143
153
  fij: Fijian
@@ -146,17 +156,21 @@ fin: Finnish
146
156
  fiu: Finno-Ugrian (Other)
147
157
  fon: Fon
148
158
  fre: French
159
+ # fri is deprecated
149
160
  fri: Frisian
150
- frm: French, Middle (ca. 1400-1600)
151
- fro: French, Old (ca. 842-1400)
161
+ frm: French, Middle (ca. 1300-1600)
162
+ fro: French, Old (ca. 842-1300)
152
163
  frr: North Frisian
153
164
  frs: East Frisian
154
165
  fry: Frisian
155
166
  ful: Fula
156
167
  fur: Friulian
157
- gaa: Ga
168
+ gaa:
169
+ # gae is deprecated
158
170
  gae: Scottish Gaelic
171
+ # gag is deprecated
159
172
  gag: Galician
173
+ # gal is deprecated
160
174
  gal: Oromo
161
175
  gay: Gayo
162
176
  gba: Gbaya
@@ -176,12 +190,13 @@ gor: Gorontalo
176
190
  got: Gothic
177
191
  grb: Grebo
178
192
  grc: Greek, Ancient (to 1453)
179
- gre: Greek, Modern (1453- )
193
+ gre: Greek, Modern (1453-)
180
194
  grn: Guarani
181
195
  gsw: Swiss German
196
+ # gua is deprecated
182
197
  gua: Guarani
183
198
  guj: Gujarati
184
- gwi: Gwich'in
199
+ gwi: Gwich'in
185
200
  hai: Haida
186
201
  hat: Haitian French Creole
187
202
  hau: Hausa
@@ -189,7 +204,7 @@ haw: Hawaiian
189
204
  heb: Hebrew
190
205
  her: Herero
191
206
  hil: Hiligaynon
192
- him: Himachali
207
+ him: Western Pahari languages
193
208
  hin: Hindi
194
209
  hit: Hittite
195
210
  hmn: Hmong
@@ -212,9 +227,11 @@ inc: Indic (Other)
212
227
  ind: Indonesian
213
228
  ine: Indo-European (Other)
214
229
  inh: Ingush
230
+ # int is deprecated
215
231
  int: Interlingua (International Auxiliary Language Association)
216
232
  ipk: Inupiaq
217
233
  ira: Iranian (Other)
234
+ # iri is deprecated
218
235
  iri: Irish
219
236
  iro: Iroquoian (Other)
220
237
  ita: Italian
@@ -226,10 +243,10 @@ jrb: Judeo-Arabic
226
243
  kaa: Kara-Kalpak
227
244
  kab: Kabyle
228
245
  kac: Kachin
229
- kal: Kalatdlisut
246
+ kal: Kalâtdlisut
230
247
  kam: Kamba
231
248
  kan: Kannada
232
- kar: Karen
249
+ kar: Karen languages
233
250
  kas: Kashmiri
234
251
  kau: Kanuri
235
252
  kaw: Kawi
@@ -247,22 +264,25 @@ kok: Konkani
247
264
  kom: Komi
248
265
  kon: Kongo
249
266
  kor: Korean
250
- kos: Kusaie
267
+ kos: Kosraean
251
268
  kpe: Kpelle
252
269
  krc: Karachay-Balkar
253
270
  krl: Karelian
254
- kro: Kru
271
+ kro: Kru (Other)
255
272
  kru: Kurukh
256
273
  kua: Kuanyama
257
274
  kum: Kumyk
258
275
  kur: Kurdish
276
+ # kus is deprecated
259
277
  kus: Kusaie
260
- kut: Kutenai
278
+ kut: Kootenai
261
279
  lad: Ladino
262
- lah: Lahnda
263
- lam: Lamba
264
- lan: Occitan (post-1500)
280
+ lah: Lahndā
281
+ lam: Lamba (Zambia and Congo)
282
+ # lan is deprecated
283
+ lan: Occitan (post 1500)
265
284
  lao: Lao
285
+ # lap is deprecated
266
286
  lap: Sami
267
287
  lat: Latin
268
288
  lav: Latvian
@@ -272,11 +292,11 @@ lin: Lingala
272
292
  lit: Lithuanian
273
293
  lol: Mongo-Nkundu
274
294
  loz: Lozi
275
- ltz: Letzeburgesch
295
+ ltz: Luxembourgish
276
296
  lua: Luba-Lulua
277
297
  lub: Luba-Katanga
278
298
  lug: Ganda
279
- lui: Luiseno
299
+ lui: Luiseño
280
300
  lun: Lunda
281
301
  luo: Luo (Kenya and Tanzania)
282
302
  lus: Lushai
@@ -291,7 +311,8 @@ man: Mandingo
291
311
  mao: Maori
292
312
  map: Austronesian (Other)
293
313
  mar: Marathi
294
- mas: Masai
314
+ mas: Maasai
315
+ # max is deprecated
295
316
  max: Manx
296
317
  may: Malay
297
318
  mdf: Moksha
@@ -302,6 +323,7 @@ mic: Micmac
302
323
  min: Minangkabau
303
324
  # mis: Miscellaneous languages
304
325
  mkh: Mon-Khmer (Other)
326
+ # mla is deprecated
305
327
  mla: Malagasy
306
328
  mlg: Malagasy
307
329
  mlt: Maltese
@@ -309,9 +331,10 @@ mnc: Manchu
309
331
  mni: Manipuri
310
332
  mno: Manobo languages
311
333
  moh: Mohawk
334
+ # mol is deprecated
312
335
  mol: Moldavian
313
336
  mon: Mongolian
314
- mos: Moore
337
+ mos: Mooré
315
338
  # mul: Multiple languages
316
339
  mun: Munda (Other)
317
340
  mus: Creek
@@ -334,7 +357,7 @@ nia: Nias
334
357
  nic: Niger-Kordofanian (Other)
335
358
  niu: Niuean
336
359
  nno: Norwegian (Nynorsk)
337
- nob: Norwegian (Bokmal)
360
+ nob: Norwegian (Bokmål)
338
361
  nog: Nogai
339
362
  non: Old Norse
340
363
  nor: Norwegian
@@ -368,10 +391,10 @@ phi: Philippine (Other)
368
391
  phn: Phoenician
369
392
  pli: Pali
370
393
  pol: Polish
371
- pon: Ponape
394
+ pon: Pohnpeian
372
395
  por: Portuguese
373
396
  pra: Prakrit languages
374
- pro: Provencal (to 1500)
397
+ pro: Provençal (to 1500)
375
398
  pus: Pushto
376
399
  que: Quechua
377
400
  raj: Rajasthani
@@ -391,18 +414,22 @@ sai: South American Indian (Other)
391
414
  sal: Salishan languages
392
415
  sam: Samaritan Aramaic
393
416
  san: Sanskrit
417
+ # sao is deprecated
394
418
  sao: Samoan
395
419
  sas: Sasak
396
420
  sat: Santali
421
+ # scc is deprecated
397
422
  scc: Serbian
398
423
  scn: Sicilian Italian
399
424
  sco: Scots
425
+ # scr is deprecated
400
426
  scr: Croatian
401
427
  sel: Selkup
402
428
  sem: Semitic (Other)
403
429
  sga: Irish, Old (to 1100)
404
430
  sgn: Sign languages
405
431
  shn: Shan
432
+ # sho is deprecated
406
433
  sho: Shona
407
434
  sid: Sidamo
408
435
  sin: Sinhalese
@@ -420,6 +447,7 @@ smo: Samoan
420
447
  sms: Skolt Sami
421
448
  sna: Shona
422
449
  snd: Sindhi
450
+ # snh is deprecated
423
451
  snh: Sinhalese
424
452
  snk: Soninke
425
453
  sog: Sogdian
@@ -432,6 +460,7 @@ srn: Sranan
432
460
  srp: Serbian
433
461
  srr: Serer
434
462
  ssa: Nilo-Saharan (Other)
463
+ # sso is deprecated
435
464
  sso: Sotho
436
465
  ssw: Swazi
437
466
  suk: Sukuma
@@ -440,14 +469,18 @@ sus: Susu
440
469
  sux: Sumerian
441
470
  swa: Swahili
442
471
  swe: Swedish
472
+ # swz is deprecated
443
473
  swz: Swazi
444
474
  syc: Syriac
445
475
  syr: Syriac, Modern
476
+ # tag is deprecated
446
477
  tag: Tagalog
447
478
  tah: Tahitian
448
479
  tai: Tai (Other)
480
+ # taj is deprecated
449
481
  taj: Tajik
450
482
  tam: Tamil
483
+ # tar is deprecated
451
484
  tar: Tatar
452
485
  tat: Tatar
453
486
  tel: Telugu
@@ -458,7 +491,7 @@ tgk: Tajik
458
491
  tgl: Tagalog
459
492
  tha: Thai
460
493
  tib: Tibetan
461
- tig: Tigre
494
+ tig: Tigré
462
495
  tir: Tigrinya
463
496
  tiv: Tiv
464
497
  tkl: Tokelauan
@@ -468,10 +501,12 @@ tmh: Tamashek
468
501
  tog: Tonga (Nyasa)
469
502
  ton: Tongan
470
503
  tpi: Tok Pisin
504
+ # tru is deprecated
471
505
  tru: Truk
472
506
  tsi: Tsimshian
473
507
  tsn: Tswana
474
508
  tso: Tsonga
509
+ # tsw is deprecated
475
510
  tsw: Tswana
476
511
  tuk: Turkmen
477
512
  tum: Tumbuka
@@ -492,17 +527,17 @@ uzb: Uzbek
492
527
  vai: Vai
493
528
  ven: Venda
494
529
  vie: Vietnamese
495
- vol: Volapuk
530
+ vol: Volapük
496
531
  vot: Votic
497
532
  wak: Wakashan languages
498
- wal: Walamo
533
+ wal: Wolayta
499
534
  war: Waray
500
- was: Washo
535
+ was: Washoe
501
536
  wel: Welsh
502
- wen: Sorbian languages
537
+ wen: Sorbian (Other)
503
538
  wln: Walloon
504
539
  wol: Wolof
505
- xal: Kalmyk
540
+ xal: Oirat
506
541
  xho: Xhosa
507
542
  yao: Yao (Africa)
508
543
  yap: Yapese
@@ -513,7 +548,7 @@ zap: Zapotec
513
548
  zbl: Blissymbolics
514
549
  zen: Zenaga
515
550
  zha: Zhuang
516
- znd: Zande
551
+ znd: Zande languages
517
552
  zul: Zulu
518
553
  zun: Zuni
519
554
  # zxx: null
@@ -108,11 +108,13 @@ module CocinaDisplay
108
108
  end
109
109
 
110
110
  # All contributors for the object, including authors, editors, etc.
111
- # @note Does not include contributors attached to events.
111
+ # Checks both description.contributor and description.event.contributor.
112
112
  # @return [Array<Contributor>]
113
113
  def contributors
114
- @contributors ||= path("$.description.contributor.*")
115
- .map { |c| CocinaDisplay::Contributors::Contributor.new(c) }
114
+ @contributors ||= Enumerator::Chain.new(
115
+ path("$.description.contributor.*"),
116
+ path("$.description.event.*.contributor.*")
117
+ ).map { |c| CocinaDisplay::Contributors::Contributor.new(c) }
116
118
  end
117
119
 
118
120
  # All contributors with an "author" role.
@@ -145,7 +147,13 @@ module CocinaDisplay
145
147
  # @return [Array<Contributor>]
146
148
  def additional_contributors
147
149
  return [] if contributors.empty? || contributors.one?
148
- contributors - [main_contributor]
150
+ contributors.reject { |c| imprint_contributors.include?(c) } - [main_contributor]
151
+ end
152
+
153
+ # The contributors associated with imprint events (usually publishers).
154
+ # @return [Array<Contributor>]
155
+ def imprint_contributors
156
+ imprint_events.flat_map(&:contributors).uniq
149
157
  end
150
158
  end
151
159
  end
@@ -38,11 +38,10 @@ module CocinaDisplay
38
38
  # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
39
39
  # @return [Array<Integer>, nil]
40
40
  # @note 6 BCE will appear as -5; 4 CE will appear as 4.
41
- def pub_year_int_range(ignore_qualified: false)
41
+ def pub_year_ints(ignore_qualified: false)
42
42
  date = pub_date(ignore_qualified: ignore_qualified)
43
43
  return unless date
44
44
 
45
- date = date.as_interval if date.is_a? CocinaDisplay::Dates::DateRange
46
45
  date.to_a.map(&:year).compact.uniq.sort
47
46
  end
48
47
 
@@ -199,15 +199,13 @@ module CocinaDisplay
199
199
  when "manuscript", "mixed material"
200
200
  values << "Archive/Manuscript"
201
201
  when "moving image"
202
- values << "Video"
202
+ values << "Video/Film"
203
203
  when "notated music"
204
204
  values << "Music score"
205
205
  when "software, multimedia"
206
206
  # Prevent GIS datasets from being labeled as "Software"
207
207
  values << "Software/Multimedia" unless cartographic? || dataset?
208
- when "sound recording-musical"
209
- values << "Music recording"
210
- when "sound recording-nonmusical", "sound recording"
208
+ when "sound recording-musical", "sound recording-nonmusical", "sound recording"
211
209
  values << "Sound recording"
212
210
  when "still image"
213
211
  values << "Image"
@@ -216,7 +214,8 @@ module CocinaDisplay
216
214
  # 2 records currently (2025) in Searchworks do this, but it is real.
217
215
  if periodical? || archived_website?
218
216
  values << "Journal/Periodical" if periodical?
219
- values << "Archived website" if archived_website?
217
+ values << "Website" if archived_website?
218
+ values << "Website|Archived website" if archived_website?
220
219
  else
221
220
  values << "Book"
222
221
  end
@@ -13,9 +13,10 @@ module CocinaDisplay
13
13
 
14
14
  # All valid coordinate data formatted for indexing into a Solr RPT field.
15
15
  # @note This type of field accommodates both points and bounding boxes.
16
+ # @note In WKT, points have longitude first, unlike {coordinates_as_point}.
16
17
  # @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#rpt
17
18
  # @return [Array<String>]
18
- # @example ["POINT(34.0522 -118.2437)", "POLYGON((-118.2437 34.0522, -118.2437 34.1996, -117.9522 34.1996, -117.9522 34.0522, -118.2437 34.0522))"]
19
+ # @example ["POINT(-118.2437 34.0522)", "POLYGON((-118.2437 34.0522, -118.2437 34.1996, -117.9522 34.1996, -117.9522 34.0522, -118.2437 34.0522))"]
19
20
  def coordinates_as_wkt
20
21
  coordinate_objects.map(&:as_wkt).uniq
21
22
  end
@@ -8,8 +8,7 @@ module CocinaDisplay
8
8
  # @example
9
9
  # record.druid #=> "druid:bb099mt5053"
10
10
  def druid
11
- cocina_doc["externalIdentifier"] ||
12
- cocina_doc.dig("description", "purl")&.split("/")&.last
11
+ cocina_doc["externalIdentifier"] || purl_url&.split("/")&.last
13
12
  end
14
13
 
15
14
  # The DRUID for the object, without the +druid:+ prefix.
@@ -64,10 +63,12 @@ module CocinaDisplay
64
63
  folio_hrid || bare_druid
65
64
  end
66
65
 
67
- # Identifier objects extracted from the Cocina metadata.
66
+ # All identifier objects, optionally filtered by type.
67
+ # @param type [String, nil] The type of identifier to filter by (e.g. "DOI").
68
+ # @note Type matching is case insensitive.
68
69
  # @return [Array<Identifier>]
69
- def identifiers
70
- @identifiers ||= path("$.description.identifier[*]").map { |id| Identifier.new(id) } + Array(doi_from_identification)
70
+ def identifiers(type: nil)
71
+ type.present? ? all_identifiers.filter { |id| id.type&.casecmp?(type) } : all_identifiers
71
72
  end
72
73
 
73
74
  # Labelled display data for identifiers.
@@ -78,6 +79,12 @@ module CocinaDisplay
78
79
 
79
80
  private
80
81
 
82
+ # All identifier objects extracted from the Cocina metadata.
83
+ # @return [Array<Identifier>]
84
+ def all_identifiers
85
+ @identifiers ||= path("$.description.identifier[*]").map { |id| Identifier.new(id) } + Array(doi_from_identification)
86
+ end
87
+
81
88
  # Synthetic Identifier object for a DOI in the identification block.
82
89
  # @return [Array<Identifier>]
83
90
  def doi_from_identification
@@ -8,6 +8,25 @@ module CocinaDisplay
8
8
  @notes ||= path("$.description.note.*").map { |note| CocinaDisplay::Note.new(note) }
9
9
  end
10
10
 
11
+ # Text of all abstract notes.
12
+ # @return [Array<String>]
13
+ def abstracts
14
+ notes.select(&:abstract?).map(&:to_s).compact_blank
15
+ end
16
+
17
+ # Text of all table of contents notes.
18
+ # @return [Array<String>]
19
+ def tables_of_contents
20
+ notes.select(&:table_of_contents?).map(&:to_s).compact_blank
21
+ end
22
+
23
+ # Preferred citation for the object.
24
+ # If there are multiple notes with this type, uses the first.
25
+ # @return [String, nil]
26
+ def preferred_citation
27
+ notes.find(&:preferred_citation?)&.to_s
28
+ end
29
+
11
30
  # Abstract metadata for display.
12
31
  # @return [Array<CocinaDisplay::DisplayData>]
13
32
  def abstract_display_data
@@ -4,23 +4,44 @@ module CocinaDisplay
4
4
  module Concerns
5
5
  # Methods for inspecting structural metadata (e.g. file hierarchy)
6
6
  module Structural
7
+ # Structured data for all file sets in the object.
8
+ # Each fileset contains one or more files.
9
+ # @return [Array<CocinaDisplay::Structural::FileSet>]
10
+ # @example
11
+ # record.filesets.each do |fileset|
12
+ # puts fileset.type #=> "image"
13
+ # puts fileset.label #=> "High Resolution Images"
14
+ # end
15
+ def filesets
16
+ @filesets ||= path("$.structural.contains.*").map do |fileset|
17
+ CocinaDisplay::Structural::FileSet.new(fileset, druid: bare_druid, base_url: stacks_base_url)
18
+ end
19
+ end
20
+
7
21
  # Structured data for all individual files in the object.
8
22
  # Traverses nested FileSet structure to return a flattened array.
9
- # @return [Array<Hash>]
23
+ # @return [Array<CocinaDisplay::Structural::File>]
10
24
  # @example
11
25
  # record.files.each do |file|
12
- # puts file["filename"] #=> "image1.jpg"
13
- # puts file["size"] #=> 123456
26
+ # puts file.filename #=> "image1.jpg"
27
+ # puts file.size #=> 123456
14
28
  # end
15
29
  def files
16
- @files ||= path("$.structural.contains.*.structural.contains.*").search
30
+ filesets.flat_map(&:files)
17
31
  end
18
32
 
19
33
  # All unique MIME types of files in this object.
20
34
  # @return [Array<String>]
21
35
  # @example ["image/jpeg", "application/pdf"]
22
36
  def file_mime_types
23
- files.pluck("hasMimeType").uniq
37
+ files.map(&:mime_type).compact.uniq
38
+ end
39
+
40
+ # All unique types of filesets in this object.
41
+ # @return [Array<String>]
42
+ # @example ["image", "document"]
43
+ def fileset_types
44
+ filesets.map(&:type).compact.uniq
24
45
  end
25
46
 
26
47
  # Human-readable string representation of {total_file_size_int}.
@@ -34,7 +55,25 @@ module CocinaDisplay
34
55
  # @return [Integer]
35
56
  # @example 2621440
36
57
  def total_file_size_int
37
- files.pluck("size").sum
58
+ files.map(&:size).compact.sum
59
+ end
60
+
61
+ # URL to a thumbnail image for this object, if any.
62
+ # @note Uses the IIIF image server to generate an image of the given size.
63
+ # @param region [String] Desired region of the image (e.g., "full", "square", "x,y,w,h", "pct:x,y,w,h").
64
+ # @param width [String] Desired width of the image in pixels (use "!" prefix to preserve aspect ratio).
65
+ # @param height [String] Desired height of the image in pixels.
66
+ # @return [String, nil]
67
+ # @example "https://stacks.stanford.edu/image/iiif/ts786ny5936%2FPC0170_s1_E_0204.jp2/full/!400,400/0/default.jpg"
68
+ def thumbnail_url(region: "full", width: "!400", height: "400")
69
+ thumbnail_file&.iiif_url(region: region, width: width, height: height)
70
+ end
71
+
72
+ # True if the object has a usable thumbnail file.
73
+ # @note Does not attempt to crawl virtual object members for thumbnails.
74
+ # @return [Boolean]
75
+ def thumbnail?
76
+ thumbnail_file.present?
38
77
  end
39
78
 
40
79
  # DRUIDs of collections this object is a member of.
@@ -44,17 +83,36 @@ module CocinaDisplay
44
83
  path("$.structural.isMemberOf.*").map { |druid| druid.delete_prefix("druid:") }
45
84
  end
46
85
 
86
+ # Whether this object is a virtual object.
87
+ # @return [Boolean]
47
88
  def virtual_object?
48
- return false if path("$.structural.contains.*").any?
89
+ return false if filesets.any?
49
90
 
50
91
  path("$.structural.hasMemberOrders.*.members.*").any?
51
92
  end
52
93
 
94
+ # DRUIDs of members of this virtual object.
95
+ # @return [Array<String>]
96
+ # @example ["ts786ny5936", "tp006ms8736", "tj297ys4758"]
53
97
  def virtual_object_members
54
98
  return [] unless virtual_object?
55
99
 
56
100
  path("$.structural.hasMemberOrders.*.members.*").map { |druid| druid.delete_prefix("druid:") }
57
101
  end
102
+
103
+ # DRUIDs of virtual objects this object is a part of.
104
+ # @return [Array<String>]
105
+ # @example "hj097bm8879"
106
+ def virtual_object_parents
107
+ related_resources.filter { |res| res.type == "part of" }.map(&:druid).compact_blank
108
+ end
109
+
110
+ # The thumbnail file for this object, if any.
111
+ # Prefers files marked as thumbnails; falls back to any JP2 image.
112
+ # @return [CocinaDisplay::Structural::File, nil]
113
+ def thumbnail_file
114
+ files.find(&:thumbnail?) || files.find(&:jp2_image?)
115
+ end
58
116
  end
59
117
  end
60
118
  end
@@ -84,7 +84,7 @@ module CocinaDisplay
84
84
  return
85
85
  end
86
86
 
87
- sanitized = value.gsub(/^[\[]+/, "").gsub(/[\.\]]+$/, "")
87
+ sanitized = value.gsub(/^\[+/, "").gsub(/[.\]]+$/, "")
88
88
  sanitized = value.rjust(4, "0") if /^\d{3}$/.match?(value)
89
89
 
90
90
  sanitized
@@ -291,25 +291,17 @@ module CocinaDisplay
291
291
  return value.sub(/(\d{2})(\d{2})-(\d{2})/, '\1\2-\1\3')
292
292
  end
293
293
 
294
- value.gsub(/(?<![\d])(\d{1,3})([xu-]{1,3})/i) { "#{Regexp.last_match(1)}#{"0" * Regexp.last_match(2).length}" }.scan(/[\d-]/).join
294
+ value.gsub(/(?<!\d)(\d{1,3})([xu-]{1,3})/i) { "#{Regexp.last_match(1)}#{"0" * Regexp.last_match(2).length}" }.scan(/[\d-]/).join
295
295
  end
296
296
 
297
297
  # Decoded version of the date with "BCE" or "CE". Strips leading zeroes.
298
298
  # @param allowed_precisions [Array<Symbol>] List of allowed precisions for the output.
299
299
  # Defaults to [:day, :month, :year, :decade, :century, :unknown].
300
- # @param ignore_unparseable [Boolean] Return nil instead of the original value if it couldn't be parsed
301
- # @param display_original_value [Boolean] Return the original value if it was not encoded
302
300
  # @return [String]
303
- def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century, :unknown], ignore_unparseable: false, display_original_value: true)
304
- return if ignore_unparseable && !parsed_date?
305
- return value.strip unless parsed_date?
306
-
307
- if display_original_value
308
- unless encoding?
309
- return value.strip unless value =~ /^-?\d+$/ || value =~ /^[\dXxu?-]{4}$/
310
- end
301
+ def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century, :unknown])
302
+ if !parsed_date? || (!encoding? && value !~ /^-?\d+$/ && value !~ /^[\dXxu?-]{4}$/)
303
+ return value.strip
311
304
  end
312
-
313
305
  if date.is_a?(EDTF::Interval)
314
306
  range = [
315
307
  Date.format_date(date.min, date.min.precision, allowed_precisions),
@@ -342,18 +334,25 @@ module CocinaDisplay
342
334
  format(qualified_format, decoded_value)
343
335
  end
344
336
 
345
- # Range between earliest possible date and latest possible date.
346
- # @note Some encodings support disjoint sets of ranges, so this method could be less accurate than {#to_a}.
347
- # @return [Range]
337
+ # Range of {Date}s between earliest possible date and latest possible date.
338
+ # @note Output has day precision, using the first day/month if unspecified.
339
+ # @note If the range is open-ended, uses today's date as the end date.
340
+ # @note {EDTF::Set}s can be disjoint ranges, but this method will return the full span, unlike {#to_a}.
341
+ # @return [Range<Date>, nil]
348
342
  def as_range
349
- return unless earliest_date && latest_date
343
+ return unless earliest_date || latest_date
350
344
 
351
- earliest_date..latest_date
345
+ start = earliest_date || latest_date
346
+ stop = latest_date || ::Date.today
347
+
348
+ start..stop
352
349
  end
353
350
 
354
- # Array of all dates that fall into the range of possible dates in the data.
355
- # @note Some encodings support disjoint sets of ranges, so this method could be more accurate than {#as_range}.
356
- # @return [Array]
351
+ # Array of all individual {Date}s that are described by the data.
352
+ # @note Output dates will have the same precision as the input date (e.g. year vs day).
353
+ # @note If the range is open-ended, uses today's date as the end date.
354
+ # @note {EDTF::Set}s can be disjoint ranges; unlike {#as_range} this method will respect any gaps.
355
+ # @return [Array<Date>]
357
356
  def to_a
358
357
  case date
359
358
  when EDTF::Set
@@ -363,8 +362,6 @@ module CocinaDisplay
363
362
  end
364
363
  end
365
364
 
366
- private
367
-
368
365
  class << self
369
366
  # Returns the date in the format specified by the precision.
370
367
  # Supports e.g. retrieving year precision when the actual date is more precise.
@@ -444,6 +441,8 @@ module CocinaDisplay
444
441
  end
445
442
  end
446
443
 
444
+ private
445
+
447
446
  # Expand placeholders like "19XX" into an object representing the full range.
448
447
  # @note This is different from dates with an explicit start/end in the Cocina.
449
448
  # @see CocinaDisplay::Dates::DateRange
@@ -523,13 +522,11 @@ module CocinaDisplay
523
522
  # MARC date parser; similar to EDTF but with some MARC-specific encodings.
524
523
  class MarcFormat < Date
525
524
  def self.normalize_to_edtf(value)
526
- return nil if value == "9999" || value == "uuuu" || value == "||||"
525
+ return nil if value == "9999" || value == "||||"
527
526
 
528
527
  super
529
528
  end
530
529
 
531
- private
532
-
533
530
  def earliest_date
534
531
  if value == "1uuu"
535
532
  ::Date.parse("1000-01-01")
@@ -545,6 +542,8 @@ module CocinaDisplay
545
542
  super
546
543
  end
547
544
  end
545
+
546
+ private
548
547
  end
549
548
 
550
549
  # Base class for date formats that match using a regex.
@@ -600,7 +599,7 @@ module CocinaDisplay
600
599
 
601
600
  # Extractor for dates encoded as Roman numerals.
602
601
  class RomanNumeralYearFormat < ExtractorDateFormat
603
- REGEX = /(?<![A-Za-z\.])(?<year>[MCDLXVI\.]+)(?![A-Za-z])/
602
+ REGEX = /(?<![A-Za-z.])(?<year>[MCDLXVI.]+)(?![A-Za-z])/
604
603
 
605
604
  def self.normalize_to_edtf(text)
606
605
  matches = text.match(REGEX)
@@ -14,8 +14,8 @@ module CocinaDisplay
14
14
  # Create the individual dates; if no encoding/type declared give them
15
15
  # top-level encoding/type
16
16
  dates = cocina["structuredValue"].map do |sv|
17
+ sv["encoding"] ||= cocina["encoding"]
17
18
  date = Date.from_cocina(sv)
18
- date.encoding ||= cocina.dig("encoding", "code")
19
19
  date.type ||= cocina["type"]
20
20
  date
21
21
  end
@@ -44,7 +44,7 @@ module CocinaDisplay
44
44
  # @see CocinaDisplay::Date#value
45
45
  # @return [Array<String>]
46
46
  def value
47
- [start&.value, stop&.value]
47
+ [start&.value, stop&.value].compact
48
48
  end
49
49
 
50
50
  # Key used to sort this date range. Respects BCE/CE ordering and precision.
@@ -52,7 +52,7 @@ module CocinaDisplay
52
52
  # @see CocinaDisplay::Date#sort_key
53
53
  # @return [String]
54
54
  def sort_key
55
- [start&.sort_key, stop&.sort_key].join(" - ")
55
+ [start&.sort_key, stop&.sort_key].compact.join(" - ")
56
56
  end
57
57
 
58
58
  # Base values of start/end as single string. Used for comparison/deduping.
@@ -116,7 +116,7 @@ module CocinaDisplay
116
116
  [
117
117
  start&.decoded_value(**kwargs),
118
118
  stop&.decoded_value(**kwargs)
119
- ].uniq.join(" - ")
119
+ ].uniq.join(" - ").strip
120
120
  end
121
121
 
122
122
  # Decoded range with "BCE" or "CE" and qualifier markers applied.
@@ -137,12 +137,34 @@ module CocinaDisplay
137
137
  end
138
138
  end
139
139
 
140
- # Express the range as an EDTF::Interval between the start and stop dates.
141
- # @return [EDTF::Interval]
142
- def as_interval
143
- interval_start = start&.date&.edtf || "open"
144
- interval_stop = stop&.date&.edtf || "open"
145
- ::Date.edtf("#{interval_start}/#{interval_stop}")
140
+ # Earliest possible date encoded in data, respecting unspecified/imprecise info.
141
+ # @return [Date]
142
+ # @return [nil] if no start or stop date is parsable
143
+ def earliest_date
144
+ start&.earliest_date || stop&.earliest_date
145
+ end
146
+
147
+ # Latest possible date encoded in data, respecting unspecified/imprecise info.
148
+ # @note If the range is open-ended, uses today's date as the end date.
149
+ # @return [Date]
150
+ # @return [nil] if open-ended range or stop is not parsable
151
+ def latest_date
152
+ stop&.latest_date || ::Date.today
153
+ end
154
+
155
+ # Array of all individual {Date}s that are described by the data.
156
+ # @note Output dates will have the same precision as the input date (e.g. year vs day).
157
+ # @note {EDTF::Set}s can be disjoint ranges; unlike {#as_range} this method will respect any gaps.
158
+ # @note If the range is open-ended, uses today's date as the end date.
159
+ # @return [Array<Date>]
160
+ def to_a
161
+ start_dates = start&.to_a || []
162
+ stop_dates = stop&.to_a || []
163
+
164
+ return [] if start_dates.empty? && stop_dates.empty?
165
+ return as_range.to_a if start_dates.one? && stop_dates.one? || stop_dates.empty?
166
+
167
+ [start_dates, stop_dates].flatten.sort.uniq
146
168
  end
147
169
  end
148
170
  end
@@ -23,7 +23,7 @@ module CocinaDisplay
23
23
  # The date portion of the imprint statement, comprising all unique dates.
24
24
  # @return [String]
25
25
  def date_str
26
- Utils.compact_and_join(unique_dates_for_display.map(&:qualified_value))
26
+ Utils.compact_and_join(unique_dates_for_display.map(&:qualified_value), delimiter: "; ")
27
27
  end
28
28
 
29
29
  # The editions portion of the imprint statement, combining all edition notes.
@@ -71,7 +71,7 @@ module CocinaDisplay
71
71
  # Remove any ranges that duplicate part of an unencoded non-range date
72
72
  ranges, singles = deduped_dates.partition { |date| date.is_a?(CocinaDisplay::Dates::DateRange) }
73
73
  unencoded_singles_dates = singles.reject(&:encoding?).flat_map(&:to_a)
74
- ranges.reject! { |range| unencoded_singles_dates.any? { |date| range.as_interval.include?(date) } }
74
+ ranges.reject! { |date_range| unencoded_singles_dates.any? { |date| date_range.as_range.include?(date) } }
75
75
 
76
76
  (singles + ranges).sort
77
77
  end
@@ -2,10 +2,19 @@ module CocinaDisplay
2
2
  module Forms
3
3
  # A Resource Type form associated with part or all of a Cocina object.
4
4
  class ResourceType < Form
5
- # Resource types are lowercased for display.
5
+ # Resource types are lowercased for display, except self-deposit types.
6
6
  # @return [String]
7
7
  def to_s
8
- super&.downcase
8
+ stanford_self_deposit? ? flat_value : flat_value.downcase
9
+ end
10
+
11
+ # For self-deposit resource types, the flat value comprises primary and any subtypes.
12
+ # @return [String]
13
+ def flat_value
14
+ return super unless stanford_self_deposit?
15
+ return primary_type unless subtypes.any?
16
+
17
+ "#{primary_type} (#{subtypes.join(", ")})"
9
18
  end
10
19
 
11
20
  # Is this a Stanford self-deposit resource type?
@@ -33,6 +42,30 @@ module CocinaDisplay
33
42
  def type_label
34
43
  (I18n.t("cocina_display.field_label.form.genre") if stanford_self_deposit?) || super
35
44
  end
45
+
46
+ # The primary type, if this is a structured self-deposit resource type.
47
+ # @return [String, nil]
48
+ def primary_type
49
+ type_components["type"].first
50
+ end
51
+
52
+ # The subtypes, if this is a structured self-deposit resource type.
53
+ # @return [Array<String>]
54
+ def subtypes
55
+ type_components["subtype"] || []
56
+ end
57
+
58
+ # A hash containing the destructured resource type and subtypes, if any.
59
+ # @return [Hash<String, Array<String>>]
60
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#form-part-types-for-structured-value
61
+ # @note Only used by self-deposit resource types.
62
+ def type_components
63
+ Utils.flatten_nested_values(cocina).each_with_object({}) do |node, hash|
64
+ type = node["type"]
65
+ hash[type] ||= []
66
+ hash[type] << node["value"]
67
+ end.compact_blank
68
+ end
36
69
  end
37
70
  end
38
71
  end
@@ -38,7 +38,7 @@ module CocinaDisplay
38
38
  # @return [Coordinates, nil]
39
39
  def parse(value)
40
40
  # Remove all whitespace for easier matching/parsing
41
- match_str = value.gsub(/[\s]+/, "")
41
+ match_str = value.gsub(/\s+/, "")
42
42
 
43
43
  # Try each parser in order until one matches; bail out if none do
44
44
  parser_class = [
@@ -117,10 +117,10 @@ module CocinaDisplay
117
117
  # Format using the Well-Known Text (WKT) representation.
118
118
  # @note Limits decimals to 6 places.
119
119
  # @see https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
120
- # @example "POINT(34.0522 -118.2437)"
120
+ # @example "POINT(-118.2437 34.0522)"
121
121
  # @return [String]
122
122
  def as_wkt
123
- "POINT(%.6f %.6f)" % [point.lat, point.lng]
123
+ "POINT(%.6f %.6f)" % [point.lng, point.lat]
124
124
  end
125
125
 
126
126
  # Format using the CQL ENVELOPE representation.
@@ -310,12 +310,14 @@ module CocinaDisplay
310
310
  # Parse for decimal degree points, like "41.891797, 12.486419".
311
311
  class DecimalPointParser < PointParser
312
312
  include DecimalParser
313
- PATTERN = /(?<lat>[0-9.EW\+\-]+),(?<lng>[0-9.NS\+\-]+)/
313
+
314
+ PATTERN = /(?<lat>[0-9.EW+-]+),(?<lng>[0-9.NS+-]+)/
314
315
  end
315
316
 
316
317
  # Parser for DMS-format points, like "N34°03′08″ W118°14′37″".
317
318
  class DMSPointParser < PointParser
318
319
  include DMSParser
320
+
319
321
  PATTERN = /(?<lat>[^EW]+)(?<lng>[^NS]+)/
320
322
  end
321
323
 
@@ -324,6 +326,7 @@ module CocinaDisplay
324
326
  # @see https://www.oclc.org/bibformats/en/2xx/255.html#subfieldc
325
327
  class DMSBoundingBoxParser < BoundingBoxParser
326
328
  include DMSParser
329
+
327
330
  PATTERN = /(?<min_lng>.+?)-+(?<max_lng>.+)\/(?<max_lat>.+?)-+(?<min_lat>.+)/
328
331
  end
329
332
 
@@ -331,6 +334,7 @@ module CocinaDisplay
331
334
  # @example W 126.04--W 052.03/N 050.37--N 006.8
332
335
  class DecimalBoundingBoxParser < BoundingBoxParser
333
336
  include DecimalParser
337
+
334
338
  PATTERN = /(?<min_lng>[0-9.EW]+?)-+(?<max_lng>[0-9.EW]+)\/(?<max_lat>[0-9.NS]+?)-+(?<min_lat>[0-9.NS]+)/
335
339
  end
336
340
 
@@ -8,7 +8,10 @@ module CocinaDisplay
8
8
  attr_reader :cocina_doc
9
9
 
10
10
  # Initialize a record with a Cocina document hash.
11
- # @param cocina_doc [Hash]
11
+ # @param cocina_doc [Hash<String, Object>] The Cocina document hash
12
+ # @example Initialize a record with a Cocina document
13
+ # cocina = Cocina.find(id)
14
+ # record = CocinaDisplay::CocinaDisplay.new(cocina.as_json)
12
15
  def initialize(cocina_doc)
13
16
  @cocina_doc = cocina_doc
14
17
  end
@@ -0,0 +1,134 @@
1
+ module CocinaDisplay
2
+ module Structural
3
+ # Represents a single file in a Cocina object.
4
+ class File
5
+ # Underlying hash parsed from Cocina JSON.
6
+ attr_reader :cocina
7
+
8
+ # URL to Stacks environment that will serve this file.
9
+ attr_reader :base_url
10
+
11
+ # Initialize the File with Cocina file data.
12
+ # @param cocina [Hash] Cocina structured data for a single file
13
+ # @param druid [String, nil] DRUID of the object this file belongs to
14
+ # @note Staging objects can't infer their DRUID and need it passed in explicitly.
15
+ def initialize(cocina, base_url: "https://stacks.stanford.edu", druid: nil)
16
+ @cocina = cocina
17
+ @base_url = base_url
18
+ @druid = druid
19
+ end
20
+
21
+ # The name of the file on disk, including file extension.
22
+ # @return [String, nil]
23
+ # @example "bc798xr9549_30C_Kalsang_Yulgial_thumb.jp2"
24
+ def filename
25
+ cocina["filename"]
26
+ end
27
+
28
+ # The MIME type of the file.
29
+ # @return [String, nil]
30
+ # @example "image/jp2"
31
+ def mime_type
32
+ cocina["hasMimeType"]
33
+ end
34
+
35
+ # The relation of the file to the object.
36
+ # @return [String, nil]
37
+ # @example "thumbnail"
38
+ def use
39
+ cocina["use"]
40
+ end
41
+
42
+ # The size in bytes of the file.
43
+ # @return [Integer, nil]
44
+ # @example 204800
45
+ def size
46
+ cocina["size"]
47
+ end
48
+
49
+ # True if this file was marked as a thumbnail and has nonzero dimensions.
50
+ # @return [Boolean]
51
+ def thumbnail?
52
+ use == "thumbnail" && nonzero_dimensions?
53
+ end
54
+
55
+ # True if this file is a JP2 image and has nonzero dimensions.
56
+ # @return [Boolean]
57
+ def jp2_image?
58
+ mime_type == "image/jp2" && nonzero_dimensions?
59
+ end
60
+
61
+ # True if file is an image with nonzero height and width.
62
+ # @return [Boolean]
63
+ def nonzero_dimensions?
64
+ height&.positive? && width&.positive?
65
+ end
66
+
67
+ # The height of the image in pixels, if applicable.
68
+ # @return [Integer, nil]
69
+ def height
70
+ cocina.dig("presentation", "height").to_i
71
+ end
72
+
73
+ # The width of the image in pixels, if applicable.
74
+ # @return [Integer, nil]
75
+ def width
76
+ cocina.dig("presentation", "width").to_i
77
+ end
78
+
79
+ # Generate a IIIF image URL for this file.
80
+ # @param region [String] Desired region of the image (e.g., "full", "square", "x,y,w,h", "pct:x,y,w,h").
81
+ # @param width [String] Desired width of the image in pixels (use "!" prefix to preserve aspect ratio).
82
+ # @param height [String] Desired height of the image in pixels.
83
+ # @return [String, nil]
84
+ # @example "https://stacks.stanford.edu/image/iiif/ts786ny5936%2FPC0170_s1_E_0204.jp2/full/!400,400/0/default.jpg"
85
+ def iiif_url(region: "full", width: "!400", height: "400")
86
+ return unless iiif_id.present?
87
+
88
+ "#{base_url}/image/iiif/#{iiif_id}/#{region}/#{width},#{height}/0/default.jpg"
89
+ end
90
+
91
+ # For images served over IIIF, we use the encoded file ID minus the extension.
92
+ # @return [String, nil]
93
+ # @example "ts786ny5936%2FPC0170_s1_E_0204"
94
+ def iiif_id
95
+ ERB::Util.url_encode(file_id.delete_suffix(".jp2")) if file_id.present? && jp2_image?
96
+ end
97
+
98
+ # Generate a download URL for this file from stacks.
99
+ # @return [String, nil]
100
+ def download_url
101
+ return unless file_id.present?
102
+
103
+ "#{base_url}/file/druid:#{file_id}"
104
+ end
105
+
106
+ private
107
+
108
+ # External identifier for the file, minus the URL prefix.
109
+ # @return [String, nil]
110
+ # @note Staging and production formats differ.
111
+ # @example production
112
+ # "fn851zf9475-fn851zf9475_1/fn851zf9475_00_0001.jp2"
113
+ # @example staging
114
+ # "ddbd323d-0dd9-4f14-ba72-336c2bccfb29"
115
+ def external_id
116
+ cocina["externalIdentifier"]&.delete_prefix("https://cocina.sul.stanford.edu/file/")
117
+ end
118
+
119
+ # The DRUID of the object this file belongs to.
120
+ # @note Staging objects can't infer this from the externalIdentifier.
121
+ # @return [String, nil]
122
+ def druid
123
+ @druid || external_id.split("-").first if external_id.present?
124
+ end
125
+
126
+ # Combination of the DRUID and filename to uniquely identify the file.
127
+ # @return [String, nil]
128
+ # @example "ts786ny5936/PC0170_s1_E_0204.jp2"
129
+ def file_id
130
+ "#{druid}/#{filename}" if druid.present? && filename.present?
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,57 @@
1
+ module CocinaDisplay
2
+ module Structural
3
+ # Represents a set of files in a Cocina object.
4
+ class FileSet
5
+ # Underlying hash parsed from Cocina JSON.
6
+ attr_reader :cocina
7
+
8
+ # URL to Stacks environment that will serve this fileset.
9
+ attr_reader :base_url
10
+
11
+ # Initialize the FileSet with Cocina structural data.
12
+ # @param cocina [Hash] Cocina structured data for a single FileSet
13
+ # @param base_url [String] URL to Stacks environment that will serve this fileset
14
+ # @param druid [String, nil] DRUID of the object this fileset belongs to
15
+ def initialize(cocina, base_url: "https://stacks.stanford.edu", druid: nil)
16
+ @cocina = cocina
17
+ @base_url = base_url
18
+ @druid = druid
19
+ end
20
+
21
+ # The declared type of the FileSet, like "image" or "document".
22
+ # @note This can differ from the contained file types.
23
+ # @return [String, nil]
24
+ def type
25
+ cocina["type"]&.delete_prefix("https://cocina.sul.stanford.edu/models/resources/")
26
+ end
27
+
28
+ # All files contained in this FileSet.
29
+ # @return [Array<CocinaDisplay::Structural::File>]
30
+ def files
31
+ @files ||= Array(cocina.dig("structural", "contains")).map do |file|
32
+ CocinaDisplay::Structural::File.new(file, base_url: base_url, druid: druid)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # DRUID of the object this fileset belongs to.
39
+ # @note Inferred from the start of the externalIdentifier.
40
+ # @return [String, nil]
41
+ def druid
42
+ @druid || external_id[/^[a-z]{2}\d{3}[a-z]{2}\d{4}/] if external_id.present?
43
+ end
44
+
45
+ # External identifier for the fileset, minus the URL prefix.
46
+ # @return [String, nil]
47
+ # @note Staging and production formats differ.
48
+ # @example production
49
+ # "bk264hq9320-bk264hq9320_3"
50
+ # @example staging
51
+ # "bh114dk3076_4"
52
+ def external_id
53
+ cocina["externalIdentifier"]&.delete_prefix("https://cocina.sul.stanford.edu/fileSet/")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -109,7 +109,7 @@ module CocinaDisplay
109
109
  # Generate the display title by stripping trailing punctuation from the full title.
110
110
  # @return [String, nil]
111
111
  def display_title_str
112
- full_title_str&.sub(/[\.,;:\/\\]+\z/, "")
112
+ full_title_str&.sub(/[.,;:\/\\]+\z/, "")
113
113
  end
114
114
 
115
115
  # The main title and subtitle, joined together with a colon.
@@ -13,12 +13,12 @@ module CocinaDisplay
13
13
  return compacted_values.first if compacted_values.one?
14
14
 
15
15
  compacted_values.reduce(+"") do |result, value|
16
- result << if value.end_with?(delimiter.strip)
16
+ result << if value.end_with?(delimiter.strip) || value.start_with?(delimiter.strip)
17
17
  value + " "
18
18
  else
19
19
  value + delimiter
20
20
  end
21
- end.delete_suffix(delimiter)
21
+ end.delete_prefix(delimiter).delete_suffix(delimiter).strip
22
22
  end
23
23
 
24
24
  # Recursively flatten structured, and grouped values in Cocina metadata.
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "1.7.0" # :nodoc:
5
+ VERSION = "1.8.1" # :nodoc:
6
6
  end
@@ -6,8 +6,7 @@ require "janeway"
6
6
  require "json"
7
7
  require "net/http"
8
8
  require "active_support"
9
- require "active_support/core_ext/object/blank"
10
- require "active_support/core_ext/hash/conversions"
9
+ require "active_support/core_ext"
11
10
  require "geo/coord"
12
11
  require "edtf"
13
12
  require "i18n"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-02-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: janeway-jsonpath
@@ -268,6 +268,8 @@ files:
268
268
  - lib/cocina_display/license.rb
269
269
  - lib/cocina_display/note.rb
270
270
  - lib/cocina_display/related_resource.rb
271
+ - lib/cocina_display/structural/file.rb
272
+ - lib/cocina_display/structural/file_set.rb
271
273
  - lib/cocina_display/subjects/subject.rb
272
274
  - lib/cocina_display/subjects/subject_value.rb
273
275
  - lib/cocina_display/title.rb
@@ -296,7 +298,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
296
298
  - !ruby/object:Gem::Version
297
299
  version: '0'
298
300
  requirements: []
299
- rubygems_version: 4.0.4
301
+ rubygems_version: 3.6.2
300
302
  specification_version: 4
301
303
  summary: Helpers for rendering Cocina metadata
302
304
  test_files: []