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
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 used for compatibility layers.
571    pub fn csl_issued_date(&self) -> Option<EdtfString> {
572        self.issued().or_else(|| self.created())
573    }
574
575    /// Return the DOI.
576    pub fn doi(&self) -> Option<String> {
577        match &self.extension {
578            ClassExtension::Monograph(r) => r.doi.clone(),
579            ClassExtension::CollectionComponent(r) => r.doi.clone(),
580            ClassExtension::SerialComponent(r) => r.doi.clone(),
581            ClassExtension::LegalCase(r) => r.doi.clone(),
582            ClassExtension::Dataset(r) => r.doi.clone(),
583            ClassExtension::Software(r) => r.doi.clone(),
584            ClassExtension::AudioVisual(_) => None,
585            _ => None,
586        }
587    }
588
589    /// Return the ADS bibcode.
590    pub fn ads_bibcode(&self) -> Option<String> {
591        match &self.extension {
592            ClassExtension::Monograph(r) => r.ads_bibcode.clone(),
593            ClassExtension::SerialComponent(r) => r.ads_bibcode.clone(),
594            ClassExtension::AudioVisual(_) => None,
595            _ => None,
596        }
597    }
598
599    /// Return the note.
600    pub fn note(&self) -> Option<RichText> {
601        match &self.extension {
602            ClassExtension::Monograph(r) => r.note.clone(),
603            ClassExtension::CollectionComponent(r) => r.note.clone(),
604            ClassExtension::SerialComponent(r) => r.note.clone(),
605            ClassExtension::Collection(r) => r.note.clone(),
606            ClassExtension::Serial(r) => r.note.clone(),
607            ClassExtension::LegalCase(r) => r.note.clone(),
608            ClassExtension::Statute(r) => r.note.clone(),
609            ClassExtension::Treaty(r) => r.note.clone(),
610            ClassExtension::Standard(r) => r.note.clone(),
611            ClassExtension::Event(r) => r.note.clone(),
612            ClassExtension::AudioVisual(r) => r.note.clone(),
613            _ => None,
614        }
615    }
616
617    /// Return the URL.
618    pub fn url(&self) -> Option<Url> {
619        match &self.extension {
620            ClassExtension::Monograph(r) => r.url.clone(),
621            ClassExtension::CollectionComponent(r) => r.url.clone(),
622            ClassExtension::SerialComponent(r) => r.url.clone(),
623            ClassExtension::Collection(r) => r.url.clone(),
624            ClassExtension::Serial(r) => r.url.clone(),
625            ClassExtension::LegalCase(r) => r.url.clone(),
626            ClassExtension::Statute(r) => r.url.clone(),
627            ClassExtension::Treaty(r) => r.url.clone(),
628            ClassExtension::Hearing(r) => r.url.clone(),
629            ClassExtension::Regulation(r) => r.url.clone(),
630            ClassExtension::Brief(r) => r.url.clone(),
631            ClassExtension::Classic(r) => r.url.clone(),
632            ClassExtension::Patent(r) => r.url.clone(),
633            ClassExtension::Dataset(r) => r.url.clone(),
634            ClassExtension::Standard(r) => r.url.clone(),
635            ClassExtension::Software(r) => r.url.clone(),
636            ClassExtension::Event(r) => r.url.clone(),
637            ClassExtension::AudioVisual(r) => r.url.clone(),
638            ClassExtension::Unknown(_) => None,
639        }
640    }
641
642    /// Return the publisher place.
643    pub fn publisher_place(&self) -> Option<String> {
644        match &self.extension {
645            ClassExtension::Monograph(r) => r
646                .publisher
647                .as_ref()
648                .and_then(|p| p.place.clone().map(Into::into))
649                .or_else(|| {
650                    r.container.as_ref().and_then(|c| match c {
651                        WorkRelation::Embedded(p) => p.publisher_place(),
652                        _ => None,
653                    })
654                }),
655            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
656                WorkRelation::Embedded(p) => p.publisher_place(),
657                _ => None,
658            }),
659            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
660                WorkRelation::Embedded(p) => p.publisher_place(),
661                _ => None,
662            }),
663            ClassExtension::Collection(r) => r
664                .publisher
665                .as_ref()
666                .and_then(|p| p.place.clone().map(Into::into))
667                .or_else(|| {
668                    r.container.as_ref().and_then(|c| match c {
669                        WorkRelation::Embedded(p) => p.publisher_place(),
670                        _ => None,
671                    })
672                }),
673            ClassExtension::Serial(_) => None,
674            ClassExtension::Classic(r) => r
675                .publisher
676                .as_ref()
677                .and_then(|p| p.place.clone().map(Into::into)),
678            ClassExtension::Dataset(r) => r
679                .publisher
680                .as_ref()
681                .and_then(|p| p.place.clone().map(Into::into)),
682            ClassExtension::Standard(r) => r
683                .publisher
684                .as_ref()
685                .and_then(|p| p.place.clone().map(Into::into)),
686            ClassExtension::Software(r) => r
687                .publisher
688                .as_ref()
689                .and_then(|p| p.place.clone().map(Into::into)),
690            ClassExtension::Event(r) => r.location.clone(),
691            ClassExtension::AudioVisual(r) => r
692                .publisher
693                .as_ref()
694                .and_then(|p| p.place.clone().map(Into::into)),
695            _ => None,
696        }
697    }
698
699    /// Return the publisher as a string.
700    pub fn publisher_str(&self) -> Option<String> {
701        match &self.extension {
702            ClassExtension::Monograph(r) => r
703                .publisher
704                .as_ref()
705                .map(|p| p.name.to_string())
706                .or_else(|| {
707                    r.container.as_ref().and_then(|c| match c {
708                        WorkRelation::Embedded(p) => p.publisher_str(),
709                        _ => None,
710                    })
711                }),
712            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
713                WorkRelation::Embedded(p) => p.publisher_str(),
714                _ => None,
715            }),
716            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
717                WorkRelation::Embedded(p) => p.publisher_str(),
718                _ => None,
719            }),
720            ClassExtension::Collection(r) => r
721                .publisher
722                .as_ref()
723                .map(|p| p.name.to_string())
724                .or_else(|| {
725                    r.container.as_ref().and_then(|c| match c {
726                        WorkRelation::Embedded(p) => p.publisher_str(),
727                        _ => None,
728                    })
729                }),
730            ClassExtension::Serial(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
731            ClassExtension::Classic(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
732            ClassExtension::Dataset(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
733            ClassExtension::Standard(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
734            ClassExtension::Software(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
735            ClassExtension::Event(r) => r.network.clone(),
736            ClassExtension::AudioVisual(r) => r.publisher.as_ref().map(|p| p.name.to_string()),
737            _ => None,
738        }
739    }
740
741    /// Normalize genre/medium values to canonical kebab-case (defensive fallback for legacy producers).
742    ///
743    /// Converts to ASCII lowercase and replaces whitespace/underscores with dashes.
744    pub(super) fn normalize_genre_medium(s: &str) -> String {
745        let lower = s.to_ascii_lowercase();
746        lower
747            .split(|c: char| c.is_whitespace() || c == '_')
748            .filter(|p| !p.is_empty())
749            .collect::<Vec<_>>()
750            .join("-")
751    }
752
753    /// Return the genre/type as string, normalized to canonical kebab-case.
754    pub fn genre(&self) -> Option<String> {
755        match &self.extension {
756            ClassExtension::Monograph(r) => {
757                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
758            }
759            ClassExtension::CollectionComponent(r) => {
760                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
761            }
762            ClassExtension::SerialComponent(r) => {
763                r.genre.as_ref().map(|g| Self::normalize_genre_medium(g))
764            }
765            ClassExtension::Event(r) => r.genre.as_ref().map(|g| Self::normalize_genre_medium(g)),
766            ClassExtension::AudioVisual(r) => r
767                .core
768                .genre
769                .as_ref()
770                .map(|g| Self::normalize_genre_medium(g)),
771            _ => None,
772        }
773    }
774
775    /// Return the archive or repository name.
776    pub fn archive(&self) -> Option<String> {
777        match &self.extension {
778            ClassExtension::Monograph(r) => r.archive.clone(),
779            _ => None,
780        }
781    }
782
783    /// Return the archive shelfmark or repository location.
784    pub fn archive_location(&self) -> Option<String> {
785        match &self.extension {
786            ClassExtension::Monograph(r) => r
787                .archive_info
788                .as_ref()
789                .and_then(|info| info.location.clone())
790                .or_else(|| r.archive_location.clone()),
791            ClassExtension::CollectionComponent(r) => {
792                r.archive_info.as_ref().and_then(|i| i.location.clone())
793            }
794            ClassExtension::SerialComponent(r) => {
795                r.archive_info.as_ref().and_then(|i| i.location.clone())
796            }
797            _ => None,
798        }
799    }
800
801    /// Return the archive name from structured ArchiveInfo.
802    pub fn archive_name(&self) -> Option<MultilingualString> {
803        match &self.extension {
804            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.name.clone()),
805            ClassExtension::CollectionComponent(r) => {
806                r.archive_info.as_ref().and_then(|i| i.name.clone())
807            }
808            ClassExtension::SerialComponent(r) => {
809                r.archive_info.as_ref().and_then(|i| i.name.clone())
810            }
811            _ => None,
812        }
813    }
814
815    /// Return the archive geographic place from structured ArchiveInfo.
816    pub fn archive_place(&self) -> Option<String> {
817        match &self.extension {
818            ClassExtension::Monograph(r) => r
819                .archive_info
820                .as_ref()
821                .and_then(|i| i.place.clone().map(Into::into)),
822            ClassExtension::CollectionComponent(r) => r
823                .archive_info
824                .as_ref()
825                .and_then(|i| i.place.clone().map(Into::into)),
826            ClassExtension::SerialComponent(r) => r
827                .archive_info
828                .as_ref()
829                .and_then(|i| i.place.clone().map(Into::into)),
830            _ => None,
831        }
832    }
833
834    /// Return the archive collection name from structured ArchiveInfo.
835    pub fn archive_collection(&self) -> Option<String> {
836        match &self.extension {
837            ClassExtension::Monograph(r) => {
838                r.archive_info.as_ref().and_then(|i| i.collection.clone())
839            }
840            ClassExtension::CollectionComponent(r) => {
841                r.archive_info.as_ref().and_then(|i| i.collection.clone())
842            }
843            ClassExtension::SerialComponent(r) => {
844                r.archive_info.as_ref().and_then(|i| i.collection.clone())
845            }
846            _ => None,
847        }
848    }
849
850    /// Return the archive collection identifier from structured ArchiveInfo.
851    pub fn archive_collection_id(&self) -> Option<String> {
852        match &self.extension {
853            ClassExtension::Monograph(r) => r
854                .archive_info
855                .as_ref()
856                .and_then(|i| i.collection_id.clone()),
857            ClassExtension::CollectionComponent(r) => r
858                .archive_info
859                .as_ref()
860                .and_then(|i| i.collection_id.clone()),
861            ClassExtension::SerialComponent(r) => r
862                .archive_info
863                .as_ref()
864                .and_then(|i| i.collection_id.clone()),
865            _ => None,
866        }
867    }
868
869    /// Return the archive series from structured ArchiveInfo.
870    pub fn archive_series(&self) -> Option<String> {
871        match &self.extension {
872            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.series.clone()),
873            ClassExtension::CollectionComponent(r) => {
874                r.archive_info.as_ref().and_then(|i| i.series.clone())
875            }
876            ClassExtension::SerialComponent(r) => {
877                r.archive_info.as_ref().and_then(|i| i.series.clone())
878            }
879            _ => None,
880        }
881    }
882
883    /// Return the archive box number from structured ArchiveInfo.
884    pub fn archive_box(&self) -> Option<String> {
885        match &self.extension {
886            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.r#box.clone()),
887            ClassExtension::CollectionComponent(r) => {
888                r.archive_info.as_ref().and_then(|i| i.r#box.clone())
889            }
890            ClassExtension::SerialComponent(r) => {
891                r.archive_info.as_ref().and_then(|i| i.r#box.clone())
892            }
893            _ => None,
894        }
895    }
896
897    /// Return the archive folder from structured ArchiveInfo.
898    pub fn archive_folder(&self) -> Option<String> {
899        match &self.extension {
900            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.folder.clone()),
901            ClassExtension::CollectionComponent(r) => {
902                r.archive_info.as_ref().and_then(|i| i.folder.clone())
903            }
904            ClassExtension::SerialComponent(r) => {
905                r.archive_info.as_ref().and_then(|i| i.folder.clone())
906            }
907            _ => None,
908        }
909    }
910
911    /// Return the archive item identifier from structured ArchiveInfo.
912    pub fn archive_item(&self) -> Option<String> {
913        match &self.extension {
914            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.item.clone()),
915            ClassExtension::CollectionComponent(r) => {
916                r.archive_info.as_ref().and_then(|i| i.item.clone())
917            }
918            ClassExtension::SerialComponent(r) => {
919                r.archive_info.as_ref().and_then(|i| i.item.clone())
920            }
921            _ => None,
922        }
923    }
924
925    /// Return the archive URL from structured ArchiveInfo.
926    pub fn archive_url(&self) -> Option<Url> {
927        match &self.extension {
928            ClassExtension::Monograph(r) => r.archive_info.as_ref().and_then(|i| i.url.clone()),
929            ClassExtension::CollectionComponent(r) => {
930                r.archive_info.as_ref().and_then(|i| i.url.clone())
931            }
932            ClassExtension::SerialComponent(r) => {
933                r.archive_info.as_ref().and_then(|i| i.url.clone())
934            }
935            _ => None,
936        }
937    }
938
939    /// Return the publication status.
940    pub fn status(&self) -> Option<String> {
941        match &self.extension {
942            ClassExtension::Monograph(r) => r.status.clone(),
943            ClassExtension::CollectionComponent(r) => r.status.clone(),
944            ClassExtension::SerialComponent(r) => r.status.clone(),
945            ClassExtension::Standard(r) => r.status.clone(),
946            _ => None,
947        }
948    }
949
950    /// Return the eprint identifier.
951    pub fn eprint_id(&self) -> Option<String> {
952        match &self.extension {
953            ClassExtension::Monograph(r) => r.eprint.as_ref().map(|e| e.id.clone()),
954            ClassExtension::CollectionComponent(r) => r.eprint.as_ref().map(|e| e.id.clone()),
955            ClassExtension::SerialComponent(r) => r.eprint.as_ref().map(|e| e.id.clone()),
956            _ => None,
957        }
958    }
959
960    /// Return the eprint server name.
961    pub fn eprint_server(&self) -> Option<String> {
962        match &self.extension {
963            ClassExtension::Monograph(r) => r.eprint.as_ref().map(|e| e.server.clone()),
964            ClassExtension::CollectionComponent(r) => r.eprint.as_ref().map(|e| e.server.clone()),
965            ClassExtension::SerialComponent(r) => r.eprint.as_ref().map(|e| e.server.clone()),
966            _ => None,
967        }
968    }
969
970    /// Return the eprint subject class.
971    pub fn eprint_class(&self) -> Option<String> {
972        match &self.extension {
973            ClassExtension::Monograph(r) => r.eprint.as_ref().and_then(|e| e.class.clone()),
974            ClassExtension::CollectionComponent(r) => {
975                r.eprint.as_ref().and_then(|e| e.class.clone())
976            }
977            ClassExtension::SerialComponent(r) => r.eprint.as_ref().and_then(|e| e.class.clone()),
978            _ => None,
979        }
980    }
981
982    /// Return the medium, normalized to canonical kebab-case.
983    pub fn medium(&self) -> Option<String> {
984        match &self.extension {
985            ClassExtension::Monograph(r) => {
986                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
987            }
988            ClassExtension::CollectionComponent(r) => {
989                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
990            }
991            ClassExtension::SerialComponent(r) => {
992                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
993            }
994            ClassExtension::AudioVisual(r) => {
995                r.medium.as_ref().map(|m| Self::normalize_genre_medium(m))
996            }
997            _ => None,
998        }
999    }
1000
1001    /// Return the version.
1002    pub fn version(&self) -> Option<String> {
1003        match &self.extension {
1004            ClassExtension::Dataset(r) => r.version.clone(),
1005            ClassExtension::Software(r) => r.version.clone(),
1006            _ => None,
1007        }
1008    }
1009
1010    /// Return the abstract.
1011    pub fn abstract_text(&self) -> Option<RichText> {
1012        match &self.extension {
1013            ClassExtension::Monograph(r) => r.abstract_text.clone(),
1014            ClassExtension::CollectionComponent(r) => r.abstract_text.clone(),
1015            ClassExtension::SerialComponent(r) => r.abstract_text.clone(),
1016            _ => None,
1017        }
1018    }
1019
1020    /// Return the container-style title for parent works, reporters, or codes.
1021    pub fn container_title(&self) -> Option<Title> {
1022        match &self.extension {
1023            ClassExtension::Monograph(r) => r.container.as_ref().and_then(|c| match c {
1024                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1025                WorkRelation::Id(_) => None,
1026            }),
1027            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
1028                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1029                WorkRelation::Id(_) => None,
1030            }),
1031            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1032                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1033                WorkRelation::Id(_) => None,
1034            }),
1035            ClassExtension::Serial(r) => r.container.as_ref().and_then(|c| match c {
1036                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1037                WorkRelation::Id(_) => None,
1038            }),
1039            ClassExtension::LegalCase(r) => r.reporter.clone().map(Title::Single),
1040            ClassExtension::Statute(r) => r.code.clone().map(Title::Single),
1041            ClassExtension::Treaty(r) => r.reporter.clone().map(Title::Single),
1042            ClassExtension::Event(r) => r.container.as_ref().and_then(|c| match c {
1043                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1044                WorkRelation::Id(_) => None,
1045            }),
1046            ClassExtension::AudioVisual(r) => r.container.as_ref().and_then(|c| match c {
1047                WorkRelation::Embedded(p) => p.title().or_else(|| p.container_title()),
1048                WorkRelation::Id(_) => None,
1049            }),
1050            _ => None,
1051        }
1052    }
1053
1054    /// Return the volume.
1055    pub fn volume(&self) -> Option<NumOrStr> {
1056        match &self.extension {
1057            ClassExtension::Monograph(r) => r
1058                .volume
1059                .clone()
1060                .or_else(|| self.find_numbering(NumberingType::Volume))
1061                .map(NumOrStr::Str),
1062            ClassExtension::Collection(r) => r
1063                .volume
1064                .clone()
1065                .or_else(|| self.find_numbering(NumberingType::Volume))
1066                .map(NumOrStr::Str),
1067            ClassExtension::CollectionComponent(r) => r
1068                .volume
1069                .clone()
1070                .or_else(|| self.find_numbering(NumberingType::Volume))
1071                .map(NumOrStr::Str),
1072            ClassExtension::SerialComponent(r) => r
1073                .volume
1074                .clone()
1075                .or_else(|| self.find_numbering(NumberingType::Volume))
1076                .map(NumOrStr::Str),
1077            ClassExtension::Classic(r) => r
1078                .volume
1079                .clone()
1080                .or_else(|| self.find_numbering(NumberingType::Volume))
1081                .map(NumOrStr::Str),
1082            ClassExtension::LegalCase(r) => r.volume.clone().map(NumOrStr::Str),
1083            ClassExtension::Statute(r) => r.volume.clone().map(NumOrStr::Str),
1084            ClassExtension::Treaty(r) => r.volume.clone().map(NumOrStr::Str),
1085            ClassExtension::Regulation(r) => r.volume.clone().map(NumOrStr::Str),
1086            _ => self
1087                .find_numbering(NumberingType::Volume)
1088                .map(NumOrStr::Str),
1089        }
1090    }
1091
1092    /// Return the collection number (series number).
1093    pub fn collection_number(&self) -> Option<String> {
1094        match &self.extension {
1095            ClassExtension::CollectionComponent(r) => r.container.as_ref().and_then(|c| match c {
1096                WorkRelation::Embedded(p) => p.collection_number(),
1097                WorkRelation::Id(_) => None,
1098            }),
1099            _ => self.find_numbering(NumberingType::Volume),
1100        }
1101    }
1102
1103    /// Return the issue.
1104    pub fn issue(&self) -> Option<NumOrStr> {
1105        match &self.extension {
1106            ClassExtension::Monograph(r) => r
1107                .issue
1108                .clone()
1109                .or_else(|| self.find_numbering(NumberingType::Issue))
1110                .map(NumOrStr::Str),
1111            ClassExtension::Collection(r) => r
1112                .issue
1113                .clone()
1114                .or_else(|| self.find_numbering(NumberingType::Issue))
1115                .map(NumOrStr::Str),
1116            ClassExtension::CollectionComponent(r) => r
1117                .issue
1118                .clone()
1119                .or_else(|| self.find_numbering(NumberingType::Issue))
1120                .map(NumOrStr::Str),
1121            ClassExtension::SerialComponent(r) => r
1122                .issue
1123                .clone()
1124                .or_else(|| self.find_numbering(NumberingType::Issue))
1125                .map(NumOrStr::Str),
1126            ClassExtension::Classic(r) => r
1127                .issue
1128                .clone()
1129                .or_else(|| self.find_numbering(NumberingType::Issue))
1130                .map(NumOrStr::Str),
1131            _ => self.find_numbering(NumberingType::Issue).map(NumOrStr::Str),
1132        }
1133    }
1134
1135    /// Return the pages.
1136    pub fn pages(&self) -> Option<NumOrStr> {
1137        match &self.extension {
1138            ClassExtension::CollectionComponent(r) => r.pages.clone(),
1139            ClassExtension::SerialComponent(r) => r.pages.clone().map(NumOrStr::Str),
1140            ClassExtension::LegalCase(r) => r.page.clone().map(NumOrStr::Str),
1141            ClassExtension::Statute(r) => r.page.clone().map(NumOrStr::Str),
1142            ClassExtension::Treaty(r) => r.page.clone().map(NumOrStr::Str),
1143            _ => None,
1144        }
1145    }
1146
1147    /// Return the authority (court, legislative body, standards org, etc.).
1148    pub fn authority(&self) -> Option<String> {
1149        match &self.extension {
1150            ClassExtension::LegalCase(r) => r.authority.clone(),
1151            ClassExtension::Statute(r) => r.authority.clone(),
1152            ClassExtension::Hearing(r) => r.authority.clone(),
1153            ClassExtension::Regulation(r) => r.authority.clone(),
1154            ClassExtension::Brief(r) => r.authority.clone(),
1155            ClassExtension::Patent(r) => r.authority.clone(),
1156            ClassExtension::Standard(r) => r.authority.clone(),
1157            _ => None,
1158        }
1159    }
1160
1161    /// Return the reporter (legal reporter series).
1162    pub fn reporter(&self) -> Option<String> {
1163        match &self.extension {
1164            ClassExtension::LegalCase(r) => r.reporter.clone(),
1165            ClassExtension::Treaty(r) => r.reporter.clone(),
1166            _ => None,
1167        }
1168    }
1169
1170    /// Return the code (legal code abbreviation).
1171    pub fn code(&self) -> Option<String> {
1172        match &self.extension {
1173            ClassExtension::Statute(r) => r.code.clone(),
1174            ClassExtension::Regulation(r) => r.code.clone(),
1175            _ => None,
1176        }
1177    }
1178
1179    /// Return the section (legal section number).
1180    pub fn section(&self) -> Option<String> {
1181        match &self.extension {
1182            ClassExtension::Statute(r) => r.section.clone(),
1183            ClassExtension::Regulation(r) => r.section.clone(),
1184            ClassExtension::Classic(_) => self.find_numbering(NumberingType::Section),
1185            _ => None,
1186        }
1187    }
1188
1189    /// Return the generic document number.
1190    pub fn number(&self) -> Option<String> {
1191        match &self.extension {
1192            ClassExtension::Monograph(r) => r
1193                .number
1194                .clone()
1195                .or_else(|| self.find_numbering(NumberingType::Number)),
1196            ClassExtension::Statute(r) => r.number.clone(),
1197            ClassExtension::Collection(r) => r
1198                .number
1199                .clone()
1200                .or_else(|| self.find_numbering(NumberingType::Number)),
1201            ClassExtension::CollectionComponent(r) => r
1202                .number
1203                .clone()
1204                .or_else(|| self.find_numbering(NumberingType::Number)),
1205            ClassExtension::SerialComponent(r) => r
1206                .number
1207                .clone()
1208                .or_else(|| self.find_numbering(NumberingType::Number)),
1209            ClassExtension::Classic(r) => r
1210                .number
1211                .clone()
1212                .or_else(|| self.find_numbering(NumberingType::Number)),
1213            _ => self.find_numbering(NumberingType::Number),
1214        }
1215    }
1216
1217    /// Return the report identifier.
1218    pub fn report_number(&self) -> Option<String> {
1219        match &self.extension {
1220            ClassExtension::Monograph(_) => self.find_numbering(NumberingType::Report),
1221            _ => None,
1222        }
1223    }
1224
1225    /// Return the edition.
1226    pub fn edition(&self) -> Option<String> {
1227        match &self.extension {
1228            ClassExtension::Monograph(r) => r
1229                .edition
1230                .clone()
1231                .or_else(|| self.find_numbering(NumberingType::Edition)),
1232            ClassExtension::Collection(r) => r
1233                .edition
1234                .clone()
1235                .or_else(|| self.find_numbering(NumberingType::Edition)),
1236            ClassExtension::CollectionComponent(r) => r
1237                .edition
1238                .clone()
1239                .or_else(|| self.find_numbering(NumberingType::Edition)),
1240            ClassExtension::SerialComponent(r) => r
1241                .edition
1242                .clone()
1243                .or_else(|| self.find_numbering(NumberingType::Edition)),
1244            ClassExtension::Classic(r) => r
1245                .edition
1246                .clone()
1247                .or_else(|| self.find_numbering(NumberingType::Edition)),
1248            _ => self.find_numbering(NumberingType::Edition),
1249        }
1250    }
1251
1252    /// Return the accessed date.
1253    pub fn accessed(&self) -> Option<EdtfString> {
1254        class_dispatch!(&self.extension, |r| r.accessed.clone(), unknown(_) => None)
1255    }
1256
1257    /// Return the embedded inline reference behind `original` if any.
1258    ///
1259    /// All 16 reference classes that carry an `original` relation expose it via
1260    /// the same `WorkRelation` shape (only `AudioVisualWork` nests it through
1261    /// `core`). Centralising the dispatch here lets each `original_*` accessor
1262    /// stay a one-liner.
1263    fn original_embedded(&self) -> Option<&InputReference> {
1264        let relation = match &self.extension {
1265            ClassExtension::Monograph(r) => r.original.as_ref(),
1266            ClassExtension::CollectionComponent(r) => r.original.as_ref(),
1267            ClassExtension::SerialComponent(r) => r.original.as_ref(),
1268            ClassExtension::LegalCase(r) => r.original.as_ref(),
1269            ClassExtension::Statute(r) => r.original.as_ref(),
1270            ClassExtension::Treaty(r) => r.original.as_ref(),
1271            ClassExtension::Hearing(r) => r.original.as_ref(),
1272            ClassExtension::Regulation(r) => r.original.as_ref(),
1273            ClassExtension::Brief(r) => r.original.as_ref(),
1274            ClassExtension::Classic(r) => r.original.as_ref(),
1275            ClassExtension::Patent(r) => r.original.as_ref(),
1276            ClassExtension::Dataset(r) => r.original.as_ref(),
1277            ClassExtension::Standard(r) => r.original.as_ref(),
1278            ClassExtension::Software(r) => r.original.as_ref(),
1279            ClassExtension::Event(r) => r.original.as_ref(),
1280            ClassExtension::AudioVisual(r) => r.core.original.as_ref(),
1281            _ => None,
1282        }?;
1283        match relation {
1284            WorkRelation::Embedded(p) => Some(p),
1285            WorkRelation::Id(_) => None,
1286        }
1287    }
1288
1289    /// Return the original publication date.
1290    pub fn original_date(&self) -> Option<EdtfString> {
1291        self.original_embedded()?.csl_issued_date()
1292    }
1293
1294    /// Return the original title.
1295    pub fn original_title(&self) -> Option<Title> {
1296        self.original_embedded()?.title()
1297    }
1298
1299    /// Return the original publisher as a string.
1300    pub fn original_publisher_str(&self) -> Option<String> {
1301        self.original_embedded()?
1302            .publisher_str()
1303            .filter(|value| !value.is_empty())
1304    }
1305
1306    /// Return the original publisher place.
1307    pub fn original_publisher_place(&self) -> Option<String> {
1308        self.original_embedded()?.publisher_place()
1309    }
1310
1311    /// Return the ISBN.
1312    pub fn isbn(&self) -> Option<String> {
1313        match &self.extension {
1314            ClassExtension::Monograph(r) => r.isbn.clone(),
1315            _ => None,
1316        }
1317    }
1318
1319    /// Return the ISSN.
1320    pub fn issn(&self) -> Option<String> {
1321        match &self.extension {
1322            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1323                WorkRelation::Embedded(s) => s.issn(),
1324                WorkRelation::Id(_) => None,
1325            }),
1326            ClassExtension::Serial(r) => r.issn.clone(),
1327            _ => None,
1328        }
1329    }
1330
1331    /// Return the Keywords.
1332    pub fn keywords(&self) -> Option<Vec<String>> {
1333        match &self.extension {
1334            ClassExtension::Monograph(r) => r.keywords.clone(),
1335            ClassExtension::CollectionComponent(r) => r.keywords.clone(),
1336            ClassExtension::SerialComponent(r) => r.keywords.clone(),
1337            ClassExtension::Collection(r) => r.keywords.clone(),
1338            ClassExtension::Serial(_) => None,
1339            ClassExtension::LegalCase(r) => r.keywords.clone(),
1340            ClassExtension::Statute(r) => r.keywords.clone(),
1341            ClassExtension::Treaty(r) => r.keywords.clone(),
1342            ClassExtension::Hearing(r) => r.keywords.clone(),
1343            ClassExtension::Regulation(r) => r.keywords.clone(),
1344            ClassExtension::Brief(r) => r.keywords.clone(),
1345            ClassExtension::Classic(r) => r.keywords.clone(),
1346            ClassExtension::Patent(r) => r.keywords.clone(),
1347            ClassExtension::Dataset(r) => r.keywords.clone(),
1348            ClassExtension::Standard(r) => r.keywords.clone(),
1349            ClassExtension::Software(r) => r.keywords.clone(),
1350            ClassExtension::Event(_) => None,
1351            ClassExtension::AudioVisual(_) => None,
1352            ClassExtension::Unknown(_) => None,
1353        }
1354    }
1355
1356    /// Return the language.
1357    pub fn language(&self) -> Option<LangID> {
1358        class_dispatch!(
1359            &self.extension,
1360            |r| r.language.clone(),
1361            audio_visual(r) => r.core.language.clone(),
1362            unknown(_) => None
1363        )
1364    }
1365
1366    /// Return field-level language overrides.
1367    pub fn field_languages(&self) -> &FieldLanguageMap {
1368        class_dispatch!(
1369            &self.extension,
1370            |r| &r.field_languages,
1371            unknown(_) => &EMPTY_FIELD_LANGUAGES
1372        )
1373    }
1374
1375    /// Set the reference ID on the class-specific extension.
1376    ///
1377    /// For unknown-class references the id is stored as a `JsonValue::String`
1378    /// inside `UnknownClassData::fields["id"]`. The wire schema requires
1379    /// `id: string`, so round-trip is lossless for valid inputs.
1380    pub fn set_id(&mut self, id: impl Into<RefID>) {
1381        let id = id.into();
1382        class_dispatch!(&mut self.extension, |r| r.id = Some(id.clone()), unknown(data) => {
1383            data.fields
1384                .insert("id".to_string(), JsonValue::String(id.to_string()));
1385        });
1386    }
1387
1388    /// Return the reference type as a string (CSL-compatible).
1389    pub fn ref_type(&self) -> String {
1390        match &self.extension {
1391            ClassExtension::Monograph(r) => self.monograph_ref_type(r),
1392            ClassExtension::CollectionComponent(r) => collection_component_ref_type(r),
1393            ClassExtension::SerialComponent(r) => serial_component_ref_type(r),
1394            ClassExtension::Collection(r) => match r.r#type {
1395                CollectionType::EditedBook => "book",
1396                _ => "collection",
1397            }
1398            .to_string(),
1399            ClassExtension::Serial(r) => match r.r#type {
1400                SerialType::AcademicJournal => "article-journal",
1401                SerialType::Magazine => "article-magazine",
1402                SerialType::Newspaper => "article-newspaper",
1403                SerialType::BroadcastProgram => "broadcast",
1404                _ => "serial",
1405            }
1406            .to_string(),
1407            ClassExtension::LegalCase(_) => "legal-case".to_string(),
1408            ClassExtension::Statute(_) => "statute".to_string(),
1409            ClassExtension::Treaty(_) => "treaty".to_string(),
1410            ClassExtension::Hearing(_) => "hearing".to_string(),
1411            ClassExtension::Regulation(_) => "regulation".to_string(),
1412            ClassExtension::Brief(_) => "brief".to_string(),
1413            ClassExtension::Classic(_) => "classic".to_string(),
1414            ClassExtension::Patent(_) => "patent".to_string(),
1415            ClassExtension::Dataset(_) => "dataset".to_string(),
1416            ClassExtension::Standard(_) => "standard".to_string(),
1417            ClassExtension::Software(_) => "software".to_string(),
1418            ClassExtension::Event(r) => event_ref_type(r).to_string(),
1419            ClassExtension::AudioVisual(r) => audio_visual_ref_type(&r.r#type).to_string(),
1420            ClassExtension::Unknown(data) => {
1421                // Unknown classes round-trip but cannot route to a known CSL
1422                // ref-type; the engine has no template branch for the raw
1423                // class string, so rendering will fall through to the default
1424                // path (typically empty output).
1425                //
1426                // TODO(csl26-1bdr): Layer 5 `CompatibilityWarning` plumbing
1427                // will surface this as a soft-degrade warning rather than
1428                // silent fall-through. Until then we return the raw class
1429                // string so debug builds and logs can identify the value.
1430                debug_assert!(
1431                    !ReferenceClass::KNOWN.contains(&data.class.as_str()),
1432                    "ClassExtension::Unknown should never wrap a known class string (got `{}`)",
1433                    data.class
1434                );
1435                data.class.clone()
1436            }
1437        }
1438    }
1439
1440    fn monograph_ref_type(&self, r: &Monograph) -> String {
1441        match r.r#type {
1442            MonographType::Book => if r
1443                .medium
1444                .as_deref()
1445                .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1446            {
1447                "interview"
1448            } else {
1449                "book"
1450            }
1451            .to_string(),
1452            MonographType::Manual => "manual".to_string(),
1453            MonographType::Report => "report".to_string(),
1454            MonographType::Thesis => "thesis".to_string(),
1455            MonographType::Webpage => "webpage".to_string(),
1456            MonographType::Post => "post".to_string(),
1457            MonographType::Interview => "interview".to_string(),
1458            MonographType::Manuscript => "manuscript".to_string(),
1459            MonographType::Preprint => "preprint".to_string(),
1460            MonographType::PersonalCommunication => "personal-communication".to_string(),
1461            MonographType::Document => {
1462                if let Some(genre) = r.genre.as_deref()
1463                    && matches!(genre, "bill-proceeding" | "bill-record")
1464                {
1465                    return genre.to_string();
1466                }
1467                if self.genre().as_deref() == Some("conference-paper") {
1468                    return "paper-conference".to_string();
1469                }
1470                if r.medium
1471                    .as_deref()
1472                    .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1473                {
1474                    "interview"
1475                } else {
1476                    "document"
1477                }
1478                .to_string()
1479            }
1480            _ => r.r#type.as_str().to_string(),
1481        }
1482    }
1483}
1484
1485fn collection_component_ref_type(r: &CollectionComponent) -> String {
1486    match r.r#type {
1487        MonographComponentType::Chapter => match r.genre.as_deref() {
1488            Some("entry-dictionary") => "entry-dictionary",
1489            Some("entry-encyclopedia") => "entry-encyclopedia",
1490            _ => "chapter",
1491        }
1492        .to_string(),
1493        MonographComponentType::Document => "paper-conference".to_string(),
1494        _ => r.r#type.as_str().to_string(),
1495    }
1496}
1497
1498fn serial_component_ref_type(r: &SerialComponent) -> String {
1499    if r.genre.as_deref() == Some("entry-encyclopedia") {
1500        return "entry-encyclopedia".to_string();
1501    }
1502    let container_type = r.container.as_ref().and_then(|c| match c {
1503        WorkRelation::Embedded(p) => Some(p.ref_type()),
1504        WorkRelation::Id(_) => None,
1505    });
1506    match container_type.as_deref() {
1507        Some("article-magazine") => "article-magazine".to_string(),
1508        Some("article-newspaper") => "article-newspaper".to_string(),
1509        Some("broadcast") => if r
1510            .genre
1511            .as_deref()
1512            .is_some_and(|g| g.to_ascii_lowercase().contains("film"))
1513        {
1514            "motion-picture"
1515        } else {
1516            "broadcast"
1517        }
1518        .to_string(),
1519        _ => "article-journal".to_string(),
1520    }
1521}
1522
1523fn event_ref_type(r: &Event) -> &'static str {
1524    let lowered = r.genre.as_deref().map(str::to_ascii_lowercase);
1525    match lowered.as_deref() {
1526        Some(g) if g.contains("conference") || g.contains("paper") => "paper-conference",
1527        Some(g) if g.contains("broadcast") => "broadcast",
1528        Some(g) if g.contains("talk") || g.contains("speech") => "speech",
1529        _ => "event",
1530    }
1531}
1532
1533fn audio_visual_ref_type(kind: &AudioVisualType) -> &'static str {
1534    match kind {
1535        AudioVisualType::Film => "motion-picture",
1536        AudioVisualType::Episode | AudioVisualType::Broadcast => "broadcast",
1537        AudioVisualType::Recording => "song",
1538    }
1539}
1540
1541/// Collects contributors with a given role from a slice of entries.
1542///
1543/// Returns `None` if no entries match. A single match returns the contributor
1544/// unwrapped; two or more fold into a [`ContributorList`].
1545fn collect_contributors_by_role(
1546    entries: &[ContributorEntry],
1547    role: &ContributorRole,
1548) -> Option<Contributor> {
1549    let mut matches = entries
1550        .iter()
1551        .filter(|e| &e.role == role)
1552        .map(|e| &e.contributor);
1553    let first = matches.next()?;
1554    let Some(second) = matches.next() else {
1555        return Some(first.clone());
1556    };
1557    let list = std::iter::once(first)
1558        .chain(std::iter::once(second))
1559        .chain(matches)
1560        .cloned()
1561        .collect();
1562    Some(Contributor::ContributorList(ContributorList(list)))
1563}