1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum TextDirectionHint {
25 #[default]
27 Auto,
28 Ltr,
30 Rtl,
32}
33
34impl TextDirectionHint {
35 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct BibleSource {
74 pub id: String,
76 pub asset_path: String,
78 pub language_name: String,
80 pub language_code: String,
82 pub translation_name: String,
84 pub abbreviation: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub description: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub year: Option<i32>,
92 pub direction: TextDirectionHint,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub source_name: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub copyright: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub license: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub canon: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub version_date: Option<String>,
109 #[serde(flatten)]
111 pub additional: Map<String, Value>,
112}
113
114impl BibleSource {
115 #[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 pub fn from_value(value: &Value) -> Result<Self, BibleDataFormatError> {
173 Self::from_value_at(value, "$")
174 }
175
176 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
334#[serde(rename_all = "camelCase")]
335pub struct BibleMetadata {
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub source: Option<BibleSource>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub id: Option<String>,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub description: Option<String>,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub language_name: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub language_code: Option<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub translation_name: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub abbreviation: Option<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub year: Option<i32>,
360 pub direction: TextDirectionHint,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub source_name: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub copyright: Option<String>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub license: Option<String>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub canon: Option<String>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub version_date: Option<String>,
377 #[serde(flatten)]
379 pub additional: Map<String, Value>,
380}
381
382impl BibleMetadata {
383 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 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 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 #[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
577pub 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#[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 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 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 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 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 #[must_use]
760 pub fn sources(&self) -> &[BibleSource] {
761 &self.sources
762 }
763
764 #[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 #[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 #[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}