Skip to main content

citum_engine/values/
variable.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for simple variables (DOI, URL, ISBN, etc.).
7//!
8//! This module handles template variable rendering, including proper localization
9//! of locator labels and special handling for reference-type-specific variables.
10
11use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::ArchiveHierarchyField;
14use citum_schema::options::titles::TextCase;
15use citum_schema::reference::{ClassExtension, RichText};
16use citum_schema::template::{SimpleVariable, TemplateVariable};
17
18/// Extracts the short title from a parent reference if available.
19///
20/// Returns the `short_title` from the embedded parent of collection or serial
21/// components, or None if the parent is an ID reference or the component
22/// type doesn't support short titles.
23fn container_title_short(reference: &Reference) -> Option<String> {
24    reference.container_title().and_then(|t| match t {
25        citum_schema::reference::types::Title::Shorthand(short, _) => Some(short),
26        citum_schema::reference::types::Title::Single(s) => Some(s),
27        _ => None,
28    })
29}
30
31fn resolve_archive_name(reference: &Reference, options: &RenderOptions<'_>) -> Option<String> {
32    let archive_name = reference.archive_name()?;
33    let multilingual = options.config.multilingual.as_ref();
34
35    Some(crate::values::resolve_multilingual_string(
36        &archive_name,
37        multilingual.and_then(|ml| ml.name_mode.as_ref()),
38        multilingual.and_then(|ml| ml.preferred_transliteration.as_deref()),
39        multilingual.and_then(|ml| ml.preferred_script.as_ref()),
40        options.locale.locale.as_str(),
41    ))
42}
43
44fn assemble_archive_hierarchy(
45    reference: &Reference,
46    options: &RenderOptions<'_>,
47) -> Option<String> {
48    let locale = options.locale;
49    let mut parts: Vec<String> = Vec::new();
50
51    // collection (with optional collection_id in parens)
52    if let Some(collection) = reference.archive_collection() {
53        let label = locale
54            .resolved_archive_term(ArchiveHierarchyField::Collection)
55            .map(|l| format!("{l} "))
56            .unwrap_or_default();
57        if let Some(cid) = reference.archive_collection_id() {
58            parts.push(format!("{label}{collection} ({cid})"));
59        } else {
60            parts.push(format!("{label}{collection}"));
61        }
62    }
63
64    // series
65    if let Some(series) = reference.archive_series() {
66        let label = locale
67            .resolved_archive_term(ArchiveHierarchyField::Series)
68            .map(|l| format!("{l} "))
69            .unwrap_or_default();
70        parts.push(format!("{label}{series}"));
71    }
72
73    // box
74    if let Some(b) = reference.archive_box() {
75        let label = locale
76            .resolved_archive_term(ArchiveHierarchyField::Box)
77            .map(|l| format!("{l} "))
78            .unwrap_or_default();
79        parts.push(format!("{label}{b}"));
80    }
81
82    // folder
83    if let Some(folder) = reference.archive_folder() {
84        let label = locale
85            .resolved_archive_term(ArchiveHierarchyField::Folder)
86            .map(|l| format!("{l} "))
87            .unwrap_or_default();
88        parts.push(format!("{label}{folder}"));
89    }
90
91    // item
92    if let Some(item) = reference.archive_item() {
93        let label = locale
94            .resolved_archive_term(ArchiveHierarchyField::Item)
95            .map(|l| format!("{l} "))
96            .unwrap_or_default();
97        parts.push(format!("{label}{item}"));
98    }
99
100    if parts.is_empty() {
101        None
102    } else {
103        Some(parts.join(", "))
104    }
105}
106
107fn make_rich_text_case_transform(case: TextCase) -> impl FnMut(&str) -> String {
108    let mut seen_alpha = false;
109    move |text: &str| match case {
110        TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
111            let lowered = text.to_lowercase();
112            if seen_alpha {
113                lowered
114            } else {
115                let result = crate::values::text_case::capitalize_first_word(&lowered);
116                if result.chars().any(char::is_alphabetic) {
117                    seen_alpha = true;
118                }
119                result
120            }
121        }
122        _ => crate::values::text_case::apply_text_case(text, case),
123    }
124}
125
126/// Resolve the raw value string for a simple variable from a reference.
127fn resolve_variable_value(
128    variable: &SimpleVariable,
129    reference: &Reference,
130    options: &RenderOptions<'_>,
131) -> Option<String> {
132    match variable {
133        SimpleVariable::Doi => reference.doi(),
134        SimpleVariable::Url => reference.url().map(|u| u.to_string()).or_else(|| {
135            (reference.ref_type() == "dataset")
136                .then(|| reference.doi().map(|doi| format!("https://doi.org/{doi}")))
137                .flatten()
138        }),
139        SimpleVariable::Isbn => reference.isbn(),
140        SimpleVariable::Issn => reference.issn(),
141        SimpleVariable::Publisher => reference.publisher_str(),
142        SimpleVariable::PublisherPlace => reference.publisher_place(),
143        SimpleVariable::OriginalPublisher => reference.original_publisher_str(),
144        SimpleVariable::OriginalPublisherPlace => reference.original_publisher_place(),
145        SimpleVariable::Genre => reference.genre().map(|k| options.locale.lookup_genre(&k)),
146        SimpleVariable::Medium => reference.medium().map(|k| options.locale.lookup_medium(&k)),
147        SimpleVariable::Status => reference.status(),
148        SimpleVariable::Abstract | SimpleVariable::Note => None,
149        SimpleVariable::Archive => reference.archive(),
150        SimpleVariable::ArchiveLocation => reference
151            .archive_location()
152            .or_else(|| assemble_archive_hierarchy(reference, options)),
153        SimpleVariable::ArchiveName => resolve_archive_name(reference, options),
154        SimpleVariable::ArchivePlace => reference.archive_place(),
155        SimpleVariable::ArchiveCollection => reference.archive_collection(),
156        SimpleVariable::ArchiveCollectionId => reference.archive_collection_id(),
157        SimpleVariable::ArchiveSeries => reference.archive_series(),
158        SimpleVariable::ArchiveBox => reference.archive_box(),
159        SimpleVariable::ArchiveFolder => reference.archive_folder(),
160        SimpleVariable::ArchiveItem => reference.archive_item(),
161        SimpleVariable::ArchiveUrl => reference.archive_url().map(|url| url.to_string()),
162        SimpleVariable::EprintId => reference.eprint_id(),
163        SimpleVariable::EprintServer => reference.eprint_server(),
164        SimpleVariable::EprintClass => reference.eprint_class(),
165        SimpleVariable::Authority => reference.authority(),
166        SimpleVariable::Code => reference.code(),
167        SimpleVariable::Reporter => reference.reporter(),
168        SimpleVariable::Page => reference.pages().map(|v| v.to_string()),
169        SimpleVariable::Section => reference.section(),
170        SimpleVariable::Volume => reference.volume().map(|v| v.to_string()),
171        SimpleVariable::Number => reference.number(),
172        SimpleVariable::DocketNumber => match reference.extension() {
173            ClassExtension::Brief(r) => r.docket_number.clone(),
174            _ => None,
175        },
176        SimpleVariable::PatentNumber => match reference.extension() {
177            ClassExtension::Patent(r) => Some(r.patent_number.clone()),
178            _ => None,
179        },
180        SimpleVariable::StandardNumber => match reference.extension() {
181            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
182            _ => None,
183        },
184        SimpleVariable::AdsBibcode => reference.ads_bibcode(),
185        SimpleVariable::ReportNumber => reference.report_number(),
186        SimpleVariable::Version => reference.version(),
187        SimpleVariable::ContainerTitleShort => container_title_short(reference),
188        SimpleVariable::Locator => options.locator_raw.map(|loc| {
189            // When no explicit locators config is set, derive a default from the
190            // processing mode so note styles automatically suppress page labels.
191            let derived;
192            let cfg = if let Some(c) = options.config.locators.as_ref() {
193                c
194            } else {
195                derived = if matches!(
196                    options.config.processing,
197                    Some(citum_schema::options::Processing::Note)
198                ) {
199                    citum_schema::options::LocatorPreset::Note.config()
200                } else {
201                    citum_schema::options::LocatorConfig::default()
202                };
203                &derived
204            };
205            let ref_type = options.ref_type.as_deref().unwrap_or("");
206            crate::values::locator::render_locator(loc, ref_type, cfg, options.locale)
207        }),
208        _ => None,
209    }
210}
211
212impl ComponentValues for TemplateVariable {
213    fn values<F: crate::render::format::OutputFormat<Output = String>>(
214        &self,
215        reference: &Reference,
216        _hints: &ProcHints,
217        options: &RenderOptions<'_>,
218    ) -> Option<ProcValues<F::Output>> {
219        // Rich-text variables carry format metadata — handle before the plain-string path.
220        let rich_text: Option<RichText> = match self.variable {
221            SimpleVariable::Note => reference.note(),
222            SimpleVariable::Abstract => reference.abstract_text(),
223            _ => None,
224        };
225
226        if let Some(rt) = rich_text {
227            if rt.is_empty() {
228                return None;
229            }
230            let fmt = F::default();
231            let (value, pre_formatted) = match (rt, self.rendering.text_case) {
232                (RichText::Plain(s), Some(tc)) => {
233                    (crate::values::text_case::apply_text_case(&s, tc), false)
234                }
235                (RichText::Plain(s), None) => (s, false),
236                (RichText::Djot { djot }, Some(tc)) => (
237                    crate::render::rich_text::render_djot_inline_with_transform(
238                        &djot,
239                        &fmt,
240                        make_rich_text_case_transform(tc),
241                    )
242                    .0,
243                    true,
244                ),
245                (RichText::Djot { djot }, None) => {
246                    (crate::render::render_djot_inline(&djot, &fmt), true)
247                }
248            };
249            return Some(ProcValues {
250                value,
251                prefix: None,
252                suffix: None,
253                url: None,
254                substituted_key: None,
255                pre_formatted,
256            });
257        }
258
259        // Plain-string path for all other variables.
260        let value = resolve_variable_value(&self.variable, reference, options);
261
262        value.filter(|s: &String| !s.is_empty()).map(|value| {
263            let value = if let Some(tc) = self.rendering.text_case {
264                crate::values::text_case::apply_text_case(&value, tc)
265            } else {
266                value
267            };
268            let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
269            use citum_schema::options::{LinkAnchor, LinkTarget};
270            let component_anchor = match self.variable {
271                SimpleVariable::Url => LinkAnchor::Url,
272                SimpleVariable::Doi => LinkAnchor::Doi,
273                _ => LinkAnchor::Component,
274            };
275
276            let mut url = crate::values::resolve_effective_url(
277                self.links.as_ref(),
278                options.config.links.as_ref(),
279                reference,
280                component_anchor,
281            );
282
283            // Fallback for simple legacy config
284            if url.is_none()
285                && let Some(links) = &self.links
286            {
287                if self.variable == SimpleVariable::Url
288                    && (links.url == Some(true)
289                        || matches!(links.target, Some(LinkTarget::Url | LinkTarget::UrlOrDoi)))
290                {
291                    url = reference.url().map(|u| u.to_string());
292                } else if self.variable == SimpleVariable::Doi
293                    && (links.doi == Some(true)
294                        || matches!(links.target, Some(LinkTarget::Doi | LinkTarget::UrlOrDoi)))
295                {
296                    url = reference.doi().map(|d| format!("https://doi.org/{d}"));
297                }
298            }
299
300            ProcValues {
301                value,
302                prefix: None,
303                suffix: None,
304                url,
305                substituted_key: None,
306                pre_formatted: false,
307            }
308        })
309    }
310}