Skip to main content

bible_io/
source.rs

1//! Translation metadata and reusable source catalogs.
2
3use std::{
4    collections::HashMap,
5    fmt,
6    hash::{Hash, Hasher},
7    path::Path,
8    str::FromStr,
9};
10
11use bible_io_references::Language;
12use indexmap::IndexMap;
13use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
14use serde_json::{Map, Value};
15
16use crate::{
17    errors::{BibleDataFormatError, BibleDataFormatErrorCode},
18    json_value::hash_json_map,
19};
20
21/// Scripture text direction hint.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum TextDirectionHint {
25    /// Let the consumer infer direction.
26    #[default]
27    Auto,
28    /// Left-to-right text.
29    Ltr,
30    /// Right-to-left text.
31    Rtl,
32}
33
34impl TextDirectionHint {
35    /// Parse common direction spellings, defaulting unknown values to auto.
36    #[must_use]
37    pub fn from_name(value: Option<&str>) -> Self {
38        match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
39            Some("ltr" | "left-to-right" | "left_to_right") => Self::Ltr,
40            Some("rtl" | "right-to-left" | "right_to_left") => Self::Rtl,
41            _ => Self::Auto,
42        }
43    }
44
45    fn parse(value: &str, path: &str) -> Result<Self, BibleDataFormatError> {
46        match value.trim().to_ascii_lowercase().as_str() {
47            "auto" => Ok(Self::Auto),
48            "ltr" | "left-to-right" | "left_to_right" => Ok(Self::Ltr),
49            "rtl" | "right-to-left" | "right_to_left" => Ok(Self::Rtl),
50            _ => Err(BibleDataFormatError::new(
51                BibleDataFormatErrorCode::InvalidValue,
52                path,
53                "text direction must be auto, ltr, or rtl",
54            )
55            .with_value(Value::String(value.to_string()))),
56        }
57    }
58}
59
60impl<'de> Deserialize<'de> for TextDirectionHint {
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        let value = String::deserialize(deserializer)?;
66        Self::parse(&value, "$direction").map_err(D::Error::custom)
67    }
68}
69
70/// Metadata for one loadable Bible source.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct BibleSource {
74    /// Stable source/edition identifier.
75    pub id: String,
76    /// File, asset, or URL path.
77    pub asset_path: String,
78    /// Human-readable language name.
79    pub language_name: String,
80    /// ISO language code.
81    pub language_code: String,
82    /// Human-readable translation name.
83    pub translation_name: String,
84    /// Short translation label.
85    pub abbreviation: String,
86    /// Optional description.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub description: Option<String>,
89    /// Optional publication year.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub year: Option<i32>,
92    /// Text direction.
93    pub direction: TextDirectionHint,
94    /// Optional upstream source name.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub source_name: Option<String>,
97    /// Optional content copyright statement.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub copyright: Option<String>,
100    /// Optional content license identifier or URL.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub license: Option<String>,
103    /// Optional canon label.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub canon: Option<String>,
106    /// Optional ISO-8601 version date, preserved as text.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub version_date: Option<String>,
109    /// Unknown source fields preserved across serialization.
110    #[serde(flatten)]
111    pub additional: Map<String, Value>,
112}
113
114impl BibleSource {
115    /// Derive conventional metadata from an asset path.
116    #[must_use]
117    pub fn from_asset_path(asset_path: impl Into<String>) -> Self {
118        Self::from_asset_path_with(asset_path.into(), None, None)
119    }
120
121    fn from_asset_path_with(
122        asset_path: String,
123        id: Option<&str>,
124        language_name: Option<&str>,
125    ) -> Self {
126        let normalized = asset_path.replace('\\', "/");
127        let segments: Vec<_> = normalized
128            .split('/')
129            .filter(|part| !part.is_empty())
130            .collect();
131        let file = segments.last().copied().unwrap_or(&normalized);
132        let stem = Path::new(file)
133            .file_stem()
134            .and_then(|value| value.to_str())
135            .unwrap_or(file);
136        let language_name = language_name.map_or_else(
137            || {
138                if segments.len() > 1 {
139                    label_from_segment(segments[segments.len() - 2])
140                } else {
141                    String::new()
142                }
143            },
144            str::to_string,
145        );
146        let language_code = language_code_for_name(&language_name).unwrap_or_default();
147        let abbreviation = stem.to_ascii_uppercase();
148        let id = id.map_or_else(
149            || sanitize_id(&format!("{language_name}_{abbreviation}")),
150            str::to_string,
151        );
152        Self {
153            id,
154            asset_path,
155            language_name,
156            language_code: language_code.clone(),
157            translation_name: label_from_segment(stem).to_ascii_uppercase(),
158            abbreviation,
159            description: None,
160            year: None,
161            direction: direction_for_language(&language_code),
162            source_name: None,
163            copyright: None,
164            license: None,
165            canon: None,
166            version_date: None,
167            additional: Map::new(),
168        }
169    }
170
171    /// Parse a source object with aliases, inference, and path-aware errors.
172    pub fn from_value(value: &Value) -> Result<Self, BibleDataFormatError> {
173        Self::from_value_at(value, "$")
174    }
175
176    /// Parse a source from JSON text.
177    pub fn from_json(input: &str) -> Result<Self, BibleDataFormatError> {
178        let value = serde_json::from_str(input).map_err(|error| {
179            BibleDataFormatError::new(
180                BibleDataFormatErrorCode::InvalidJson,
181                "$",
182                "Bible source is not valid JSON",
183            )
184            .with_cause(error)
185        })?;
186        Self::from_value(&value)
187    }
188
189    fn from_value_at(value: &Value, path: &str) -> Result<Self, BibleDataFormatError> {
190        let object = value.as_object().ok_or_else(|| {
191            data_error(
192                BibleDataFormatErrorCode::InvalidType,
193                path,
194                "Bible source must be an object",
195                value,
196            )
197        })?;
198
199        let asset_path = read_string(object, SOURCE_PATH_KEYS, path)?.unwrap_or_default();
200        let fallback = (!asset_path.is_empty()).then(|| Self::from_asset_path(asset_path.clone()));
201        let language_name =
202            read_string(object, &["languageName", "language_name", "language"], path)?
203                .or_else(|| fallback.as_ref().map(|source| source.language_name.clone()))
204                .unwrap_or_default();
205        let language_code = read_string(object, &["languageCode", "language_code", "lang"], path)?
206            .or_else(|| fallback.as_ref().map(|source| source.language_code.clone()))
207            .or_else(|| language_code_for_name(&language_name))
208            .unwrap_or_default();
209        let abbreviation = read_string(
210            object,
211            &["abbreviation", "abbr", "shortName", "short_name"],
212            path,
213        )?
214        .or_else(|| fallback.as_ref().map(|source| source.abbreviation.clone()))
215        .unwrap_or_default();
216        let translation_name = read_string(
217            object,
218            &[
219                "translationName",
220                "translation_name",
221                "name",
222                "title",
223                "version",
224            ],
225            path,
226        )?
227        .or_else(|| {
228            fallback
229                .as_ref()
230                .map(|source| source.translation_name.clone())
231        })
232        .unwrap_or_else(|| abbreviation.clone());
233        let direction = read_direction(object, path)?
234            .or_else(|| fallback.as_ref().map(|source| source.direction))
235            .unwrap_or_else(|| direction_for_language(&language_code));
236
237        let source = Self {
238            id: read_identifier(object, &["id", "key"], path)?
239                .or_else(|| fallback.as_ref().map(|source| source.id.clone()))
240                .unwrap_or_else(|| sanitize_id(&format!("{language_name}_{abbreviation}"))),
241            asset_path,
242            language_name,
243            language_code,
244            translation_name,
245            abbreviation,
246            description: read_string(object, &["description", "summary"], path)?,
247            year: read_i32(object, &["year"], path)?,
248            direction,
249            source_name: read_string(object, &["sourceName", "source_name", "source"], path)?,
250            copyright: read_string(object, &["copyright"], path)?,
251            license: read_string(object, &["license"], path)?,
252            canon: read_string(object, &["canon"], path)?,
253            version_date: read_date(object, &["versionDate", "version_date", "date"], path)?,
254            additional: additional_fields(object, SOURCE_RECOGNIZED_KEYS),
255        };
256        source.validate(path)?;
257        Ok(source)
258    }
259
260    /// Validate required fields, canonical identity, dates, and extensions.
261    pub fn validate(&self, path: &str) -> Result<(), BibleDataFormatError> {
262        for (field, value) in [
263            ("id", self.id.as_str()),
264            ("assetPath", self.asset_path.as_str()),
265            ("languageName", self.language_name.as_str()),
266            ("languageCode", self.language_code.as_str()),
267            ("translationName", self.translation_name.as_str()),
268            ("abbreviation", self.abbreviation.as_str()),
269        ] {
270            if value.trim().is_empty() {
271                return Err(BibleDataFormatError::new(
272                    BibleDataFormatErrorCode::MissingField,
273                    json_path(path, field),
274                    format!("required source field {field} cannot be blank"),
275                )
276                .with_value(Value::String(value.to_string())));
277            }
278        }
279        if self.id.trim() != self.id {
280            return Err(BibleDataFormatError::new(
281                BibleDataFormatErrorCode::InvalidValue,
282                json_path(path, "id"),
283                "source IDs cannot have surrounding whitespace",
284            )
285            .with_value(Value::String(self.id.clone())));
286        }
287        validate_optional_date(
288            self.version_date.as_deref(),
289            &json_path(path, "versionDate"),
290        )?;
291        validate_additional(&self.additional, METADATA_RECOGNIZED_KEYS, path)?;
292        Ok(())
293    }
294
295    /// Return this source as a JSON object.
296    #[must_use]
297    pub fn to_json_value(&self) -> Value {
298        serde_json::to_value(self).expect("BibleSource contains only JSON values")
299    }
300}
301
302impl<'de> Deserialize<'de> for BibleSource {
303    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
304    where
305        D: Deserializer<'de>,
306    {
307        let value = Value::deserialize(deserializer)?;
308        Self::from_value_at(&value, "$").map_err(D::Error::custom)
309    }
310}
311
312impl Hash for BibleSource {
313    fn hash<H: Hasher>(&self, state: &mut H) {
314        self.id.hash(state);
315        self.asset_path.hash(state);
316        self.language_name.hash(state);
317        self.language_code.hash(state);
318        self.translation_name.hash(state);
319        self.abbreviation.hash(state);
320        self.description.hash(state);
321        self.year.hash(state);
322        self.direction.hash(state);
323        self.source_name.hash(state);
324        self.copyright.hash(state);
325        self.license.hash(state);
326        self.canon.hash(state);
327        self.version_date.hash(state);
328        hash_json_map(&self.additional, state);
329    }
330}
331
332/// Metadata attached to a loaded Bible instance.
333#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
334#[serde(rename_all = "camelCase")]
335pub struct BibleMetadata {
336    /// Optional nested source provenance.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub source: Option<BibleSource>,
339    /// Stable edition ID.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub id: Option<String>,
342    /// Translation description.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub description: Option<String>,
345    /// Human-readable language name.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub language_name: Option<String>,
348    /// ISO language code.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub language_code: Option<String>,
351    /// Translation display name.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub translation_name: Option<String>,
354    /// Translation abbreviation.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub abbreviation: Option<String>,
357    /// Optional publication year.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub year: Option<i32>,
360    /// Text direction.
361    pub direction: TextDirectionHint,
362    /// Optional upstream source name.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub source_name: Option<String>,
365    /// Optional content copyright.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub copyright: Option<String>,
368    /// Optional content license.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub license: Option<String>,
371    /// Optional canon label.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub canon: Option<String>,
374    /// Optional ISO-8601 version date, preserved as text.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub version_date: Option<String>,
377    /// Unknown metadata fields preserved across serialization.
378    #[serde(flatten)]
379    pub additional: Map<String, Value>,
380}
381
382impl BibleMetadata {
383    /// Parse a standalone metadata object.
384    pub fn from_value(value: &Value) -> Result<Self, BibleDataFormatError> {
385        let object = value.as_object().ok_or_else(|| {
386            data_error(
387                BibleDataFormatErrorCode::InvalidType,
388                "$.metadata",
389                "Bible metadata must be an object",
390                value,
391            )
392        })?;
393        Self::from_layers(Some(object), None, None, "$.metadata")
394    }
395
396    /// Read metadata from a complete Bible document.
397    ///
398    /// Values nested in `metadata` take precedence over legacy root values,
399    /// followed by an explicitly supplied source, an embedded source, and
400    /// inferred language fallbacks.
401    pub fn from_document_value(
402        document: &Value,
403        supplied_source: Option<&BibleSource>,
404    ) -> Result<Self, BibleDataFormatError> {
405        let root = document.as_object().ok_or_else(|| {
406            data_error(
407                BibleDataFormatErrorCode::InvalidType,
408                "$",
409                "Bible JSON must have an object at its root",
410                document,
411            )
412        })?;
413        let nested = match root.get("metadata") {
414            None | Some(Value::Null) => None,
415            Some(Value::Object(object)) => Some(object),
416            Some(value) => {
417                return Err(data_error(
418                    BibleDataFormatErrorCode::InvalidType,
419                    "$.metadata",
420                    "Bible metadata must be an object",
421                    value,
422                ));
423            }
424        };
425        Self::from_layers(nested, Some(root), supplied_source, "$.metadata")
426    }
427
428    fn from_layers(
429        nested: Option<&Map<String, Value>>,
430        root: Option<&Map<String, Value>>,
431        supplied_source: Option<&BibleSource>,
432        path: &str,
433    ) -> Result<Self, BibleDataFormatError> {
434        let embedded = read_embedded_source(nested, root)?;
435        let source = supplied_source.cloned().or(embedded);
436
437        let language_name = read_layered_string(
438            nested,
439            root,
440            &["languageName", "language_name", "language"],
441            path,
442        )?
443        .or_else(|| source.as_ref().map(|source| source.language_name.clone()));
444        let language_code = read_layered_string(
445            nested,
446            root,
447            &["languageCode", "language_code", "lang"],
448            path,
449        )?
450        .or_else(|| source.as_ref().map(|source| source.language_code.clone()))
451        .or_else(|| language_name.as_deref().and_then(language_code_for_name));
452        let explicit_direction = read_layered_direction(nested, root, path)?;
453        let direction = explicit_direction
454            .or_else(|| source.as_ref().map(|source| source.direction))
455            .unwrap_or_else(|| {
456                direction_for_language(language_code.as_deref().unwrap_or_default())
457            });
458
459        let mut additional = Map::new();
460        if let Some(root) = root {
461            additional.extend(additional_fields(root, ROOT_METADATA_RECOGNIZED_KEYS));
462        }
463        if let Some(nested) = nested {
464            additional.extend(additional_fields(nested, METADATA_RECOGNIZED_KEYS));
465        }
466
467        let metadata = Self {
468            source,
469            id: read_layered_identifier(nested, root, &["id", "editionId", "edition_id"], path)?,
470            description: read_layered_string(nested, root, &["description", "summary"], path)?,
471            language_name,
472            language_code,
473            translation_name: read_layered_string(
474                nested,
475                root,
476                &[
477                    "translationName",
478                    "translation_name",
479                    "name",
480                    "title",
481                    "version",
482                ],
483                path,
484            )?,
485            abbreviation: read_layered_string(
486                nested,
487                root,
488                &["abbreviation", "abbr", "shortName", "short_name"],
489                path,
490            )?,
491            year: read_layered_i32(nested, root, &["year"], path)?,
492            direction,
493            source_name: read_layered_string(nested, root, &["sourceName", "source_name"], path)?,
494            copyright: read_layered_string(nested, root, &["copyright"], path)?,
495            license: read_layered_string(nested, root, &["license"], path)?,
496            canon: read_layered_string(nested, root, &["canon"], path)?,
497            version_date: read_layered_date(
498                nested,
499                root,
500                &["versionDate", "version_date", "date"],
501                path,
502            )?,
503            additional,
504        };
505
506        let mut metadata = fill_from_source(metadata);
507        if metadata.language_code.is_none() {
508            metadata.language_code = metadata
509                .language_name
510                .as_deref()
511                .and_then(language_code_for_name);
512        }
513        metadata.validate(path)?;
514        Ok(metadata)
515    }
516
517    /// Validate optional identity, nested source, dates, and extensions.
518    pub fn validate(&self, path: &str) -> Result<(), BibleDataFormatError> {
519        if let Some(id) = &self.id {
520            if id.trim().is_empty() || id.trim() != id {
521                return Err(BibleDataFormatError::new(
522                    BibleDataFormatErrorCode::InvalidValue,
523                    json_path(path, "id"),
524                    "edition IDs must be non-blank and trimmed",
525                )
526                .with_value(Value::String(id.clone())));
527            }
528        }
529        if let Some(source) = &self.source {
530            source.validate(&json_path(path, "source"))?;
531        }
532        validate_optional_date(
533            self.version_date.as_deref(),
534            &json_path(path, "versionDate"),
535        )?;
536        validate_additional(&self.additional, METADATA_RECOGNIZED_KEYS, path)?;
537        Ok(())
538    }
539
540    /// Return this metadata as a JSON object.
541    #[must_use]
542    pub fn to_json_value(&self) -> Value {
543        serde_json::to_value(self).expect("BibleMetadata contains only JSON values")
544    }
545}
546
547impl<'de> Deserialize<'de> for BibleMetadata {
548    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
549    where
550        D: Deserializer<'de>,
551    {
552        let value = Value::deserialize(deserializer)?;
553        Self::from_value(&value).map_err(D::Error::custom)
554    }
555}
556
557impl Hash for BibleMetadata {
558    fn hash<H: Hasher>(&self, state: &mut H) {
559        self.source.hash(state);
560        self.id.hash(state);
561        self.description.hash(state);
562        self.language_name.hash(state);
563        self.language_code.hash(state);
564        self.translation_name.hash(state);
565        self.abbreviation.hash(state);
566        self.year.hash(state);
567        self.direction.hash(state);
568        self.source_name.hash(state);
569        self.copyright.hash(state);
570        self.license.hash(state);
571        self.canon.hash(state);
572        self.version_date.hash(state);
573        hash_json_map(&self.additional, state);
574    }
575}
576
577/// Merge explicit metadata, source data, and language fallbacks.
578pub fn merge_bible_metadata(
579    metadata: Option<&BibleMetadata>,
580    source: Option<&BibleSource>,
581    fallback_language_name: Option<&str>,
582    fallback_language_code: Option<&str>,
583) -> Result<BibleMetadata, BibleDataFormatError> {
584    let effective_source = source
585        .cloned()
586        .or_else(|| metadata.and_then(|value| value.source.clone()));
587    let metadata_direction = metadata.map(|value| value.direction);
588    let direction = match metadata_direction {
589        Some(direction) if direction != TextDirectionHint::Auto => direction,
590        _ => effective_source
591            .as_ref()
592            .map(|source| source.direction)
593            .or(metadata_direction)
594            .unwrap_or_else(|| {
595                direction_for_language(
596                    metadata
597                        .and_then(|value| value.language_code.as_deref())
598                        .or_else(|| {
599                            effective_source
600                                .as_ref()
601                                .map(|value| value.language_code.as_str())
602                        })
603                        .or(fallback_language_code)
604                        .unwrap_or_default(),
605                )
606            }),
607    };
608
609    let merged = BibleMetadata {
610        source: effective_source.clone(),
611        id: metadata
612            .and_then(|value| value.id.clone())
613            .or_else(|| effective_source.as_ref().map(|value| value.id.clone())),
614        description: metadata
615            .and_then(|value| value.description.clone())
616            .or_else(|| {
617                effective_source
618                    .as_ref()
619                    .and_then(|value| value.description.clone())
620            }),
621        language_name: metadata
622            .and_then(|value| value.language_name.clone())
623            .or_else(|| {
624                effective_source
625                    .as_ref()
626                    .map(|value| value.language_name.clone())
627            })
628            .or_else(|| fallback_language_name.map(str::to_string)),
629        language_code: metadata
630            .and_then(|value| value.language_code.clone())
631            .or_else(|| {
632                effective_source
633                    .as_ref()
634                    .map(|value| value.language_code.clone())
635            })
636            .or_else(|| fallback_language_code.map(str::to_string)),
637        translation_name: metadata
638            .and_then(|value| value.translation_name.clone())
639            .or_else(|| {
640                effective_source
641                    .as_ref()
642                    .map(|value| value.translation_name.clone())
643            }),
644        abbreviation: metadata
645            .and_then(|value| value.abbreviation.clone())
646            .or_else(|| {
647                effective_source
648                    .as_ref()
649                    .map(|value| value.abbreviation.clone())
650            }),
651        year: metadata
652            .and_then(|value| value.year)
653            .or_else(|| effective_source.as_ref().and_then(|value| value.year)),
654        direction,
655        source_name: metadata
656            .and_then(|value| value.source_name.clone())
657            .or_else(|| {
658                effective_source
659                    .as_ref()
660                    .and_then(|value| value.source_name.clone())
661            }),
662        copyright: metadata
663            .and_then(|value| value.copyright.clone())
664            .or_else(|| {
665                effective_source
666                    .as_ref()
667                    .and_then(|value| value.copyright.clone())
668            }),
669        license: metadata
670            .and_then(|value| value.license.clone())
671            .or_else(|| {
672                effective_source
673                    .as_ref()
674                    .and_then(|value| value.license.clone())
675            }),
676        canon: metadata.and_then(|value| value.canon.clone()).or_else(|| {
677            effective_source
678                .as_ref()
679                .and_then(|value| value.canon.clone())
680        }),
681        version_date: metadata
682            .and_then(|value| value.version_date.clone())
683            .or_else(|| {
684                effective_source
685                    .as_ref()
686                    .and_then(|value| value.version_date.clone())
687            }),
688        additional: metadata.map_or_else(Map::new, |value| value.additional.clone()),
689    };
690    merged.validate("$.metadata")?;
691    Ok(merged)
692}
693
694/// Collection of available Bible sources indexed by stable ID.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub struct BibleCatalog {
697    sources: Vec<BibleSource>,
698    by_id: HashMap<String, usize>,
699}
700
701impl Hash for BibleCatalog {
702    fn hash<H: Hasher>(&self, state: &mut H) {
703        self.sources.hash(state);
704    }
705}
706
707impl BibleCatalog {
708    /// Construct a catalog and reject duplicate or invalid sources.
709    pub fn new(sources: Vec<BibleSource>) -> Result<Self, BibleDataFormatError> {
710        let mut by_id = HashMap::new();
711        for (index, source) in sources.iter().enumerate() {
712            let path = format!("$.sources[{index}]");
713            source.validate(&path)?;
714            if by_id.insert(source.id.clone(), index).is_some() {
715                return Err(BibleDataFormatError::new(
716                    BibleDataFormatErrorCode::DuplicateId,
717                    format!("{path}.id"),
718                    "Bible source IDs must be unique",
719                ));
720            }
721        }
722        Ok(Self { sources, by_id })
723    }
724
725    /// Parse a catalog from JSON text.
726    pub fn from_json(input: &str) -> Result<Self, BibleDataFormatError> {
727        let value: Value = serde_json::from_str(input).map_err(|error| {
728            BibleDataFormatError::new(
729                BibleDataFormatErrorCode::InvalidJson,
730                "$",
731                "catalog is not valid JSON",
732            )
733            .with_cause(error)
734        })?;
735        Self::from_value(&value)
736    }
737
738    /// Parse a catalog from UTF-8 JSON bytes.
739    pub fn from_json_slice(input: &[u8]) -> Result<Self, BibleDataFormatError> {
740        let text = std::str::from_utf8(input).map_err(|error| {
741            BibleDataFormatError::new(
742                BibleDataFormatErrorCode::InvalidJson,
743                "$",
744                "catalog is not valid UTF-8",
745            )
746            .with_cause(error)
747        })?;
748        Self::from_json(text)
749    }
750
751    /// Parse a list, container, path, ID map, or nested language map.
752    pub fn from_value(value: &Value) -> Result<Self, BibleDataFormatError> {
753        let mut sources = Vec::new();
754        parse_catalog_value(value, "$", None, None, false, &mut sources)?;
755        Self::new(sources)
756    }
757
758    /// Return every source in catalog order.
759    #[must_use]
760    pub fn sources(&self) -> &[BibleSource] {
761        &self.sources
762    }
763
764    /// Find a source by exact stable ID.
765    #[must_use]
766    pub fn find_by_id(&self, id: &str) -> Option<&BibleSource> {
767        self.by_id.get(id).map(|index| &self.sources[*index])
768    }
769
770    /// Find sources by case-insensitive language name or code.
771    #[must_use]
772    pub fn for_language(&self, language: &str) -> Vec<&BibleSource> {
773        self.sources
774            .iter()
775            .filter(|source| {
776                source.language_name.eq_ignore_ascii_case(language.trim())
777                    || source.language_code.eq_ignore_ascii_case(language.trim())
778            })
779            .collect()
780    }
781
782    /// Group sources by their display language in catalog order.
783    #[must_use]
784    pub fn by_language_name(&self) -> IndexMap<&str, Vec<&BibleSource>> {
785        let mut grouped = IndexMap::new();
786        for source in &self.sources {
787            grouped
788                .entry(source.language_name.as_str())
789                .or_insert_with(Vec::new)
790                .push(source);
791        }
792        grouped
793    }
794}
795
796fn parse_catalog_value(
797    value: &Value,
798    path: &str,
799    language: Option<&str>,
800    id: Option<&str>,
801    expect_source: bool,
802    output: &mut Vec<BibleSource>,
803) -> Result<(), BibleDataFormatError> {
804    match value {
805        Value::String(asset_path) => {
806            if asset_path.trim().is_empty() {
807                return Err(data_error(
808                    BibleDataFormatErrorCode::InvalidValue,
809                    path,
810                    "Bible source asset path cannot be blank",
811                    value,
812                ));
813            }
814            let source = BibleSource::from_asset_path_with(asset_path.clone(), id, language);
815            push_catalog_source(source, path, output)
816        }
817        Value::Array(items) => {
818            for (index, item) in items.iter().enumerate() {
819                parse_catalog_value(
820                    item,
821                    &format!("{path}[{index}]"),
822                    language,
823                    None,
824                    true,
825                    output,
826                )?;
827            }
828            Ok(())
829        }
830        Value::Object(object) => {
831            if expect_source || looks_like_source(object) {
832                let mut source_value = object.clone();
833                if let Some(id) = id {
834                    source_value
835                        .entry("id".to_string())
836                        .or_insert_with(|| Value::String(id.to_string()));
837                }
838                if let Some(language) = language {
839                    source_value
840                        .entry("languageName".to_string())
841                        .or_insert_with(|| Value::String(language.to_string()));
842                }
843                let source = BibleSource::from_value_at(&Value::Object(source_value), path)?;
844                return push_catalog_source(source, path, output);
845            }
846
847            let containers: Vec<_> = ["sources", "bibles", "translations"]
848                .into_iter()
849                .filter(|key| object.contains_key(*key))
850                .collect();
851            if containers.len() > 1 {
852                return Err(BibleDataFormatError::new(
853                    BibleDataFormatErrorCode::InvalidValue,
854                    path,
855                    "catalog must use only one source container key",
856                ));
857            }
858            if let Some(container) = containers.first() {
859                return parse_catalog_value(
860                    &object[*container],
861                    &json_path(path, container),
862                    language,
863                    None,
864                    false,
865                    output,
866                );
867            }
868
869            for (key, child) in object {
870                let child_path = json_path(path, key);
871                match child {
872                    Value::String(_) => {
873                        parse_catalog_value(
874                            child,
875                            &child_path,
876                            language,
877                            Some(key),
878                            false,
879                            output,
880                        )?;
881                    }
882                    Value::Array(_) => {
883                        parse_catalog_value(
884                            child,
885                            &child_path,
886                            language.or(Some(key)),
887                            None,
888                            false,
889                            output,
890                        )?;
891                    }
892                    Value::Object(child_object) if looks_like_source(child_object) => {
893                        parse_catalog_value(
894                            child,
895                            &child_path,
896                            language,
897                            Some(key),
898                            false,
899                            output,
900                        )?;
901                    }
902                    Value::Object(_) => {
903                        parse_catalog_value(
904                            child,
905                            &child_path,
906                            language.or(Some(key)),
907                            None,
908                            false,
909                            output,
910                        )?;
911                    }
912                    _ => {
913                        return Err(data_error(
914                            BibleDataFormatErrorCode::InvalidType,
915                            &child_path,
916                            "catalog entry must be a source object, list, or path",
917                            child,
918                        ));
919                    }
920                }
921            }
922            Ok(())
923        }
924        _ => Err(data_error(
925            BibleDataFormatErrorCode::InvalidType,
926            path,
927            "catalog entries must be source objects, lists, or paths",
928            value,
929        )),
930    }
931}
932
933fn push_catalog_source(
934    source: BibleSource,
935    path: &str,
936    output: &mut Vec<BibleSource>,
937) -> Result<(), BibleDataFormatError> {
938    source.validate(path)?;
939    output.push(source);
940    Ok(())
941}
942
943fn read_embedded_source(
944    nested: Option<&Map<String, Value>>,
945    root: Option<&Map<String, Value>>,
946) -> Result<Option<BibleSource>, BibleDataFormatError> {
947    if let Some(nested) = nested {
948        if let Some(value) = nested.get("source") {
949            if !value.is_null() {
950                return BibleSource::from_value_at(value, "$.metadata.source").map(Some);
951            }
952        }
953    }
954    if let Some(root) = root {
955        if let Some(value) = root.get("source") {
956            if !value.is_null() {
957                return BibleSource::from_value_at(value, "$.source").map(Some);
958            }
959        }
960    }
961    Ok(None)
962}
963
964fn fill_from_source(mut metadata: BibleMetadata) -> BibleMetadata {
965    let Some(source) = metadata.source.as_ref() else {
966        return metadata;
967    };
968    metadata.id.get_or_insert_with(|| source.id.clone());
969    if metadata.description.is_none() {
970        metadata.description = source.description.clone();
971    }
972    metadata
973        .language_name
974        .get_or_insert_with(|| source.language_name.clone());
975    metadata
976        .language_code
977        .get_or_insert_with(|| source.language_code.clone());
978    metadata
979        .translation_name
980        .get_or_insert_with(|| source.translation_name.clone());
981    metadata
982        .abbreviation
983        .get_or_insert_with(|| source.abbreviation.clone());
984    if metadata.year.is_none() {
985        metadata.year = source.year;
986    }
987    if metadata.source_name.is_none() {
988        metadata.source_name = source.source_name.clone();
989    }
990    if metadata.copyright.is_none() {
991        metadata.copyright = source.copyright.clone();
992    }
993    if metadata.license.is_none() {
994        metadata.license = source.license.clone();
995    }
996    if metadata.canon.is_none() {
997        metadata.canon = source.canon.clone();
998    }
999    if metadata.version_date.is_none() {
1000        metadata.version_date = source.version_date.clone();
1001    }
1002    metadata
1003}
1004
1005fn read_layered_string(
1006    nested: Option<&Map<String, Value>>,
1007    root: Option<&Map<String, Value>>,
1008    keys: &[&str],
1009    path: &str,
1010) -> Result<Option<String>, BibleDataFormatError> {
1011    if let Some(nested) = nested {
1012        if let Some(value) = read_string(nested, keys, path)? {
1013            return Ok(Some(value));
1014        }
1015    }
1016    root.map_or(Ok(None), |root| read_string(root, keys, "$"))
1017}
1018
1019fn read_layered_identifier(
1020    nested: Option<&Map<String, Value>>,
1021    root: Option<&Map<String, Value>>,
1022    keys: &[&str],
1023    path: &str,
1024) -> Result<Option<String>, BibleDataFormatError> {
1025    if let Some(nested) = nested {
1026        if let Some(value) = read_identifier(nested, keys, path)? {
1027            return Ok(Some(value));
1028        }
1029    }
1030    root.map_or(Ok(None), |root| read_identifier(root, keys, "$"))
1031}
1032
1033fn read_layered_i32(
1034    nested: Option<&Map<String, Value>>,
1035    root: Option<&Map<String, Value>>,
1036    keys: &[&str],
1037    path: &str,
1038) -> Result<Option<i32>, BibleDataFormatError> {
1039    if let Some(nested) = nested {
1040        if let Some(value) = read_i32(nested, keys, path)? {
1041            return Ok(Some(value));
1042        }
1043    }
1044    root.map_or(Ok(None), |root| read_i32(root, keys, "$"))
1045}
1046
1047fn read_layered_date(
1048    nested: Option<&Map<String, Value>>,
1049    root: Option<&Map<String, Value>>,
1050    keys: &[&str],
1051    path: &str,
1052) -> Result<Option<String>, BibleDataFormatError> {
1053    if let Some(nested) = nested {
1054        if let Some(value) = read_date(nested, keys, path)? {
1055            return Ok(Some(value));
1056        }
1057    }
1058    root.map_or(Ok(None), |root| read_date(root, keys, "$"))
1059}
1060
1061fn read_layered_direction(
1062    nested: Option<&Map<String, Value>>,
1063    root: Option<&Map<String, Value>>,
1064    path: &str,
1065) -> Result<Option<TextDirectionHint>, BibleDataFormatError> {
1066    if let Some(nested) = nested {
1067        if let Some(value) = read_direction(nested, path)? {
1068            return Ok(Some(value));
1069        }
1070    }
1071    root.map_or(Ok(None), |root| read_direction(root, "$"))
1072}
1073
1074fn read_string(
1075    object: &Map<String, Value>,
1076    keys: &[&str],
1077    path: &str,
1078) -> Result<Option<String>, BibleDataFormatError> {
1079    for key in keys {
1080        let Some(value) = object.get(*key) else {
1081            continue;
1082        };
1083        if value.is_null() {
1084            continue;
1085        }
1086        let Some(value) = value.as_str() else {
1087            return Err(data_error(
1088                BibleDataFormatErrorCode::InvalidType,
1089                &json_path(path, key),
1090                "expected a string",
1091                value,
1092            ));
1093        };
1094        if value.trim().is_empty() {
1095            return Err(data_error(
1096                BibleDataFormatErrorCode::InvalidValue,
1097                &json_path(path, key),
1098                "string value cannot be blank",
1099                &Value::String(value.to_string()),
1100            ));
1101        }
1102        return Ok(Some(value.trim().to_string()));
1103    }
1104    Ok(None)
1105}
1106
1107fn read_identifier(
1108    object: &Map<String, Value>,
1109    keys: &[&str],
1110    path: &str,
1111) -> Result<Option<String>, BibleDataFormatError> {
1112    for key in keys {
1113        let Some(value) = object.get(*key) else {
1114            continue;
1115        };
1116        if value.is_null() {
1117            continue;
1118        }
1119        let Some(value) = value.as_str() else {
1120            return Err(data_error(
1121                BibleDataFormatErrorCode::InvalidType,
1122                &json_path(path, key),
1123                "expected an identifier string",
1124                value,
1125            ));
1126        };
1127        if value.trim().is_empty() || value.trim() != value {
1128            return Err(data_error(
1129                BibleDataFormatErrorCode::InvalidValue,
1130                &json_path(path, key),
1131                "identifiers must be non-blank and have no surrounding whitespace",
1132                &Value::String(value.to_string()),
1133            ));
1134        }
1135        return Ok(Some(value.to_string()));
1136    }
1137    Ok(None)
1138}
1139
1140fn read_i32(
1141    object: &Map<String, Value>,
1142    keys: &[&str],
1143    path: &str,
1144) -> Result<Option<i32>, BibleDataFormatError> {
1145    for key in keys {
1146        let Some(value) = object.get(*key) else {
1147            continue;
1148        };
1149        if value.is_null() {
1150            continue;
1151        }
1152        let result = match value {
1153            Value::Number(number) if number.is_i64() => {
1154                number.as_i64().and_then(|value| i32::try_from(value).ok())
1155            }
1156            Value::Number(number) if number.is_u64() => {
1157                number.as_u64().and_then(|value| i32::try_from(value).ok())
1158            }
1159            Value::Number(_) => {
1160                return Err(data_error(
1161                    BibleDataFormatErrorCode::InvalidType,
1162                    &json_path(path, key),
1163                    "expected an integer",
1164                    value,
1165                ));
1166            }
1167            Value::String(value) => value.trim().parse::<i32>().ok(),
1168            _ => {
1169                return Err(data_error(
1170                    BibleDataFormatErrorCode::InvalidType,
1171                    &json_path(path, key),
1172                    "expected an integer",
1173                    value,
1174                ));
1175            }
1176        };
1177        return result.map(Some).ok_or_else(|| {
1178            data_error(
1179                BibleDataFormatErrorCode::InvalidValue,
1180                &json_path(path, key),
1181                "expected an integer value",
1182                value,
1183            )
1184        });
1185    }
1186    Ok(None)
1187}
1188
1189fn read_direction(
1190    object: &Map<String, Value>,
1191    path: &str,
1192) -> Result<Option<TextDirectionHint>, BibleDataFormatError> {
1193    let keys = ["direction", "textDirection", "text_direction"];
1194    for key in keys {
1195        let Some(value) = object.get(key) else {
1196            continue;
1197        };
1198        if value.is_null() {
1199            continue;
1200        }
1201        let Some(value) = value.as_str() else {
1202            return Err(data_error(
1203                BibleDataFormatErrorCode::InvalidType,
1204                &json_path(path, key),
1205                "expected a string",
1206                value,
1207            ));
1208        };
1209        return TextDirectionHint::parse(value, &json_path(path, key)).map(Some);
1210    }
1211    Ok(None)
1212}
1213
1214fn read_date(
1215    object: &Map<String, Value>,
1216    keys: &[&str],
1217    path: &str,
1218) -> Result<Option<String>, BibleDataFormatError> {
1219    let value = read_string(object, keys, path)?;
1220    if let Some(value) = &value {
1221        let key = keys
1222            .iter()
1223            .find(|key| object.get(**key).is_some_and(|value| !value.is_null()))
1224            .copied()
1225            .unwrap_or(keys[0]);
1226        validate_optional_date(Some(value), &json_path(path, key))?;
1227    }
1228    Ok(value)
1229}
1230
1231fn validate_optional_date(value: Option<&str>, path: &str) -> Result<(), BibleDataFormatError> {
1232    if let Some(value) = value {
1233        if !is_iso_8601(value) {
1234            return Err(BibleDataFormatError::new(
1235                BibleDataFormatErrorCode::InvalidValue,
1236                path,
1237                "expected an ISO-8601 date",
1238            )
1239            .with_value(Value::String(value.to_string())));
1240        }
1241    }
1242    Ok(())
1243}
1244
1245fn is_iso_8601(value: &str) -> bool {
1246    let bytes = value.as_bytes();
1247    if bytes.len() < 10
1248        || bytes[4] != b'-'
1249        || bytes[7] != b'-'
1250        || !bytes[..4].iter().all(u8::is_ascii_digit)
1251        || !bytes[5..7].iter().all(u8::is_ascii_digit)
1252        || !bytes[8..10].iter().all(u8::is_ascii_digit)
1253    {
1254        return false;
1255    }
1256    let year = value[..4].parse::<i32>().ok();
1257    let month = value[5..7].parse::<u8>().ok();
1258    let day = value[8..10].parse::<u8>().ok();
1259    let (Some(year), Some(month), Some(day)) = (year, month, day) else {
1260        return false;
1261    };
1262    let maximum_day = match month {
1263        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1264        4 | 6 | 9 | 11 => 30,
1265        2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
1266        2 => 28,
1267        _ => return false,
1268    };
1269    if day == 0 || day > maximum_day {
1270        return false;
1271    }
1272    if bytes.len() == 10 {
1273        return true;
1274    }
1275    if !matches!(bytes[10], b'T' | b' ') || bytes.len() < 16 {
1276        return false;
1277    }
1278    let time = &value[11..];
1279    let time_bytes = time.as_bytes();
1280    let hour = time.get(..2).and_then(|part| part.parse::<u8>().ok());
1281    let minute = time.get(3..5).and_then(|part| part.parse::<u8>().ok());
1282    if !matches!(
1283        (hour, time_bytes.get(2), minute),
1284        (Some(0..=23), Some(b':'), Some(0..=59))
1285    ) {
1286        return false;
1287    }
1288
1289    let mut remainder = &time[5..];
1290    if let Some(after_colon) = remainder.strip_prefix(':') {
1291        let Some(seconds) = after_colon.get(..2) else {
1292            return false;
1293        };
1294        if !seconds.bytes().all(|byte| byte.is_ascii_digit())
1295            || seconds
1296                .parse::<u8>()
1297                .ok()
1298                .is_none_or(|seconds| seconds > 59)
1299        {
1300            return false;
1301        }
1302        remainder = &after_colon[2..];
1303        if let Some(after_dot) = remainder.strip_prefix('.') {
1304            let digit_count = after_dot
1305                .bytes()
1306                .take_while(|byte| byte.is_ascii_digit())
1307                .count();
1308            if digit_count == 0 {
1309                return false;
1310            }
1311            remainder = &after_dot[digit_count..];
1312        }
1313    }
1314
1315    if remainder.is_empty() || matches!(remainder, "Z" | "z") {
1316        return true;
1317    }
1318    let Some(offset) = remainder
1319        .strip_prefix('+')
1320        .or_else(|| remainder.strip_prefix('-'))
1321    else {
1322        return false;
1323    };
1324    if offset.len() != 5 || offset.as_bytes().get(2) != Some(&b':') {
1325        return false;
1326    }
1327    let offset_hour = offset[..2].parse::<u8>().ok();
1328    let offset_minute = offset[3..].parse::<u8>().ok();
1329    matches!((offset_hour, offset_minute), (Some(0..=23), Some(0..=59)))
1330}
1331
1332fn validate_additional(
1333    additional: &Map<String, Value>,
1334    reserved: &[&str],
1335    path: &str,
1336) -> Result<(), BibleDataFormatError> {
1337    if let Some(key) = reserved.iter().find(|key| additional.contains_key(**key)) {
1338        return Err(BibleDataFormatError::new(
1339            BibleDataFormatErrorCode::ReservedField,
1340            json_path(path, key),
1341            "recognized metadata fields cannot be stored as extensions",
1342        )
1343        .with_value(additional[*key].clone()));
1344    }
1345    Ok(())
1346}
1347
1348fn additional_fields(object: &Map<String, Value>, recognized: &[&str]) -> Map<String, Value> {
1349    object
1350        .iter()
1351        .filter(|(key, _)| !recognized.contains(&key.as_str()))
1352        .map(|(key, value)| (key.clone(), value.clone()))
1353        .collect()
1354}
1355
1356fn looks_like_source(object: &Map<String, Value>) -> bool {
1357    object
1358        .keys()
1359        .any(|key| SOURCE_RECOGNIZED_KEYS.contains(&key.as_str()))
1360}
1361
1362fn language_code_for_name(language_name: &str) -> Option<String> {
1363    if let Ok(language) = Language::from_str(language_name) {
1364        if !language.is_auto() {
1365            return Some(language.code().to_string());
1366        }
1367    }
1368    language_name
1369        .trim()
1370        .eq_ignore_ascii_case("italian")
1371        .then(|| "it".to_string())
1372}
1373
1374fn label_from_segment(value: &str) -> String {
1375    value
1376        .split(['_', '-'])
1377        .filter(|word| !word.is_empty())
1378        .map(|word| {
1379            let mut characters = word.chars();
1380            characters.next().map_or_else(String::new, |first| {
1381                first.to_uppercase().collect::<String>() + &characters.as_str().to_lowercase()
1382            })
1383        })
1384        .collect::<Vec<_>>()
1385        .join(" ")
1386}
1387
1388fn sanitize_id(value: &str) -> String {
1389    let mut result = String::new();
1390    let mut separator = false;
1391    for character in value.to_ascii_lowercase().chars() {
1392        if character.is_ascii_alphanumeric() {
1393            if separator && !result.is_empty() {
1394                result.push('_');
1395            }
1396            result.push(character);
1397            separator = false;
1398        } else {
1399            separator = true;
1400        }
1401    }
1402    if result.is_empty() {
1403        "bible_source".to_string()
1404    } else {
1405        result
1406    }
1407}
1408
1409fn direction_for_language(code: &str) -> TextDirectionHint {
1410    match code.trim().to_ascii_lowercase().as_str() {
1411        "ar" | "fa" | "he" | "ur" => TextDirectionHint::Rtl,
1412        _ => TextDirectionHint::Auto,
1413    }
1414}
1415
1416fn data_error(
1417    code: BibleDataFormatErrorCode,
1418    path: &str,
1419    message: &str,
1420    value: &Value,
1421) -> BibleDataFormatError {
1422    BibleDataFormatError::new(code, path, message).with_value(value.clone())
1423}
1424
1425fn json_path(base: &str, key: &str) -> String {
1426    if is_simple_key(key) {
1427        format!("{base}.{key}")
1428    } else {
1429        format!(
1430            "{base}[{}]",
1431            serde_json::to_string(key).expect("a string always serializes")
1432        )
1433    }
1434}
1435
1436fn is_simple_key(key: &str) -> bool {
1437    let mut characters = key.chars();
1438    match characters.next() {
1439        Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
1440        _ => return false,
1441    }
1442    characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
1443}
1444
1445const SOURCE_PATH_KEYS: &[&str] = &["assetPath", "asset_path", "path", "file", "url"];
1446
1447const SOURCE_RECOGNIZED_KEYS: &[&str] = &[
1448    "assetPath",
1449    "asset_path",
1450    "path",
1451    "file",
1452    "url",
1453    "id",
1454    "key",
1455    "languageName",
1456    "language_name",
1457    "language",
1458    "languageCode",
1459    "language_code",
1460    "lang",
1461    "translationName",
1462    "translation_name",
1463    "name",
1464    "title",
1465    "version",
1466    "abbreviation",
1467    "abbr",
1468    "shortName",
1469    "short_name",
1470    "description",
1471    "summary",
1472    "year",
1473    "direction",
1474    "textDirection",
1475    "text_direction",
1476    "sourceName",
1477    "source_name",
1478    "source",
1479    "copyright",
1480    "license",
1481    "canon",
1482    "versionDate",
1483    "version_date",
1484    "date",
1485];
1486
1487const METADATA_RECOGNIZED_KEYS: &[&str] = &[
1488    "assetPath",
1489    "asset_path",
1490    "path",
1491    "file",
1492    "url",
1493    "id",
1494    "key",
1495    "editionId",
1496    "edition_id",
1497    "languageName",
1498    "language_name",
1499    "language",
1500    "languageCode",
1501    "language_code",
1502    "lang",
1503    "translationName",
1504    "translation_name",
1505    "name",
1506    "title",
1507    "version",
1508    "abbreviation",
1509    "abbr",
1510    "shortName",
1511    "short_name",
1512    "description",
1513    "summary",
1514    "year",
1515    "direction",
1516    "textDirection",
1517    "text_direction",
1518    "sourceName",
1519    "source_name",
1520    "source",
1521    "copyright",
1522    "license",
1523    "canon",
1524    "versionDate",
1525    "version_date",
1526    "date",
1527];
1528
1529const ROOT_METADATA_RECOGNIZED_KEYS: &[&str] = &[
1530    "assetPath",
1531    "asset_path",
1532    "path",
1533    "file",
1534    "url",
1535    "id",
1536    "key",
1537    "editionId",
1538    "edition_id",
1539    "languageName",
1540    "language_name",
1541    "language",
1542    "languageCode",
1543    "language_code",
1544    "lang",
1545    "translationName",
1546    "translation_name",
1547    "name",
1548    "title",
1549    "version",
1550    "abbreviation",
1551    "abbr",
1552    "shortName",
1553    "short_name",
1554    "description",
1555    "summary",
1556    "year",
1557    "direction",
1558    "textDirection",
1559    "text_direction",
1560    "sourceName",
1561    "source_name",
1562    "source",
1563    "copyright",
1564    "license",
1565    "canon",
1566    "versionDate",
1567    "version_date",
1568    "date",
1569    "books",
1570    "metadata",
1571];
1572
1573impl fmt::Display for BibleSource {
1574    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1575        write!(
1576            formatter,
1577            "{} ({})",
1578            self.translation_name, self.abbreviation
1579        )
1580    }
1581}