Skip to main content

citum_schema_data/reference/
accessors.rs

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