Skip to main content

citum_schema_data/reference/
accessors.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Typed read/mutation surface for [`InputReference`].
7//!
8//! All shared bibliographic data lives inside the class-specific payload in
9//! `extension`; this file dispatches accessors through every known class.
10
11use serde_json::Value as JsonValue;
12use url::Url;
13
14use super::classes::class_dispatch;
15use super::contributor::{Contributor, ContributorEntry, ContributorList, ContributorRole};
16use super::date::EdtfString;
17use super::types::common::{
18    FieldLanguageMap, HasNumbering, LangID, MultilingualString, NumOrStr, NumberingType, Publisher,
19    RefID, RichText, Title,
20};
21use super::types::legal::{Brief, Hearing, LegalCase, Regulation, Statute, Treaty};
22use super::types::specialized::{
23    AudioVisualType, AudioVisualWork, Classic, Dataset, Event, Patent, Software, Standard,
24};
25use super::types::structural::{
26    Collection, CollectionComponent, CollectionType, Monograph, MonographComponentType,
27    MonographType, Serial, SerialComponent, SerialType,
28};
29use super::{
30    ClassExtension, EMPTY_FIELD_LANGUAGES, InputReference, ReferenceClass, UnknownClassData,
31    WorkRelation,
32};
33
34impl InputReference {
35    /// Return the typed class discriminator.
36    #[must_use]
37    pub fn class(&self) -> ReferenceClass {
38        self.extension.reference_class()
39    }
40
41    /// Return the active class-specific overlay.
42    #[must_use]
43    pub fn extension(&self) -> &ClassExtension {
44        &self.extension
45    }
46
47    /// Return the mutable active class-specific overlay.
48    #[must_use]
49    pub fn extension_mut(&mut self) -> &mut ClassExtension {
50        &mut self.extension
51    }
52
53    /// Return monograph data when this reference is a monograph.
54    #[must_use]
55    pub fn as_monograph(&self) -> Option<&Monograph> {
56        match &self.extension {
57            ClassExtension::Monograph(reference) => Some(reference.as_ref()),
58            _ => None,
59        }
60    }
61
62    /// Return collection-component data when this reference is a collection component.
63    #[must_use]
64    pub fn as_collection_component(&self) -> Option<&CollectionComponent> {
65        match &self.extension {
66            ClassExtension::CollectionComponent(reference) => Some(reference.as_ref()),
67            _ => None,
68        }
69    }
70
71    /// Return serial-component data when this reference is a serial component.
72    #[must_use]
73    pub fn as_serial_component(&self) -> Option<&SerialComponent> {
74        match &self.extension {
75            ClassExtension::SerialComponent(reference) => Some(reference.as_ref()),
76            _ => None,
77        }
78    }
79
80    /// Return collection data when this reference is a collection.
81    #[must_use]
82    pub fn as_collection(&self) -> Option<&Collection> {
83        match &self.extension {
84            ClassExtension::Collection(reference) => Some(reference.as_ref()),
85            _ => None,
86        }
87    }
88
89    /// Return serial data when this reference is a serial.
90    #[must_use]
91    pub fn as_serial(&self) -> Option<&Serial> {
92        match &self.extension {
93            ClassExtension::Serial(reference) => Some(reference.as_ref()),
94            _ => None,
95        }
96    }
97
98    /// Return legal-case data when this reference is a legal case.
99    #[must_use]
100    pub fn as_legal_case(&self) -> Option<&LegalCase> {
101        match &self.extension {
102            ClassExtension::LegalCase(reference) => Some(reference.as_ref()),
103            _ => None,
104        }
105    }
106
107    /// Return statute data when this reference is a statute.
108    #[must_use]
109    pub fn as_statute(&self) -> Option<&Statute> {
110        match &self.extension {
111            ClassExtension::Statute(reference) => Some(reference.as_ref()),
112            _ => None,
113        }
114    }
115
116    /// Return treaty data when this reference is a treaty.
117    #[must_use]
118    pub fn as_treaty(&self) -> Option<&Treaty> {
119        match &self.extension {
120            ClassExtension::Treaty(reference) => Some(reference.as_ref()),
121            _ => None,
122        }
123    }
124
125    /// Return hearing data when this reference is a hearing.
126    #[must_use]
127    pub fn as_hearing(&self) -> Option<&Hearing> {
128        match &self.extension {
129            ClassExtension::Hearing(reference) => Some(reference.as_ref()),
130            _ => None,
131        }
132    }
133
134    /// Return regulation data when this reference is a regulation.
135    #[must_use]
136    pub fn as_regulation(&self) -> Option<&Regulation> {
137        match &self.extension {
138            ClassExtension::Regulation(reference) => Some(reference.as_ref()),
139            _ => None,
140        }
141    }
142
143    /// Return brief data when this reference is a brief.
144    #[must_use]
145    pub fn as_brief(&self) -> Option<&Brief> {
146        match &self.extension {
147            ClassExtension::Brief(reference) => Some(reference.as_ref()),
148            _ => None,
149        }
150    }
151
152    /// Return classic-work data when this reference is a classic.
153    #[must_use]
154    pub fn as_classic(&self) -> Option<&Classic> {
155        match &self.extension {
156            ClassExtension::Classic(reference) => Some(reference.as_ref()),
157            _ => None,
158        }
159    }
160
161    /// Return patent data when this reference is a patent.
162    #[must_use]
163    pub fn as_patent(&self) -> Option<&Patent> {
164        match &self.extension {
165            ClassExtension::Patent(reference) => Some(reference.as_ref()),
166            _ => None,
167        }
168    }
169
170    /// Return dataset data when this reference is a dataset.
171    #[must_use]
172    pub fn as_dataset(&self) -> Option<&Dataset> {
173        match &self.extension {
174            ClassExtension::Dataset(reference) => Some(reference.as_ref()),
175            _ => None,
176        }
177    }
178
179    /// Return standard data when this reference is a standard.
180    #[must_use]
181    pub fn as_standard(&self) -> Option<&Standard> {
182        match &self.extension {
183            ClassExtension::Standard(reference) => Some(reference.as_ref()),
184            _ => None,
185        }
186    }
187
188    /// Return software data when this reference is software.
189    #[must_use]
190    pub fn as_software(&self) -> Option<&Software> {
191        match &self.extension {
192            ClassExtension::Software(reference) => Some(reference.as_ref()),
193            _ => None,
194        }
195    }
196
197    /// Return event data when this reference is an event.
198    #[must_use]
199    pub fn as_event(&self) -> Option<&Event> {
200        match &self.extension {
201            ClassExtension::Event(reference) => Some(reference.as_ref()),
202            _ => None,
203        }
204    }
205
206    /// Return audio-visual data when this reference is audio-visual.
207    #[must_use]
208    pub fn as_audio_visual(&self) -> Option<&AudioVisualWork> {
209        match &self.extension {
210            ClassExtension::AudioVisual(reference) => Some(reference.as_ref()),
211            _ => None,
212        }
213    }
214
215    /// Return unknown-class data when this reference names an unknown class.
216    #[must_use]
217    pub fn unknown_class(&self) -> Option<&UnknownClassData> {
218        match &self.extension {
219            ClassExtension::Unknown(data) => Some(data),
220            _ => None,
221        }
222    }
223
224    fn numbered(&self) -> Option<&dyn HasNumbering> {
225        match &self.extension {
226            ClassExtension::Monograph(reference) => Some(reference.as_ref()),
227            ClassExtension::Collection(reference) => Some(reference.as_ref()),
228            ClassExtension::CollectionComponent(reference) => Some(reference.as_ref()),
229            ClassExtension::SerialComponent(reference) => Some(reference.as_ref()),
230            ClassExtension::Classic(reference) => Some(reference.as_ref()),
231            ClassExtension::AudioVisual(reference) => Some(reference.as_ref()),
232            _ => None,
233        }
234    }
235
236    /// Internal helper to find a numbering by type.
237    fn find_numbering(&self, numbering_type: NumberingType) -> Option<String> {
238        self.numbered()
239            .and_then(|reference| reference.find_numbering(numbering_type))
240    }
241
242    /// Return the numbering value for an arbitrary numbering kind.
243    pub fn numbering_value(&self, numbering_type: &NumberingType) -> Option<String> {
244        self.find_numbering(numbering_type.clone())
245    }
246
247    /// Return the reference ID.
248    pub fn id(&self) -> Option<RefID> {
249        class_dispatch!(&self.extension, |r| r.id.clone(), unknown(data) => data
250                .fields
251                .get("id")
252                .and_then(JsonValue::as_str)
253                .map(|id| RefID(id.to_string())))
254    }
255
256    /// Return the author.
257    pub fn author(&self) -> Option<Contributor> {
258        use crate::reference::contributor::ContributorRole as DataRole;
259
260        match &self.extension {
261            ClassExtension::Monograph(r) => {
262                collect_contributors_by_role(&r.contributors, &DataRole::Author)
263                    .or_else(|| r.author.clone())
264            }
265            ClassExtension::CollectionComponent(r) => {
266                collect_contributors_by_role(&r.contributors, &DataRole::Author)
267                    .or_else(|| r.author.clone())
268            }
269            ClassExtension::SerialComponent(r) => {
270                let explicit_author =
271                    collect_contributors_by_role(&r.contributors, &DataRole::Author)
272                        .or_else(|| r.author.clone());
273
274                explicit_author.or_else(|| {
275                    let av_like = r
276                        .medium
277                        .as_deref()
278                        .map(|value| {
279                            let lowered = value.to_ascii_lowercase();
280                            lowered.contains("podcast")
281                                || lowered.contains("tv")
282                                || lowered.contains("film")
283                                || lowered.contains("video")
284                        })
285                        .unwrap_or(false)
286                        || r.genre
287                            .as_deref()
288                            .map(|value| {
289                                let lowered = value.to_ascii_lowercase();
290                                lowered.contains("broadcast") || lowered.contains("film")
291                            })
292                            .unwrap_or(false);
293
294                    if av_like {
295                        collect_contributors_by_role(&r.contributors, &DataRole::Producer).or_else(
296                            || collect_contributors_by_role(&r.contributors, &DataRole::Host),
297                        )
298                    } else {
299                        None
300                    }
301                })
302            }
303            ClassExtension::Treaty(r) => r.author.clone(),
304            ClassExtension::Brief(r) => r.author.clone(),
305            ClassExtension::Classic(r) => r.author.clone(),
306            ClassExtension::Patent(r) => r.author.clone(),
307            ClassExtension::Dataset(r) => r.author.clone(),
308            ClassExtension::Software(r) => r.author.clone(),
309            ClassExtension::Event(r) => {
310                collect_contributors_by_role(&r.contributors, &DataRole::Performer)
311                    .or_else(|| {
312                        collect_contributors_by_role(
313                            &r.contributors,
314                            &DataRole::Unknown("organizer".to_string()),
315                        )
316                    })
317                    .or_else(|| collect_contributors_by_role(&r.contributors, &DataRole::Author))
318            }
319            ClassExtension::AudioVisual(r) => {
320                let explicit_author =
321                    collect_contributors_by_role(&r.core.contributors, &DataRole::Author);
322
323                match r.r#type {
324                    AudioVisualType::Film | AudioVisualType::Episode => {
325                        explicit_author.or_else(|| {
326                            collect_contributors_by_role(&r.core.contributors, &DataRole::Director)
327                        })
328                    }
329                    AudioVisualType::Recording => explicit_author.or_else(|| {
330                        collect_contributors_by_role(&r.core.contributors, &DataRole::Composer)
331                            .or_else(|| {
332                                collect_contributors_by_role(
333                                    &r.core.contributors,
334                                    &DataRole::Performer,
335                                )
336                            })
337                    }),
338                    AudioVisualType::Broadcast => explicit_author
339                        .or_else(|| {
340                            collect_contributors_by_role(&r.core.contributors, &DataRole::Director)
341                        })
342                        .or_else(|| {
343                            collect_contributors_by_role(&r.core.contributors, &DataRole::Producer)
344                        }),
345                }
346            }
347            _ => None,
348        }
349    }
350
351    pub fn editor(&self) -> Option<Contributor> {
352        match &self.extension {
353            ClassExtension::Monograph(r) => {
354                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
355                    .or_else(|| r.editor.clone())
356            }
357            ClassExtension::Collection(r) => {
358                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
359                    .or_else(|| r.editor.clone())
360            }
361            ClassExtension::CollectionComponent(r) => r
362                .container
363                .as_ref()
364                .and_then(|c| match c {
365                    WorkRelation::Embedded(p) => p.editor(),
366                    WorkRelation::Id(_) => None,
367                })
368                .or_else(|| {
369                    collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
370                }),
371            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
372                WorkRelation::Embedded(p) => p.editor(),
373                WorkRelation::Id(_) => None,
374            }),
375            ClassExtension::Serial(r) => {
376                collect_contributors_by_role(&r.contributors, &ContributorRole::Editor)
377                    .or_else(|| r.editor.clone())
378            }
379            ClassExtension::Classic(r) => r.editor.clone(),
380            ClassExtension::AudioVisual(_) => None,
381            _ => None,
382        }
383    }
384
385    /// Return the translator.
386    pub fn translator(&self) -> Option<Contributor> {
387        match &self.extension {
388            ClassExtension::Monograph(r) => {
389                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
390                    .or_else(|| r.translator.clone())
391            }
392            ClassExtension::CollectionComponent(r) => {
393                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
394                    .or_else(|| r.translator.clone())
395            }
396            ClassExtension::SerialComponent(r) => {
397                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
398                    .or_else(|| r.translator.clone())
399            }
400            ClassExtension::Collection(r) => {
401                collect_contributors_by_role(&r.contributors, &ContributorRole::Translator)
402                    .or_else(|| r.translator.clone())
403            }
404            ClassExtension::Classic(r) => r.translator.clone(),
405            ClassExtension::AudioVisual(r) => {
406                collect_contributors_by_role(&r.core.contributors, &ContributorRole::Translator)
407            }
408            _ => None,
409        }
410    }
411
412    /// Return the publisher.
413    pub fn publisher(&self) -> Option<Publisher> {
414        match &self.extension {
415            ClassExtension::Monograph(r) => r.publisher.clone(),
416            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
417                WorkRelation::Embedded(p) => p.publisher(),
418                WorkRelation::Id(_) => None,
419            }),
420            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
421                WorkRelation::Embedded(p) => p.publisher(),
422                WorkRelation::Id(_) => None,
423            }),
424            ClassExtension::Collection(r) => r.publisher.clone(),
425            ClassExtension::Serial(r) => r.publisher.clone(),
426            ClassExtension::Classic(r) => r.publisher.clone(),
427            ClassExtension::Dataset(r) => r.publisher.clone(),
428            ClassExtension::Standard(r) => r.publisher.clone(),
429            ClassExtension::Software(r) => r.publisher.clone(),
430            ClassExtension::AudioVisual(r) => r.publisher.clone(),
431            _ => None,
432        }
433    }
434
435    /// Returns contributors matching `role` for any reference class that
436    /// carries a contributors list.
437    ///
438    /// Returns `None` if no contributors with the given role exist.
439    /// Returns a single `Contributor` directly, or folds multiple into a `ContributorList`.
440    pub fn contributor(&self, role: ContributorRole) -> Option<Contributor> {
441        let entries = self.all_contributor_entries();
442        collect_contributors_by_role(entries, &role)
443    }
444
445    /// Return all contributor entries matching the requested role.
446    pub fn contributor_entries(&self, role: &ContributorRole) -> Vec<&ContributorEntry> {
447        self.all_contributor_entries()
448            .iter()
449            .filter(|entry| &entry.role == role)
450            .collect()
451    }
452
453    /// Return all contributor entries regardless of role.
454    pub fn all_contributor_entries(&self) -> &[ContributorEntry] {
455        match &self.extension {
456            ClassExtension::Monograph(r) => &r.contributors,
457            ClassExtension::Collection(r) => &r.contributors,
458            ClassExtension::CollectionComponent(r) => &r.contributors,
459            ClassExtension::Serial(r) => &r.contributors,
460            ClassExtension::SerialComponent(r) => &r.contributors,
461            ClassExtension::Event(r) => &r.contributors,
462            ClassExtension::AudioVisual(r) => &r.core.contributors,
463            _ => &[],
464        }
465    }
466
467    /// Return the title.
468    pub fn title(&self) -> Option<Title> {
469        match &self.extension {
470            ClassExtension::Monograph(r) => match (&r.title, &r.short_title) {
471                (Some(Title::Single(long)), Some(short)) => {
472                    Some(Title::Shorthand(short.clone(), long.clone()))
473                }
474                _ => r.title.clone(),
475            },
476            ClassExtension::CollectionComponent(r) => r.title.clone(),
477            ClassExtension::SerialComponent(r) => r.title.clone(),
478            ClassExtension::Collection(r) => match (&r.title, &r.short_title) {
479                (Some(Title::Single(long)), Some(short)) => {
480                    Some(Title::Shorthand(short.clone(), long.clone()))
481                }
482                _ => r.title.clone(),
483            },
484            ClassExtension::Serial(r) => match (&r.title, &r.short_title) {
485                (Some(Title::Single(long)), Some(short)) => {
486                    Some(Title::Shorthand(short.clone(), long.clone()))
487                }
488                _ => r.title.clone(),
489            },
490            ClassExtension::LegalCase(r) => r.title.clone(),
491            ClassExtension::Statute(r) => r.title.clone(),
492            ClassExtension::Treaty(r) => r.title.clone(),
493            ClassExtension::Hearing(r) => r.title.clone(),
494            ClassExtension::Regulation(r) => r.title.clone(),
495            ClassExtension::Brief(r) => r.title.clone(),
496            ClassExtension::Classic(r) => r.title.clone(),
497            ClassExtension::Patent(r) => r.title.clone(),
498            ClassExtension::Dataset(r) => r.title.clone(),
499            ClassExtension::Standard(r) => r.title.clone(),
500            ClassExtension::Software(r) => r.title.clone(),
501            ClassExtension::Event(r) => r.title.clone(),
502            ClassExtension::AudioVisual(r) => match (&r.core.title, &r.core.short_title) {
503                (Some(Title::Single(long)), Some(short)) => {
504                    Some(Title::Shorthand(short.clone(), long.clone()))
505                }
506                _ => r.core.title.clone(),
507            },
508            ClassExtension::Unknown(data) => data
509                .fields
510                .get("title")
511                .and_then(JsonValue::as_str)
512                .map(|title| Title::Single(title.to_string())),
513        }
514    }
515
516    fn non_empty_date(date: EdtfString) -> Option<EdtfString> {
517        if date.is_empty() { None } else { Some(date) }
518    }
519
520    /// Return the creation or origination date.
521    pub fn created(&self) -> Option<EdtfString> {
522        match &self.extension {
523            ClassExtension::Monograph(r) => Self::non_empty_date(r.created.clone()),
524            ClassExtension::CollectionComponent(r) => Self::non_empty_date(r.created.clone()),
525            ClassExtension::SerialComponent(r) => Self::non_empty_date(r.created.clone()),
526            ClassExtension::Collection(r) => Self::non_empty_date(r.created.clone()),
527            ClassExtension::Serial(_) => None,
528            ClassExtension::LegalCase(r) => Self::non_empty_date(r.created.clone()),
529            ClassExtension::Statute(r) => Self::non_empty_date(r.created.clone()),
530            ClassExtension::Treaty(r) => Self::non_empty_date(r.created.clone()),
531            ClassExtension::Hearing(r) => Self::non_empty_date(r.created.clone()),
532            ClassExtension::Regulation(r) => Self::non_empty_date(r.created.clone()),
533            ClassExtension::Brief(r) => Self::non_empty_date(r.created.clone()),
534            ClassExtension::Classic(r) => Self::non_empty_date(r.created.clone()),
535            ClassExtension::Patent(r) => Self::non_empty_date(r.created.clone()),
536            ClassExtension::Dataset(r) => Self::non_empty_date(r.created.clone()),
537            ClassExtension::Standard(r) => Self::non_empty_date(r.created.clone()),
538            ClassExtension::Software(r) => Self::non_empty_date(r.created.clone()),
539            ClassExtension::Event(_) => None,
540            ClassExtension::AudioVisual(r) => Self::non_empty_date(r.core.created.clone()),
541            ClassExtension::Unknown(_) => None,
542        }
543    }
544
545    /// Return the explicit publication or release date.
546    pub fn issued(&self) -> Option<EdtfString> {
547        match &self.extension {
548            ClassExtension::Monograph(r) => Self::non_empty_date(r.issued.clone()),
549            ClassExtension::CollectionComponent(r) => Self::non_empty_date(r.issued.clone()),
550            ClassExtension::SerialComponent(r) => Self::non_empty_date(r.issued.clone()),
551            ClassExtension::Collection(r) => Self::non_empty_date(r.issued.clone()),
552            ClassExtension::Serial(_) => None,
553            ClassExtension::LegalCase(r) => Self::non_empty_date(r.issued.clone()),
554            ClassExtension::Statute(r) => Self::non_empty_date(r.issued.clone()),
555            ClassExtension::Treaty(r) => Self::non_empty_date(r.issued.clone()),
556            ClassExtension::Hearing(r) => Self::non_empty_date(r.issued.clone()),
557            ClassExtension::Regulation(r) => Self::non_empty_date(r.issued.clone()),
558            ClassExtension::Brief(r) => Self::non_empty_date(r.issued.clone()),
559            ClassExtension::Classic(r) => Self::non_empty_date(r.issued.clone()),
560            ClassExtension::Patent(r) => Self::non_empty_date(r.issued.clone()),
561            ClassExtension::Dataset(r) => Self::non_empty_date(r.issued.clone()),
562            ClassExtension::Standard(r) => Self::non_empty_date(r.issued.clone()),
563            ClassExtension::Software(r) => Self::non_empty_date(r.issued.clone()),
564            ClassExtension::Event(r) => r.date.clone(),
565            ClassExtension::AudioVisual(r) => Self::non_empty_date(r.core.issued.clone()),
566            ClassExtension::Unknown(_) => None,
567        }
568    }
569
570    /// Return the effective issued date, falling back to the created date when
571    /// no explicit issued date is present.
572    pub fn effective_issued_date(&self) -> Option<EdtfString> {
573        self.issued().or_else(|| self.created())
574    }
575
576    /// Return the DOI.
577    pub fn doi(&self) -> Option<String> {
578        match &self.extension {
579            ClassExtension::Monograph(r) => r.doi.clone(),
580            ClassExtension::CollectionComponent(r) => r.doi.clone(),
581            ClassExtension::SerialComponent(r) => r.doi.clone(),
582            ClassExtension::LegalCase(r) => r.doi.clone(),
583            ClassExtension::Dataset(r) => r.doi.clone(),
584            ClassExtension::Standard(r) => r.doi.clone(),
585            ClassExtension::Software(r) => r.doi.clone(),
586            ClassExtension::AudioVisual(_) => None,
587            _ => None,
588        }
589    }
590
591    /// Return the ADS bibcode.
592    pub fn ads_bibcode(&self) -> Option<String> {
593        match &self.extension {
594            ClassExtension::Monograph(r) => r.ads_bibcode.clone(),
595            ClassExtension::SerialComponent(r) => r.ads_bibcode.clone(),
596            ClassExtension::AudioVisual(_) => None,
597            _ => None,
598        }
599    }
600
601    /// Return the note.
602    pub fn note(&self) -> Option<RichText> {
603        match &self.extension {
604            ClassExtension::Monograph(r) => r.note.clone(),
605            ClassExtension::CollectionComponent(r) => r.note.clone(),
606            ClassExtension::SerialComponent(r) => r.note.clone(),
607            ClassExtension::Collection(r) => r.note.clone(),
608            ClassExtension::Serial(r) => r.note.clone(),
609            ClassExtension::LegalCase(r) => r.note.clone(),
610            ClassExtension::Statute(r) => r.note.clone(),
611            ClassExtension::Treaty(r) => r.note.clone(),
612            ClassExtension::Standard(r) => r.note.clone(),
613            ClassExtension::Event(r) => r.note.clone(),
614            ClassExtension::AudioVisual(r) => r.note.clone(),
615            _ => None,
616        }
617    }
618
619    /// Return the URL.
620    pub fn url(&self) -> Option<Url> {
621        match &self.extension {
622            ClassExtension::Monograph(r) => r.url.clone(),
623            ClassExtension::CollectionComponent(r) => r.url.clone(),
624            ClassExtension::SerialComponent(r) => r.url.clone(),
625            ClassExtension::Collection(r) => r.url.clone(),
626            ClassExtension::Serial(r) => r.url.clone(),
627            ClassExtension::LegalCase(r) => r.url.clone(),
628            ClassExtension::Statute(r) => r.url.clone(),
629            ClassExtension::Treaty(r) => r.url.clone(),
630            ClassExtension::Hearing(r) => r.url.clone(),
631            ClassExtension::Regulation(r) => r.url.clone(),
632            ClassExtension::Brief(r) => r.url.clone(),
633            ClassExtension::Classic(r) => r.url.clone(),
634            ClassExtension::Patent(r) => r.url.clone(),
635            ClassExtension::Dataset(r) => r.url.clone(),
636            ClassExtension::Standard(r) => r.url.clone(),
637            ClassExtension::Software(r) => r.url.clone(),
638            ClassExtension::Event(r) => r.url.clone(),
639            ClassExtension::AudioVisual(r) => r.url.clone(),
640            ClassExtension::Unknown(_) => None,
641        }
642    }
643
644    /// Return the publisher place.
645    pub fn publisher_place(&self) -> Option<String> {
646        match &self.extension {
647            ClassExtension::Monograph(r) => r
648                .publisher
649                .as_ref()
650                .and_then(|p| p.place.clone().map(Into::into))
651                .or_else(|| {
652                    r.container.as_ref().and_then(|c| match c {
653                        WorkRelation::Embedded(p) => p.publisher_place(),
654                        _ => None,
655                    })
656                }),
657            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
658                WorkRelation::Embedded(p) => p.publisher_place(),
659                _ => None,
660            }),
661            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
662                WorkRelation::Embedded(p) => p.publisher_place(),
663                _ => None,
664            }),
665            ClassExtension::Collection(r) => r
666                .publisher
667                .as_ref()
668                .and_then(|p| p.place.clone().map(Into::into))
669                .or_else(|| {
670                    r.container.as_ref().and_then(|c| match c {
671                        WorkRelation::Embedded(p) => p.publisher_place(),
672                        _ => None,
673                    })
674                }),
675            ClassExtension::Serial(_) => None,
676            ClassExtension::Classic(r) => r
677                .publisher
678                .as_ref()
679                .and_then(|p| p.place.clone().map(Into::into)),
680            ClassExtension::Dataset(r) => r
681                .publisher
682                .as_ref()
683                .and_then(|p| p.place.clone().map(Into::into)),
684            ClassExtension::Standard(r) => r
685                .publisher
686                .as_ref()
687                .and_then(|p| p.place.clone().map(Into::into)),
688            ClassExtension::Software(r) => r
689                .publisher
690                .as_ref()
691                .and_then(|p| p.place.clone().map(Into::into)),
692            ClassExtension::Event(r) => r.location.clone(),
693            ClassExtension::AudioVisual(r) => r
694                .publisher
695                .as_ref()
696                .and_then(|p| p.place.clone().map(Into::into)),
697            _ => None,
698        }
699    }
700
701    /// Return the publisher as a string.
702    pub fn publisher_str(&self) -> Option<String> {
703        match &self.extension {
704            ClassExtension::Monograph(r) => r
705                .publisher
706                .as_ref()
707                .map(|p| p.name.to_string())
708                .or_else(|| {
709                    r.container.as_ref().and_then(|c| match c {
710                        WorkRelation::Embedded(p) => p.publisher_str(),
711                        _ => None,
712                    })
713                }),
714            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
715                WorkRelation::Embedded(p) => p.publisher_str(),
716                _ => None,
717            }),
718            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
719                WorkRelation::Embedded(p) => p.publisher_str(),
720                _ => None,
721            }),
722            ClassExtension::Collection(r) => r
723                .publisher
724                .as_ref()
725                .map(|p| p.name.to_string())
726                .or_else(|| {
727                    r.container.as_ref().and_then(|c| match c {
728                        WorkRelation::Embedded(p) => p.publisher_str(),
729                        _ => None,
730                    })
731                }),
732            ClassExtension::Serial(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
733            ClassExtension::Classic(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
734            ClassExtension::Dataset(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
735            ClassExtension::Standard(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
736            ClassExtension::Software(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
737            ClassExtension::Event(r) => r.network.clone(),
738            ClassExtension::AudioVisual(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
739            _ => None,
740        }
741    }
742
743    /// Normalize genre/medium values to canonical kebab-case (defensive fallback for legacy producers).
744    ///
745    /// Converts to ASCII lowercase and replaces whitespace/underscores with dashes.
746    pub(super) fn normalize_genre_medium(s: &str) -> String {
747        let lower = s.to_ascii_lowercase();
748        lower
749            .split(|c: char| c.is_whitespace() || c == '_')
750            .filter(|p| !p.is_empty())
751            .collect::<Vec<_>>()
752            .join("-")
753    }
754
755    /// Return the genre/type as string, normalized to canonical kebab-case.
756    pub fn genre(&self) -> Option<String> {
757        match &self.extension {
758            ClassExtension::Monograph(r) => {
759                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
760            }
761            ClassExtension::CollectionComponent(r) => {
762                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
763            }
764            ClassExtension::SerialComponent(r) => {
765                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
766            }
767            ClassExtension::Event(r) => r.genre.as_ref().map(|g| Self::normalize_genre_medium(g)),
768            ClassExtension::AudioVisual(r) => r
769                .core
770                .genre
771                .as_ref()
772                .map(|g| Self::normalize_genre_medium(g)),
773            _ => None,
774        }
775    }
776
777    /// Return the archive or repository name.
778    pub fn archive(&self) -> Option<String> {
779        match &self.extension {
780            ClassExtension::Monograph(r) => r.archive.clone(),
781            _ => None,
782        }
783    }
784
785    /// Return the archive shelfmark or repository location.
786    pub fn archive_location(&self) -> Option<String> {
787        match &self.extension {
788            ClassExtension::Monograph(r) => r
789                .archive_info
790                .as_ref()
791                .and_then(|info| info.location.clone())
792                .or_else(|| r.archive_location.clone()),
793            ClassExtension::CollectionComponent(r) => {
794                r.archive_info.as_ref().and_then(|i| i.location.clone())
795            }
796            ClassExtension::SerialComponent(r) => {
797                r.archive_info.as_ref().and_then(|i| i.location.clone())
798            }
799            _ => None,
800        }
801    }
802
803    /// Return the archive name from structured ArchiveInfo.
804    pub fn archive_name(&self) -> Option<MultilingualString> {
805        match &self.extension {
806            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.name.clone()),
807            ClassExtension::CollectionComponent(r) => {
808                r.archive_info.as_ref().and_then(|i| i.name.clone())
809            }
810            ClassExtension::SerialComponent(r) => {
811                r.archive_info.as_ref().and_then(|i| i.name.clone())
812            }
813            _ => None,
814        }
815    }
816
817    /// Return the archive geographic place from structured ArchiveInfo.
818    pub fn archive_place(&self) -> Option<String> {
819        match &self.extension {
820            ClassExtension::Monograph(r) => r
821                .archive_info
822                .as_ref()
823                .and_then(|i| i.place.clone().map(Into::into)),
824            ClassExtension::CollectionComponent(r) => r
825                .archive_info
826                .as_ref()
827                .and_then(|i| i.place.clone().map(Into::into)),
828            ClassExtension::SerialComponent(r) => r
829                .archive_info
830                .as_ref()
831                .and_then(|i| i.place.clone().map(Into::into)),
832            _ => None,
833        }
834    }
835
836    /// Return the archive collection name from structured ArchiveInfo.
837    pub fn archive_collection(&self) -> Option<String> {
838        match &self.extension {
839            ClassExtension::Monograph(r) => {
840                r.archive_info.as_ref().and_then(|i| i.collection.clone())
841            }
842            ClassExtension::CollectionComponent(r) => {
843                r.archive_info.as_ref().and_then(|i| i.collection.clone())
844            }
845            ClassExtension::SerialComponent(r) => {
846                r.archive_info.as_ref().and_then(|i| i.collection.clone())
847            }
848            _ => None,
849        }
850    }
851
852    /// Return the archive collection identifier from structured ArchiveInfo.
853    pub fn archive_collection_id(&self) -> Option<String> {
854        match &self.extension {
855            ClassExtension::Monograph(r) => r
856                .archive_info
857                .as_ref()
858                .and_then(|i| i.collection_id.clone()),
859            ClassExtension::CollectionComponent(r) => r
860                .archive_info
861                .as_ref()
862                .and_then(|i| i.collection_id.clone()),
863            ClassExtension::SerialComponent(r) => r
864                .archive_info
865                .as_ref()
866                .and_then(|i| i.collection_id.clone()),
867            _ => None,
868        }
869    }
870
871    /// Return the archive series from structured ArchiveInfo.
872    pub fn archive_series(&self) -> Option<String> {
873        match &self.extension {
874            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.series.clone()),
875            ClassExtension::CollectionComponent(r) => {
876                r.archive_info.as_ref().and_then(|i| i.series.clone())
877            }
878            ClassExtension::SerialComponent(r) => {
879                r.archive_info.as_ref().and_then(|i| i.series.clone())
880            }
881            _ => None,
882        }
883    }
884
885    /// Return the archive box number from structured ArchiveInfo.
886    pub fn archive_box(&self) -> Option<String> {
887        match &self.extension {
888            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.r#box.clone()),
889            ClassExtension::CollectionComponent(r) => {
890                r.archive_info.as_ref().and_then(|i| i.r#box.clone())
891            }
892            ClassExtension::SerialComponent(r) => {
893                r.archive_info.as_ref().and_then(|i| i.r#box.clone())
894            }
895            _ => None,
896        }
897    }
898
899    /// Return the archive folder from structured ArchiveInfo.
900    pub fn archive_folder(&self) -> Option<String> {
901        match &self.extension {
902            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.folder.clone()),
903            ClassExtension::CollectionComponent(r) => {
904                r.archive_info.as_ref().and_then(|i| i.folder.clone())
905            }
906            ClassExtension::SerialComponent(r) => {
907                r.archive_info.as_ref().and_then(|i| i.folder.clone())
908            }
909            _ => None,
910        }
911    }
912
913    /// Return the archive item identifier from structured ArchiveInfo.
914    pub fn archive_item(&self) -> Option<String> {
915        match &self.extension {
916            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.item.clone()),
917            ClassExtension::CollectionComponent(r) => {
918                r.archive_info.as_ref().and_then(|i| i.item.clone())
919            }
920            ClassExtension::SerialComponent(r) => {
921                r.archive_info.as_ref().and_then(|i| i.item.clone())
922            }
923            _ => None,
924        }
925    }
926
927    /// Return the archive URL from structured ArchiveInfo.
928    pub fn archive_url(&self) -> Option<Url> {
929        match &self.extension {
930            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.url.clone()),
931            ClassExtension::CollectionComponent(r) => {
932                r.archive_info.as_ref().and_then(|i| i.url.clone())
933            }
934            ClassExtension::SerialComponent(r) => {
935                r.archive_info.as_ref().and_then(|i| i.url.clone())
936            }
937            _ => None,
938        }
939    }
940
941    /// Return the publication status.
942    pub fn status(&self) -> Option<String> {
943        match &self.extension {
944            ClassExtension::Monograph(r) => r.status.clone(),
945            ClassExtension::CollectionComponent(r) => r.status.clone(),
946            ClassExtension::SerialComponent(r) => r.status.clone(),
947            ClassExtension::Standard(r) => r.status.clone(),
948            _ => None,
949        }
950    }
951
952    /// Return the eprint identifier.
953    pub fn eprint_id(&self) -> Option<String> {
954        match &self.extension {
955            ClassExtension::Monograph(r) => r.eprint.as_ref().map(|e| e.id.clone()),
956            ClassExtension::CollectionComponent(r) => r.eprint.as_ref().map(|e| e.id.clone()),
957            ClassExtension::SerialComponent(r) => r.eprint.as_ref().map(|e| e.id.clone()),
958            _ => None,
959        }
960    }
961
962    /// Return the eprint server name.
963    pub fn eprint_server(&self) -> Option<String> {
964        match &self.extension {
965            ClassExtension::Monograph(r) => r.eprint.as_ref().map(|e| e.server.clone()),
966            ClassExtension::CollectionComponent(r) => r.eprint.as_ref().map(|e| e.server.clone()),
967            ClassExtension::SerialComponent(r) => r.eprint.as_ref().map(|e| e.server.clone()),
968            _ => None,
969        }
970    }
971
972    /// Return the eprint subject class.
973    pub fn eprint_class(&self) -> Option<String> {
974        match &self.extension {
975            ClassExtension::Monograph(r) => r.eprint.as_ref().and_then(|e| e.class.clone()),
976            ClassExtension::CollectionComponent(r) => {
977                r.eprint.as_ref().and_then(|e| e.class.clone())
978            }
979            ClassExtension::SerialComponent(r) => r.eprint.as_ref().and_then(|e| e.class.clone()),
980            _ => None,
981        }
982    }
983
984    /// Return the medium, normalized to canonical kebab-case.
985    pub fn medium(&self) -> Option<String> {
986        match &self.extension {
987            ClassExtension::Monograph(r) => {
988                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
989            }
990            ClassExtension::CollectionComponent(r) => {
991                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
992            }
993            ClassExtension::SerialComponent(r) => {
994                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
995            }
996            ClassExtension::AudioVisual(r) => {
997                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
998            }
999            _ => None,
1000        }
1001    }
1002
1003    /// Return the version.
1004    pub fn version(&self) -> Option<String> {
1005        match &self.extension {
1006            ClassExtension::Dataset(r) => r.version.clone(),
1007            ClassExtension::Software(r) => r.version.clone(),
1008            _ => None,
1009        }
1010    }
1011
1012    /// Return the abstract.
1013    pub fn abstract_text(&self) -> Option<RichText> {
1014        match &self.extension {
1015            ClassExtension::Monograph(r) => r.abstract_text.clone(),
1016            ClassExtension::CollectionComponent(r) => r.abstract_text.clone(),
1017            ClassExtension::SerialComponent(r) => r.abstract_text.clone(),
1018            _ => None,
1019        }
1020    }
1021
1022    /// Return the container-style title for parent works, reporters, or codes.
1023    pub fn container_title(&self) -> Option<Title> {
1024        match &self.extension {
1025            ClassExtension::Monograph(r) => r.container.as_ref().and_then(|c| match c {
1026                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1027                WorkRelation::Id(_) => None,
1028            }),
1029            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
1030                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1031                WorkRelation::Id(_) => None,
1032            }),
1033            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1034                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1035                WorkRelation::Id(_) => None,
1036            }),
1037            ClassExtension::Serial(r) => r.container.as_ref().and_then(|c| match c {
1038                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1039                WorkRelation::Id(_) => None,
1040            }),
1041            ClassExtension::LegalCase(r) => r.reporter.clone().map(Title::Single),
1042            ClassExtension::Statute(r) => r.code.clone().map(Title::Single),
1043            ClassExtension::Treaty(r) => r.reporter.clone().map(Title::Single),
1044            ClassExtension::Event(r) => r.container.as_ref().and_then(|c| match c {
1045                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1046                WorkRelation::Id(_) => None,
1047            }),
1048            ClassExtension::AudioVisual(r) => r.container.as_ref().and_then(|c| match c {
1049                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1050                WorkRelation::Id(_) => None,
1051            }),
1052            _ => None,
1053        }
1054    }
1055
1056    /// Return the series or collection title for a parent work.
1057    pub fn collection_title(&self) -> Option<Title> {
1058        fn nested_collection_title(relation: &WorkRelation) -> Option<Title> {
1059            let WorkRelation::Embedded(parent) = relation else {
1060                return None;
1061            };
1062            let container = match parent.extension() {
1063                ClassExtension::Monograph(r) => r.container.as_ref(),
1064                ClassExtension::Collection(r) => r.container.as_ref(),
1065                ClassExtension::Serial(r) => r.container.as_ref(),
1066                _ => None,
1067            }?;
1068            match container {
1069                WorkRelation::Embedded(collection) => collection.title(),
1070                WorkRelation::Id(_) => None,
1071            }
1072        }
1073
1074        match &self.extension {
1075            ClassExtension::Monograph(r) => r.container.as_ref().and_then(nested_collection_title),
1076            ClassExtension::CollectionComponent(r) => {
1077                r.container.as_ref().and_then(nested_collection_title)
1078            }
1079            ClassExtension::SerialComponent(r) => {
1080                r.container.as_ref().and_then(nested_collection_title)
1081            }
1082            _ => None,
1083        }
1084    }
1085
1086    /// Return the volume.
1087    pub fn volume(&self) -> Option<NumOrStr> {
1088        match &self.extension {
1089            ClassExtension::Monograph(r) => r
1090                .volume
1091                .clone()
1092                .or_else(|| self.find_numbering(NumberingType::Volume))
1093                .map(NumOrStr::Str),
1094            ClassExtension::Collection(r) => r
1095                .volume
1096                .clone()
1097                .or_else(|| self.find_numbering(NumberingType::Volume))
1098                .map(NumOrStr::Str),
1099            ClassExtension::CollectionComponent(r) => r
1100                .volume
1101                .clone()
1102                .or_else(|| self.find_numbering(NumberingType::Volume))
1103                .map(NumOrStr::Str),
1104            ClassExtension::SerialComponent(r) => r
1105                .volume
1106                .clone()
1107                .or_else(|| self.find_numbering(NumberingType::Volume))
1108                .map(NumOrStr::Str),
1109            ClassExtension::Classic(r) => r
1110                .volume
1111                .clone()
1112                .or_else(|| self.find_numbering(NumberingType::Volume))
1113                .map(NumOrStr::Str),
1114            ClassExtension::LegalCase(r) => r.volume.clone().map(NumOrStr::Str),
1115            ClassExtension::Statute(r) => r.volume.clone().map(NumOrStr::Str),
1116            ClassExtension::Treaty(r) => r.volume.clone().map(NumOrStr::Str),
1117            ClassExtension::Regulation(r) => r.volume.clone().map(NumOrStr::Str),
1118            _ => self
1119                .find_numbering(NumberingType::Volume)
1120                .map(NumOrStr::Str),
1121        }
1122    }
1123
1124    /// Return the collection number (series number).
1125    pub fn collection_number(&self) -> Option<String> {
1126        match &self.extension {
1127            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
1128                WorkRelation::Embedded(p) => p.collection_number(),
1129                WorkRelation::Id(_) => None,
1130            }),
1131            _ => self.find_numbering(NumberingType::Volume),
1132        }
1133    }
1134
1135    /// Return the issue.
1136    pub fn issue(&self) -> Option<NumOrStr> {
1137        match &self.extension {
1138            ClassExtension::Monograph(r) => r
1139                .issue
1140                .clone()
1141                .or_else(|| self.find_numbering(NumberingType::Issue))
1142                .map(NumOrStr::Str),
1143            ClassExtension::Collection(r) => r
1144                .issue
1145                .clone()
1146                .or_else(|| self.find_numbering(NumberingType::Issue))
1147                .map(NumOrStr::Str),
1148            ClassExtension::CollectionComponent(r) => r
1149                .issue
1150                .clone()
1151                .or_else(|| self.find_numbering(NumberingType::Issue))
1152                .map(NumOrStr::Str),
1153            ClassExtension::SerialComponent(r) => r
1154                .issue
1155                .clone()
1156                .or_else(|| self.find_numbering(NumberingType::Issue))
1157                .map(NumOrStr::Str),
1158            ClassExtension::Classic(r) => r
1159                .issue
1160                .clone()
1161                .or_else(|| self.find_numbering(NumberingType::Issue))
1162                .map(NumOrStr::Str),
1163            _ => self.find_numbering(NumberingType::Issue).map(NumOrStr::Str),
1164        }
1165    }
1166
1167    /// Return the pages.
1168    pub fn pages(&self) -> Option<NumOrStr> {
1169        match &self.extension {
1170            ClassExtension::CollectionComponent(r) => r.pages.clone(),
1171            ClassExtension::SerialComponent(r) => r.pages.clone().map(NumOrStr::Str),
1172            ClassExtension::LegalCase(r) => r.page.clone().map(NumOrStr::Str),
1173            ClassExtension::Statute(r) => r.page.clone().map(NumOrStr::Str),
1174            ClassExtension::Treaty(r) => r.page.clone().map(NumOrStr::Str),
1175            _ => None,
1176        }
1177    }
1178
1179    /// Return the authority (court, legislative body, standards org, etc.).
1180    pub fn authority(&self) -> Option<String> {
1181        match &self.extension {
1182            ClassExtension::LegalCase(r) => r.authority.clone(),
1183            ClassExtension::Statute(r) => r.authority.clone(),
1184            ClassExtension::Hearing(r) => r.authority.clone(),
1185            ClassExtension::Regulation(r) => r.authority.clone(),
1186            ClassExtension::Brief(r) => r.authority.clone(),
1187            ClassExtension::Patent(r) => r.authority.clone(),
1188            ClassExtension::Standard(r) => r.authority.clone(),
1189            _ => None,
1190        }
1191    }
1192
1193    /// Return the reporter (legal reporter series).
1194    pub fn reporter(&self) -> Option<String> {
1195        match &self.extension {
1196            ClassExtension::LegalCase(r) => r.reporter.clone(),
1197            ClassExtension::Treaty(r) => r.reporter.clone(),
1198            _ => None,
1199        }
1200    }
1201
1202    /// Return the code (legal code abbreviation).
1203    pub fn code(&self) -> Option<String> {
1204        match &self.extension {
1205            ClassExtension::Statute(r) => r.code.clone(),
1206            ClassExtension::Regulation(r) => r.code.clone(),
1207            _ => None,
1208        }
1209    }
1210
1211    /// Return the section (legal section number).
1212    pub fn section(&self) -> Option<String> {
1213        match &self.extension {
1214            ClassExtension::Statute(r) => r.section.clone(),
1215            ClassExtension::Regulation(r) => r.section.clone(),
1216            ClassExtension::Classic(_) => self.find_numbering(NumberingType::Section),
1217            _ => None,
1218        }
1219    }
1220
1221    /// Return the generic document number.
1222    pub fn number(&self) -> Option<String> {
1223        match &self.extension {
1224            ClassExtension::Monograph(r) => r
1225                .number
1226                .clone()
1227                .or_else(|| self.find_numbering(NumberingType::Number)),
1228            ClassExtension::Statute(r) => r.number.clone(),
1229            ClassExtension::Hearing(r) => r.session_number.clone(),
1230            ClassExtension::Collection(r) => r
1231                .number
1232                .clone()
1233                .or_else(|| self.find_numbering(NumberingType::Number)),
1234            ClassExtension::CollectionComponent(r) => r
1235                .number
1236                .clone()
1237                .or_else(|| self.find_numbering(NumberingType::Number)),
1238            ClassExtension::SerialComponent(r) => r
1239                .number
1240                .clone()
1241                .or_else(|| self.find_numbering(NumberingType::Number)),
1242            ClassExtension::Classic(r) => r
1243                .number
1244                .clone()
1245                .or_else(|| self.find_numbering(NumberingType::Number)),
1246            _ => self.find_numbering(NumberingType::Number),
1247        }
1248    }
1249
1250    /// Return the report identifier.
1251    pub fn report_number(&self) -> Option<String> {
1252        match &self.extension {
1253            ClassExtension::Monograph(_) => self.find_numbering(NumberingType::Report),
1254            _ => None,
1255        }
1256    }
1257
1258    /// Return the edition.
1259    pub fn edition(&self) -> Option<String> {
1260        match &self.extension {
1261            ClassExtension::Monograph(r) => r
1262                .edition
1263                .clone()
1264                .or_else(|| self.find_numbering(NumberingType::Edition)),
1265            ClassExtension::Collection(r) => r
1266                .edition
1267                .clone()
1268                .or_else(|| self.find_numbering(NumberingType::Edition)),
1269            ClassExtension::CollectionComponent(r) => r
1270                .edition
1271                .clone()
1272                .or_else(|| self.find_numbering(NumberingType::Edition)),
1273            ClassExtension::SerialComponent(r) => r
1274                .edition
1275                .clone()
1276                .or_else(|| self.find_numbering(NumberingType::Edition)),
1277            ClassExtension::Classic(r) => r
1278                .edition
1279                .clone()
1280                .or_else(|| self.find_numbering(NumberingType::Edition)),
1281            _ => self.find_numbering(NumberingType::Edition),
1282        }
1283    }
1284
1285    /// Return the accessed date.
1286    pub fn accessed(&self) -> Option<EdtfString> {
1287        class_dispatch!(&self.extension, |r| r.accessed.clone(), unknown(_) => None)
1288    }
1289
1290    /// Return the forward-compat `unknown_fields` captured for this reference.
1291    ///
1292    /// Returns `None` for unknown-class references: their fields are kept
1293    /// wholesale in [`UnknownClassData::fields`] and reported separately via
1294    /// the unknown-class warning.
1295    pub fn unknown_fields(&self) -> Option<&std::collections::BTreeMap<String, JsonValue>> {
1296        class_dispatch!(&self.extension, |r| Some(&r.unknown_fields), unknown(_) => None)
1297    }
1298
1299    /// Return the embedded inline reference behind `original` if any.
1300    ///
1301    /// All 16 reference classes that carry an `original` relation expose it via
1302    /// the same `WorkRelation` shape (only `AudioVisualWork` nests it through
1303    /// `core`). Centralising the dispatch here lets each `original_*` accessor
1304    /// stay a one-liner.
1305    fn original_embedded(&self) -> Option<&InputReference> {
1306        let relation = match &self.extension {
1307            ClassExtension::Monograph(r) => r.original.as_ref(),
1308            ClassExtension::CollectionComponent(r) => r.original.as_ref(),
1309            ClassExtension::SerialComponent(r) => r.original.as_ref(),
1310            ClassExtension::LegalCase(r) => r.original.as_ref(),
1311            ClassExtension::Statute(r) => r.original.as_ref(),
1312            ClassExtension::Treaty(r) => r.original.as_ref(),
1313            ClassExtension::Hearing(r) => r.original.as_ref(),
1314            ClassExtension::Regulation(r) => r.original.as_ref(),
1315            ClassExtension::Brief(r) => r.original.as_ref(),
1316            ClassExtension::Classic(r) => r.original.as_ref(),
1317            ClassExtension::Patent(r) => r.original.as_ref(),
1318            ClassExtension::Dataset(r) => r.original.as_ref(),
1319            ClassExtension::Standard(r) => r.original.as_ref(),
1320            ClassExtension::Software(r) => r.original.as_ref(),
1321            ClassExtension::Event(r) => r.original.as_ref(),
1322            ClassExtension::AudioVisual(r) => r.core.original.as_ref(),
1323            _ => None,
1324        }?;
1325        match relation {
1326            WorkRelation::Embedded(p) => Some(p),
1327            WorkRelation::Id(_) => None,
1328        }
1329    }
1330
1331    /// Return the original publication date.
1332    pub fn original_date(&self) -> Option<EdtfString> {
1333        self.original_embedded()?.effective_issued_date()
1334    }
1335
1336    /// Return the original title.
1337    pub fn original_title(&self) -> Option<Title> {
1338        self.original_embedded()?.title()
1339    }
1340
1341    /// Return the original publisher as a string.
1342    pub fn original_publisher_str(&self) -> Option<String> {
1343        self.original_embedded()?
1344            .publisher_str()
1345            .filter(|value| !value.is_empty())
1346    }
1347
1348    /// Return the original publisher place.
1349    pub fn original_publisher_place(&self) -> Option<String> {
1350        self.original_embedded()?.publisher_place()
1351    }
1352
1353    /// Return the ISBN.
1354    pub fn isbn(&self) -> Option<String> {
1355        match &self.extension {
1356            ClassExtension::Monograph(r) => r.isbn.clone(),
1357            _ => None,
1358        }
1359    }
1360
1361    /// Return the ISSN.
1362    pub fn issn(&self) -> Option<String> {
1363        match &self.extension {
1364            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1365                WorkRelation::Embedded(s) => s.issn(),
1366                WorkRelation::Id(_) => None,
1367            }),
1368            ClassExtension::Serial(r) => r.issn.clone(),
1369            _ => None,
1370        }
1371    }
1372
1373    /// Return the Keywords.
1374    pub fn keywords(&self) -> Option<Vec<String>> {
1375        match &self.extension {
1376            ClassExtension::Monograph(r) => r.keywords.clone(),
1377            ClassExtension::CollectionComponent(r) => r.keywords.clone(),
1378            ClassExtension::SerialComponent(r) => r.keywords.clone(),
1379            ClassExtension::Collection(r) => r.keywords.clone(),
1380            ClassExtension::Serial(_) => None,
1381            ClassExtension::LegalCase(r) => r.keywords.clone(),
1382            ClassExtension::Statute(r) => r.keywords.clone(),
1383            ClassExtension::Treaty(r) => r.keywords.clone(),
1384            ClassExtension::Hearing(r) => r.keywords.clone(),
1385            ClassExtension::Regulation(r) => r.keywords.clone(),
1386            ClassExtension::Brief(r) => r.keywords.clone(),
1387            ClassExtension::Classic(r) => r.keywords.clone(),
1388            ClassExtension::Patent(r) => r.keywords.clone(),
1389            ClassExtension::Dataset(r) => r.keywords.clone(),
1390            ClassExtension::Standard(r) => r.keywords.clone(),
1391            ClassExtension::Software(r) => r.keywords.clone(),
1392            ClassExtension::Event(_) => None,
1393            ClassExtension::AudioVisual(_) => None,
1394            ClassExtension::Unknown(_) => None,
1395        }
1396    }
1397
1398    /// Return the language.
1399    pub fn language(&self) -> Option<LangID> {
1400        class_dispatch!(
1401            &self.extension,
1402            |r| r.language.clone(),
1403            audio_visual(r) => r.core.language.clone(),
1404            unknown(_) => None
1405        )
1406    }
1407
1408    /// Return field-level language overrides.
1409    pub fn field_languages(&self) -> &FieldLanguageMap {
1410        class_dispatch!(
1411            &self.extension,
1412            |r| &r.field_languages,
1413            unknown(_) => &EMPTY_FIELD_LANGUAGES
1414        )
1415    }
1416
1417    /// Set the reference ID on the class-specific extension.
1418    ///
1419    /// For unknown-class references the id is stored as a `JsonValue::String`
1420    /// inside `UnknownClassData::fields["id"]`. The wire schema requires
1421    /// `id: string`, so round-trip is lossless for valid inputs.
1422    pub fn set_id(&mut self, id: impl Into<RefID>) {
1423        let id = id.into();
1424        class_dispatch!(&mut self.extension, |r| r.id = Some(id.clone()), unknown(data) => {
1425            data.fields
1426                .insert("id".to_string(), JsonValue::String(id.to_string()));
1427        });
1428    }
1429
1430    /// Return the reference type as a string (CSL-compatible).
1431    pub fn ref_type(&self) -> String {
1432        match &self.extension {
1433            ClassExtension::Monograph(r) => self.monograph_ref_type(r),
1434            ClassExtension::CollectionComponent(r) => collection_component_ref_type(r),
1435            ClassExtension::SerialComponent(r) => serial_component_ref_type(r),
1436            ClassExtension::Collection(r) => match r.r#type {
1437                CollectionType::EditedBook => "book",
1438                _ => "collection",
1439            }
1440            .to_string(),
1441            ClassExtension::Serial(r) => match r.r#type {
1442                SerialType::AcademicJournal => "article-journal",
1443                SerialType::Magazine => "article-magazine",
1444                SerialType::Newspaper => "article-newspaper",
1445                SerialType::BroadcastProgram => "broadcast",
1446                _ => "serial",
1447            }
1448            .to_string(),
1449            ClassExtension::LegalCase(_) => "legal-case".to_string(),
1450            ClassExtension::Statute(_) => "statute".to_string(),
1451            ClassExtension::Treaty(_) => "treaty".to_string(),
1452            ClassExtension::Hearing(_) => "hearing".to_string(),
1453            ClassExtension::Regulation(_) => "regulation".to_string(),
1454            ClassExtension::Brief(_) => "brief".to_string(),
1455            ClassExtension::Classic(_) => "classic".to_string(),
1456            ClassExtension::Patent(_) => "patent".to_string(),
1457            ClassExtension::Dataset(_) => "dataset".to_string(),
1458            ClassExtension::Standard(_) => "standard".to_string(),
1459            ClassExtension::Software(_) => "software".to_string(),
1460            ClassExtension::Event(r) => event_ref_type(r).to_string(),
1461            ClassExtension::AudioVisual(r) => audio_visual_ref_type(&r.r#type).to_string(),
1462            ClassExtension::Unknown(data) => {
1463                // Unknown classes round-trip but cannot route to a known CSL
1464                // ref-type; the engine has no template branch for the raw
1465                // class string, so rendering will fall through to the default
1466                // path (typically empty output).
1467                //
1468                // TODO(csl26-1bdr): Layer 5 `CompatibilityWarning` plumbing
1469                // will surface this as a soft-degrade warning rather than
1470                // silent fall-through. Until then we return the raw class
1471                // string so debug builds and logs can identify the value.
1472                debug_assert!(
1473                    !ReferenceClass::KNOWN.contains(&data.class.as_str()),
1474                    "ClassExtension::Unknown should never wrap a known class string (got `{}`)",
1475                    data.class
1476                );
1477                data.class.clone()
1478            }
1479        }
1480    }
1481
1482    fn monograph_ref_type(&self, r: &Monograph) -> String {
1483        match r.r#type {
1484            MonographType::Book => if r
1485                .medium
1486                .as_deref()
1487                .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1488            {
1489                "interview"
1490            } else {
1491                "book"
1492            }
1493            .to_string(),
1494            MonographType::Manual => "manual".to_string(),
1495            MonographType::Report => "report".to_string(),
1496            MonographType::Thesis => "thesis".to_string(),
1497            MonographType::Webpage => "webpage".to_string(),
1498            MonographType::Post => "post".to_string(),
1499            MonographType::Interview => "interview".to_string(),
1500            MonographType::Manuscript => "manuscript".to_string(),
1501            MonographType::Preprint => "preprint".to_string(),
1502            MonographType::PersonalCommunication => "personal-communication".to_string(),
1503            MonographType::Document => {
1504                if r.genre
1505                    .as_deref()
1506                    .is_some_and(|genre| genre.eq_ignore_ascii_case("map"))
1507                {
1508                    return "map".to_string();
1509                }
1510                if let Some(genre) = r.genre.as_deref()
1511                    && matches!(genre, "bill-proceeding" | "bill-record")
1512                {
1513                    return genre.to_string();
1514                }
1515                if self.genre().as_deref() == Some("conference-paper") {
1516                    return "paper-conference".to_string();
1517                }
1518                if r.medium
1519                    .as_deref()
1520                    .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1521                {
1522                    "interview"
1523                } else {
1524                    "document"
1525                }
1526                .to_string()
1527            }
1528            _ => r.r#type.as_str().to_string(),
1529        }
1530    }
1531}
1532
1533fn collection_component_ref_type(r: &CollectionComponent) -> String {
1534    match r.r#type {
1535        MonographComponentType::Chapter => match r.genre.as_deref() {
1536            Some("entry-dictionary") => "entry-dictionary",
1537            Some("entry-encyclopedia") => "entry-encyclopedia",
1538            _ => "chapter",
1539        }
1540        .to_string(),
1541        MonographComponentType::Document => "paper-conference".to_string(),
1542        _ => r.r#type.as_str().to_string(),
1543    }
1544}
1545
1546fn serial_component_ref_type(r: &SerialComponent) -> String {
1547    if r.genre.as_deref() == Some("entry-encyclopedia") {
1548        return "entry-encyclopedia".to_string();
1549    }
1550    let container_type = r.container.as_ref().and_then(|c| match c {
1551        WorkRelation::Embedded(p) => Some(p.ref_type()),
1552        WorkRelation::Id(_) => None,
1553    });
1554    match container_type.as_deref() {
1555        Some("article-magazine") => "article-magazine".to_string(),
1556        Some("article-newspaper") => "article-newspaper".to_string(),
1557        Some("broadcast") => if r
1558            .genre
1559            .as_deref()
1560            .is_some_and(|g| g.to_ascii_lowercase().contains("film"))
1561        {
1562            "motion-picture"
1563        } else {
1564            "broadcast"
1565        }
1566        .to_string(),
1567        _ => "article-journal".to_string(),
1568    }
1569}
1570
1571fn event_ref_type(r: &Event) -> &'static str {
1572    let lowered = r.genre.as_deref().map(str::to_ascii_lowercase);
1573    match lowered.as_deref() {
1574        Some(g) if g.contains("conference") || g.contains("paper") => "paper-conference",
1575        Some(g) if g.contains("broadcast") => "broadcast",
1576        Some(g) if g.contains("talk") || g.contains("speech") => "speech",
1577        _ => "event",
1578    }
1579}
1580
1581fn audio_visual_ref_type(kind: &AudioVisualType) -> &'static str {
1582    match kind {
1583        AudioVisualType::Film => "motion-picture",
1584        AudioVisualType::Episode | AudioVisualType::Broadcast => "broadcast",
1585        AudioVisualType::Recording => "song",
1586    }
1587}
1588
1589/// Collects contributors with a given role from a slice of entries.
1590///
1591/// Returns `None` if no entries match. A single match returns the contributor
1592/// unwrapped; two or more fold into a [`ContributorList`].
1593fn collect_contributors_by_role(
1594    entries: &[ContributorEntry],
1595    role: &ContributorRole,
1596) -> Option<Contributor> {
1597    let mut matches = entries
1598        .iter()
1599        .filter(|e| &e.role == role)
1600        .map(|e| &e.contributor);
1601    let first = matches.next()?;
1602    let Some(second) = matches.next() else {
1603        return Some(first.clone());
1604    };
1605    let list = std::iter::once(first)
1606        .chain(std::iter::once(second))
1607        .chain(matches)
1608        .cloned()
1609        .collect();
1610    Some(Contributor::ContributorList(ContributorList(list)))
1611}