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