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 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 forward-compat `unknown_fields` captured for this reference.
1258    ///
1259    /// Returns `None` for unknown-class references: their fields are kept
1260    /// wholesale in [`UnknownClassData::fields`] and reported separately via
1261    /// the unknown-class warning.
1262    pub fn unknown_fields(&self) -> Option<&std::collections::BTreeMap<String, JsonValue>> {
1263        class_dispatch!(&self.extension, |r| Some(&r.unknown_fields), unknown(_) => None)
1264    }
1265
1266    /// Return the embedded inline reference behind `original` if any.
1267    ///
1268    /// All 16 reference classes that carry an `original` relation expose it via
1269    /// the same `WorkRelation` shape (only `AudioVisualWork` nests it through
1270    /// `core`). Centralising the dispatch here lets each `original_*` accessor
1271    /// stay a one-liner.
1272    fn original_embedded(&self) -> Option<&InputReference> {
1273        let relation = match &self.extension {
1274            ClassExtension::Monograph(r) => r.original.as_ref(),
1275            ClassExtension::CollectionComponent(r) => r.original.as_ref(),
1276            ClassExtension::SerialComponent(r) => r.original.as_ref(),
1277            ClassExtension::LegalCase(r) => r.original.as_ref(),
1278            ClassExtension::Statute(r) => r.original.as_ref(),
1279            ClassExtension::Treaty(r) => r.original.as_ref(),
1280            ClassExtension::Hearing(r) => r.original.as_ref(),
1281            ClassExtension::Regulation(r) => r.original.as_ref(),
1282            ClassExtension::Brief(r) => r.original.as_ref(),
1283            ClassExtension::Classic(r) => r.original.as_ref(),
1284            ClassExtension::Patent(r) => r.original.as_ref(),
1285            ClassExtension::Dataset(r) => r.original.as_ref(),
1286            ClassExtension::Standard(r) => r.original.as_ref(),
1287            ClassExtension::Software(r) => r.original.as_ref(),
1288            ClassExtension::Event(r) => r.original.as_ref(),
1289            ClassExtension::AudioVisual(r) => r.core.original.as_ref(),
1290            _ => None,
1291        }?;
1292        match relation {
1293            WorkRelation::Embedded(p) => Some(p),
1294            WorkRelation::Id(_) => None,
1295        }
1296    }
1297
1298    /// Return the original publication date.
1299    pub fn original_date(&self) -> Option<EdtfString> {
1300        self.original_embedded()?.csl_issued_date()
1301    }
1302
1303    /// Return the original title.
1304    pub fn original_title(&self) -> Option<Title> {
1305        self.original_embedded()?.title()
1306    }
1307
1308    /// Return the original publisher as a string.
1309    pub fn original_publisher_str(&self) -> Option<String> {
1310        self.original_embedded()?
1311            .publisher_str()
1312            .filter(|value| !value.is_empty())
1313    }
1314
1315    /// Return the original publisher place.
1316    pub fn original_publisher_place(&self) -> Option<String> {
1317        self.original_embedded()?.publisher_place()
1318    }
1319
1320    /// Return the ISBN.
1321    pub fn isbn(&self) -> Option<String> {
1322        match &self.extension {
1323            ClassExtension::Monograph(r) => r.isbn.clone(),
1324            _ => None,
1325        }
1326    }
1327
1328    /// Return the ISSN.
1329    pub fn issn(&self) -> Option<String> {
1330        match &self.extension {
1331            ClassExtension::SerialComponent(r) => r.container.as_ref().and_then(|c| match c {
1332                WorkRelation::Embedded(s) => s.issn(),
1333                WorkRelation::Id(_) => None,
1334            }),
1335            ClassExtension::Serial(r) => r.issn.clone(),
1336            _ => None,
1337        }
1338    }
1339
1340    /// Return the Keywords.
1341    pub fn keywords(&self) -> Option<Vec<String>> {
1342        match &self.extension {
1343            ClassExtension::Monograph(r) => r.keywords.clone(),
1344            ClassExtension::CollectionComponent(r) => r.keywords.clone(),
1345            ClassExtension::SerialComponent(r) => r.keywords.clone(),
1346            ClassExtension::Collection(r) => r.keywords.clone(),
1347            ClassExtension::Serial(_) => None,
1348            ClassExtension::LegalCase(r) => r.keywords.clone(),
1349            ClassExtension::Statute(r) => r.keywords.clone(),
1350            ClassExtension::Treaty(r) => r.keywords.clone(),
1351            ClassExtension::Hearing(r) => r.keywords.clone(),
1352            ClassExtension::Regulation(r) => r.keywords.clone(),
1353            ClassExtension::Brief(r) => r.keywords.clone(),
1354            ClassExtension::Classic(r) => r.keywords.clone(),
1355            ClassExtension::Patent(r) => r.keywords.clone(),
1356            ClassExtension::Dataset(r) => r.keywords.clone(),
1357            ClassExtension::Standard(r) => r.keywords.clone(),
1358            ClassExtension::Software(r) => r.keywords.clone(),
1359            ClassExtension::Event(_) => None,
1360            ClassExtension::AudioVisual(_) => None,
1361            ClassExtension::Unknown(_) => None,
1362        }
1363    }
1364
1365    /// Return the language.
1366    pub fn language(&self) -> Option<LangID> {
1367        class_dispatch!(
1368            &self.extension,
1369            |r| r.language.clone(),
1370            audio_visual(r) => r.core.language.clone(),
1371            unknown(_) => None
1372        )
1373    }
1374
1375    /// Return field-level language overrides.
1376    pub fn field_languages(&self) -> &FieldLanguageMap {
1377        class_dispatch!(
1378            &self.extension,
1379            |r| &r.field_languages,
1380            unknown(_) => &EMPTY_FIELD_LANGUAGES
1381        )
1382    }
1383
1384    /// Set the reference ID on the class-specific extension.
1385    ///
1386    /// For unknown-class references the id is stored as a `JsonValue::String`
1387    /// inside `UnknownClassData::fields["id"]`. The wire schema requires
1388    /// `id: string`, so round-trip is lossless for valid inputs.
1389    pub fn set_id(&mut self, id: impl Into<RefID>) {
1390        let id = id.into();
1391        class_dispatch!(&mut self.extension, |r| r.id = Some(id.clone()), unknown(data) => {
1392            data.fields
1393                .insert("id".to_string(), JsonValue::String(id.to_string()));
1394        });
1395    }
1396
1397    /// Return the reference type as a string (CSL-compatible).
1398    pub fn ref_type(&self) -> String {
1399        match &self.extension {
1400            ClassExtension::Monograph(r) => self.monograph_ref_type(r),
1401            ClassExtension::CollectionComponent(r) => collection_component_ref_type(r),
1402            ClassExtension::SerialComponent(r) => serial_component_ref_type(r),
1403            ClassExtension::Collection(r) => match r.r#type {
1404                CollectionType::EditedBook => "book",
1405                _ => "collection",
1406            }
1407            .to_string(),
1408            ClassExtension::Serial(r) => match r.r#type {
1409                SerialType::AcademicJournal => "article-journal",
1410                SerialType::Magazine => "article-magazine",
1411                SerialType::Newspaper => "article-newspaper",
1412                SerialType::BroadcastProgram => "broadcast",
1413                _ => "serial",
1414            }
1415            .to_string(),
1416            ClassExtension::LegalCase(_) => "legal-case".to_string(),
1417            ClassExtension::Statute(_) => "statute".to_string(),
1418            ClassExtension::Treaty(_) => "treaty".to_string(),
1419            ClassExtension::Hearing(_) => "hearing".to_string(),
1420            ClassExtension::Regulation(_) => "regulation".to_string(),
1421            ClassExtension::Brief(_) => "brief".to_string(),
1422            ClassExtension::Classic(_) => "classic".to_string(),
1423            ClassExtension::Patent(_) => "patent".to_string(),
1424            ClassExtension::Dataset(_) => "dataset".to_string(),
1425            ClassExtension::Standard(_) => "standard".to_string(),
1426            ClassExtension::Software(_) => "software".to_string(),
1427            ClassExtension::Event(r) => event_ref_type(r).to_string(),
1428            ClassExtension::AudioVisual(r) => audio_visual_ref_type(&r.r#type).to_string(),
1429            ClassExtension::Unknown(data) => {
1430                // Unknown classes round-trip but cannot route to a known CSL
1431                // ref-type; the engine has no template branch for the raw
1432                // class string, so rendering will fall through to the default
1433                // path (typically empty output).
1434                //
1435                // TODO(csl26-1bdr): Layer 5 `CompatibilityWarning` plumbing
1436                // will surface this as a soft-degrade warning rather than
1437                // silent fall-through. Until then we return the raw class
1438                // string so debug builds and logs can identify the value.
1439                debug_assert!(
1440                    !ReferenceClass::KNOWN.contains(&data.class.as_str()),
1441                    "ClassExtension::Unknown should never wrap a known class string (got `{}`)",
1442                    data.class
1443                );
1444                data.class.clone()
1445            }
1446        }
1447    }
1448
1449    fn monograph_ref_type(&self, r: &Monograph) -> String {
1450        match r.r#type {
1451            MonographType::Book => if r
1452                .medium
1453                .as_deref()
1454                .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1455            {
1456                "interview"
1457            } else {
1458                "book"
1459            }
1460            .to_string(),
1461            MonographType::Manual => "manual".to_string(),
1462            MonographType::Report => "report".to_string(),
1463            MonographType::Thesis => "thesis".to_string(),
1464            MonographType::Webpage => "webpage".to_string(),
1465            MonographType::Post => "post".to_string(),
1466            MonographType::Interview => "interview".to_string(),
1467            MonographType::Manuscript => "manuscript".to_string(),
1468            MonographType::Preprint => "preprint".to_string(),
1469            MonographType::PersonalCommunication => "personal-communication".to_string(),
1470            MonographType::Document => {
1471                if let Some(genre) = r.genre.as_deref()
1472                    && matches!(genre, "bill-proceeding" | "bill-record")
1473                {
1474                    return genre.to_string();
1475                }
1476                if self.genre().as_deref() == Some("conference-paper") {
1477                    return "paper-conference".to_string();
1478                }
1479                if r.medium
1480                    .as_deref()
1481                    .is_some_and(|m| m.to_ascii_lowercase().contains("interview"))
1482                {
1483                    "interview"
1484                } else {
1485                    "document"
1486                }
1487                .to_string()
1488            }
1489            _ => r.r#type.as_str().to_string(),
1490        }
1491    }
1492}
1493
1494fn collection_component_ref_type(r: &CollectionComponent) -> String {
1495    match r.r#type {
1496        MonographComponentType::Chapter => match r.genre.as_deref() {
1497            Some("entry-dictionary") => "entry-dictionary",
1498            Some("entry-encyclopedia") => "entry-encyclopedia",
1499            _ => "chapter",
1500        }
1501        .to_string(),
1502        MonographComponentType::Document => "paper-conference".to_string(),
1503        _ => r.r#type.as_str().to_string(),
1504    }
1505}
1506
1507fn serial_component_ref_type(r: &SerialComponent) -> String {
1508    if r.genre.as_deref() == Some("entry-encyclopedia") {
1509        return "entry-encyclopedia".to_string();
1510    }
1511    let container_type = r.container.as_ref().and_then(|c| match c {
1512        WorkRelation::Embedded(p) => Some(p.ref_type()),
1513        WorkRelation::Id(_) => None,
1514    });
1515    match container_type.as_deref() {
1516        Some("article-magazine") => "article-magazine".to_string(),
1517        Some("article-newspaper") => "article-newspaper".to_string(),
1518        Some("broadcast") => if r
1519            .genre
1520            .as_deref()
1521            .is_some_and(|g| g.to_ascii_lowercase().contains("film"))
1522        {
1523            "motion-picture"
1524        } else {
1525            "broadcast"
1526        }
1527        .to_string(),
1528        _ => "article-journal".to_string(),
1529    }
1530}
1531
1532fn event_ref_type(r: &Event) -> &'static str {
1533    let lowered = r.genre.as_deref().map(str::to_ascii_lowercase);
1534    match lowered.as_deref() {
1535        Some(g) if g.contains("conference") || g.contains("paper") => "paper-conference",
1536        Some(g) if g.contains("broadcast") => "broadcast",
1537        Some(g) if g.contains("talk") || g.contains("speech") => "speech",
1538        _ => "event",
1539    }
1540}
1541
1542fn audio_visual_ref_type(kind: &AudioVisualType) -> &'static str {
1543    match kind {
1544        AudioVisualType::Film => "motion-picture",
1545        AudioVisualType::Episode | AudioVisualType::Broadcast => "broadcast",
1546        AudioVisualType::Recording => "song",
1547    }
1548}
1549
1550/// Collects contributors with a given role from a slice of entries.
1551///
1552/// Returns `None` if no entries match. A single match returns the contributor
1553/// unwrapped; two or more fold into a [`ContributorList`].
1554fn collect_contributors_by_role(
1555    entries: &[ContributorEntry],
1556    role: &ContributorRole,
1557) -> Option<Contributor> {
1558    let mut matches = entries
1559        .iter()
1560        .filter(|e| &e.role == role)
1561        .map(|e| &e.contributor);
1562    let first = matches.next()?;
1563    let Some(second) = matches.next() else {
1564        return Some(first.clone());
1565    };
1566    let list = std::iter::once(first)
1567        .chain(std::iter::once(second))
1568        .chain(matches)
1569        .cloned()
1570        .collect();
1571    Some(Contributor::ContributorList(ContributorList(list)))
1572}