1use logoxide::{
8 LogoxideError, Result,
9 catalog::{Catalog, CatalogEntry, Download},
10 language,
11 license::{Derivatives, Rights},
12 manifest::{Direction, PackageKind},
13 storage::{MemoryStorage, Storage},
14};
15
16use crate::{Made, external, finish, import_id, package_id};
17
18pub fn url(id: &str) -> String {
20 format!("https://v1.fetch.bible/bibles/{id}/")
21}
22
23pub fn book_url(id: &str, code: &str) -> String {
25 format!(
26 "https://v1.fetch.bible/bibles/{id}/usx/{}.usx",
27 code.to_ascii_lowercase()
28 )
29}
30
31pub fn catalog(text: &str) -> Result<Catalog> {
34 use serde_json::Value;
35 let bad = |message: &str| LogoxideError::Catalog(format!("manifest.json: {message}"));
36 let json: Value = serde_json::from_str(text).map_err(|e| bad(&e.to_string()))?;
37 let bibles = json
38 .get("bibles")
39 .and_then(Value::as_object)
40 .ok_or_else(|| bad("no bibles"))?;
41 let ordered: Vec<String> = (json.get("books_ordered").and_then(Value::as_array))
42 .map(|books| {
43 books
44 .iter()
45 .filter_map(|b| Some(b.as_str()?.to_string()))
46 .collect()
47 })
48 .unwrap_or_default();
49 let (ot, nt) = ordered.split_at(ordered.len().min(39));
51 let languages = json.get("languages");
52 let text_of = |v: Option<&Value>| {
53 v.and_then(Value::as_str)
54 .map(str::trim)
55 .filter(|s| !s.is_empty())
56 .map(String::from)
57 };
58 let books_of = |v: Option<&Value>, all: &[String]| match v {
59 Some(Value::Bool(true)) => all.to_vec(),
60 Some(Value::Array(list)) => list
61 .iter()
62 .filter_map(|b| Some(b.as_str()?.to_ascii_lowercase()))
63 .collect(),
64 _ => vec![],
65 };
66 let mut entries = vec![];
67 for (id, bible) in bibles {
68 let mut books = books_of(bible.get("books_ot"), ot);
69 books.extend(books_of(bible.get("books_nt"), nt));
70 if books.is_empty() {
71 continue;
72 }
73 let names = bible.get("name");
74 let name_of = |key: &str| text_of(names.and_then(|n| n.get(key)));
75 let code = id.split('_').next().unwrap_or(id).to_string();
76 let language_entry = languages.and_then(|l| l.get(&code));
77 let (tag, raw) = language::normalize_language(&code);
78 let copyright = bible.get("copyright");
79 let (license, rights) = (copyright.and_then(|c| c.get("licenses")))
80 .and_then(Value::as_array)
81 .and_then(|l| l.first())
82 .map(|l| license_of(l.get("license"), text_of(l.get("url"))))
83 .map_or((None, Default::default()), |(l, r)| (Some(l), r));
84 let abbreviation = name_of("english_abbrev")
85 .or_else(|| name_of("local_abbrev"))
86 .unwrap_or_else(|| id.to_uppercase());
87 let english = name_of("english");
88 let local = name_of("local");
89 let downloads: Vec<Download> = (books.iter())
92 .map(|code| Download {
93 name: code.clone(),
94 urls: vec![book_url(id, code)],
95 })
96 .collect();
97 let source = match external("fetch", id, false, downloads) {
98 logoxide::catalog::CatalogSource::External {
99 import,
100 tracks_versions,
101 downloads,
102 ..
103 } => logoxide::catalog::CatalogSource::External {
104 import,
105 upstream: Some(url(id)),
106 tracks_versions,
107 downloads,
108 },
109 other => other,
110 };
111 entries.push(CatalogEntry {
112 id: format!("fetch-{}", package_id(id)),
114 name: english
115 .clone()
116 .or_else(|| local.clone())
117 .unwrap_or_else(|| id.clone()),
118 aliases: local
119 .filter(|l| Some(l) != english.as_ref())
120 .into_iter()
121 .collect(),
122 abbreviation: Some(abbreviation),
123 language_name: text_of(language_entry.and_then(|l| l.get("english"))).or(raw),
124 script: language::script_of_tag(&tag),
125 language: tag,
126 description: None,
127 license,
128 rights,
129 copyright: text_of(copyright.and_then(|c| c.get("attribution"))),
130 version: (bible.get("year").and_then(Value::as_u64)).map(|y| y.to_string()),
131 kind: PackageKind::Bible,
132 direction: match bible.get("direction").and_then(Value::as_str) {
133 Some("rtl") => Direction::Rtl,
134 _ => Direction::Ltr,
135 },
136 source,
137 path: None,
138 releases: vec![],
139 keywords: vec![],
140 categories: vec![],
141 about_books: vec![],
142 work: None,
143 edition: None,
144 edition_year: None,
145 keys: None,
146 });
147 }
148 entries.sort_by(|a, b| a.id.cmp(&b.id));
149 logoxide::catalog::known_works(&mut entries);
150 Ok(Catalog {
151 name: String::from("fetch.bible"),
152 entries,
153 works: vec![],
154 warnings: vec![],
155 })
156}
157
158fn license_of(license: Option<&serde_json::Value>, url: Option<String>) -> (String, Rights) {
165 let license = match license {
166 Some(serde_json::Value::Object(terms)) => {
167 let forbids = |key: &str| terms.get(key).and_then(serde_json::Value::as_bool);
168 let derivatives = match terms.get("forbid_derivatives") {
169 Some(serde_json::Value::Bool(true)) => Some(Derivatives::None),
170 Some(serde_json::Value::Bool(false)) => Some(Derivatives::Allowed),
171 Some(serde_json::Value::String(s)) if s == "same-license" => {
172 Some(Derivatives::ShareAlike)
173 }
174 _ => None,
175 };
176 let rights = Rights {
177 redistributable: Some(true),
179 derivatives,
180 commercial: forbids("forbid_commercial").map(|f| !f),
181 attribution: forbids("forbid_attributionless"),
182 };
183 return (String::from("LicenseRef-fetch.bible"), rights);
184 }
185 Some(license) => license.as_str(),
186 None => None,
187 };
188 let license = match license {
189 Some("public") => String::from("LicenseRef-PublicDomain"),
190 Some(cc) if cc.starts_with("cc-") => {
191 let version = (url.as_deref())
192 .and_then(|u| u.trim_end_matches('/').rsplit('/').next())
193 .filter(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
194 .unwrap_or("4.0")
195 .to_string();
196 format!("{}-{version}", cc.to_uppercase())
197 }
198 _ => String::from("LicenseRef-fetch.bible"),
199 };
200 (license, Rights::default())
201}
202
203pub fn import(entry: &CatalogEntry, books: &[(String, Vec<u8>)], dest: &dyn Storage) -> Result<()> {
209 let id = import_id(entry, "fetch", "fetch.bible")?;
210 let staged = MemoryStorage::new();
211 for (code, data) in books {
212 if !data.starts_with(b"<?xml")
213 && !data.starts_with(b"<usx")
214 && !data.starts_with(b"\xef\xbb\xbf")
215 {
216 return Err(LogoxideError::Import(format!(
217 "{id}: {code} isn't USX (was it downloaded from {}?)",
218 book_url(id, code)
219 )));
220 }
221 staged.write(&format!("usx/{}.usx", code.to_ascii_uppercase()), data)?;
222 }
223 let mut license_file = None;
224 let notice = [entry.copyright.as_deref(), entry.license.as_deref()];
225 if notice.iter().any(Option::is_some) {
226 let text: Vec<&str> = notice.into_iter().flatten().collect();
227 staged.write("COPYRIGHT.txt", format!("{}\n", text.join("\n")).as_bytes())?;
228 license_file = Some(String::from("COPYRIGHT.txt"));
229 }
230 let made = Made {
231 format: "usx",
232 license_file,
233 publisher: "fetch.bible",
234 publisher_name: None,
235 homepage: String::from("https://fetch.bible/"),
236 versification: None,
237 keys: None,
238 note: None,
239 };
240 finish(entry, made, staged, dest)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use logoxide::{
247 catalog::FETCH_BIBLE_CATALOG,
248 package::{Features, Package},
249 };
250
251 #[test]
252 fn reads_fetch_bibles_manifest() {
253 let json = r#"{"bibles": {
254 "eng_bsb": {"name": {"local": "Berean Standard Bible", "local_abbrev": "BSB", "english": "Berean Standard Bible", "english_abbrev": "BSB"},
255 "year": 2024, "direction": "ltr", "copyright": {"licenses": [{"license": "public", "url": ""}], "attribution": "BSB Publishing, LLC"},
256 "books_ot": true, "books_nt": true},
257 "spa_x": {"name": {"local": "Biblia", "local_abbrev": "", "english": "A Bible", "english_abbrev": "AB"},
258 "year": 2020, "direction": "ltr", "copyright": {"licenses": [{"license": "cc-by-sa", "url": "https://creativecommons.org/licenses/by-sa/3.0/"}], "attribution": "Someone"},
259 "books_ot": [], "books_nt": ["jhn", "rom"]},
260 "heb_y": {"name": {"local": "", "local_abbrev": "", "english": "Restricted", "english_abbrev": "R"},
261 "year": 2020, "direction": "rtl", "copyright": {"licenses": [{"license": {"forbid_commercial": true}, "url": ""}], "attribution": ""},
262 "books_ot": ["gen"], "books_nt": false},
263 "none_z": {"name": {"english": "Nothing"}, "books_ot": [], "books_nt": false}
264 },
265 "languages": {"eng": {"english": "English"}, "spa": {"english": "Spanish"}},
266 "books_ordered": ["gen","exo","lev","num","deu","jos","jdg","rut","1sa","2sa","1ki","2ki","1ch","2ch","ezr","neh","est","job","psa","pro","ecc","sng","isa","jer","lam","ezk","dan","hos","jol","amo","oba","jon","mic","nam","hab","zep","hag","zec","mal","mat","mrk","luk","jhn","act","rom","1co","2co","gal","eph","php","col","1th","2th","1ti","2ti","tit","phm","heb","jas","1pe","2pe","1jn","2jn","3jn","jud","rev"]}"#;
267 let catalog = crate::catalog::parse_text(FETCH_BIBLE_CATALOG, json).unwrap();
268 assert_eq!(
269 catalog.entries.len(),
270 3,
271 "a translation without books is left out"
272 );
273 let bsb = &catalog.entries[0];
274 assert_eq!(
275 (bsb.id.as_str(), bsb.short_name(), bsb.language.as_str()),
276 ("fetch-eng_bsb", "BSB", "en")
277 );
278 assert_eq!(bsb.license.as_deref(), Some("LicenseRef-PublicDomain"));
279 assert_eq!(bsb.source.import_name(), Some("fetch:eng_bsb"));
280 assert_eq!(
281 bsb.source.upstream_url(),
282 Some("https://v1.fetch.bible/bibles/eng_bsb/")
283 );
284 assert!(!bsb.source.versions_track_updates());
285 let downloads = bsb.source.downloads();
287 assert_eq!((downloads.len(), downloads[42].name.as_str()), (66, "jhn"));
288 assert_eq!(
289 downloads[42].urls,
290 ["https://v1.fetch.bible/bibles/eng_bsb/usx/jhn.usx"]
291 );
292 assert_eq!(bsb.work.as_deref(), Some("bsb"));
294 let x = catalog
295 .entries
296 .iter()
297 .find(|e| e.id == "fetch-spa_x")
298 .unwrap();
299 assert_eq!(x.license.as_deref(), Some("CC-BY-SA-3.0"));
300 assert_eq!(x.aliases, ["Biblia"]);
301 assert_eq!(x.language_name.as_deref(), Some("Spanish"));
302 let y = catalog
303 .entries
304 .iter()
305 .find(|e| e.id == "fetch-heb_y")
306 .unwrap();
307 assert_eq!(
308 (y.license.as_deref(), y.direction),
309 (Some("LicenseRef-fetch.bible"), Direction::Rtl)
310 );
311 assert_eq!(
313 y.rights,
314 Rights {
315 redistributable: Some(true),
316 derivatives: None,
317 commercial: Some(false),
318 attribution: None
319 }
320 );
321 assert_eq!(x.text_rights().derivatives, Some(Derivatives::ShareAlike));
322 }
323
324 #[test]
325 fn imports_fetch_bibles_books() {
326 let json = r#"{"bibles": {"eng_asv": {"name": {"english": "American Standard Version", "english_abbrev": "ASV"},
327 "year": 1901, "copyright": {"licenses": [{"license": "public", "url": ""}], "attribution": "Someone"},
328 "books_ot": [], "books_nt": ["jhn"]}},
329 "languages": {"eng": {"english": "English"}},
330 "books_ordered": ["gen","exo","lev","num","deu","jos","jdg","rut","1sa","2sa","1ki","2ki","1ch","2ch","ezr","neh","est","job","psa","pro","ecc","sng","isa","jer","lam","ezk","dan","hos","jol","amo","oba","jon","mic","nam","hab","zep","hag","zec","mal","mat","mrk","luk","jhn"]}"#;
331 let catalog = catalog(json).unwrap();
332 let entry = &catalog.entries[0];
333 let usx = r#"<?xml version="1.0" encoding="utf-8"?><usx version="3.0"><book code="JHN" style="id"/><chapter number="3" style="c" sid="JHN 3"/><para style="p"><verse number="16" style="v" sid="JHN 3:16"/>For God so loved the world.<verse eid="JHN 3:16"/></para></usx>"#;
334 let dest = MemoryStorage::new();
335 crate::tests::build_twice(entry, &[("jhn".into(), usx.as_bytes().to_vec())], &dest);
336 assert_eq!(
337 dest.walk("").unwrap(),
338 ["COPYRIGHT.txt", "logoxide.toml", "usx/JHN.usx"]
339 );
340 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
341 assert_eq!(package.books().len(), 1);
342 let info = package.info();
343 assert_eq!(info.work.as_deref(), Some("asv"));
344 let upstream = info.upstream.as_ref().unwrap();
345 assert_eq!(
346 (upstream.import.as_deref(), upstream.url.as_deref()),
347 (
348 Some("fetch:eng_asv"),
349 Some("https://v1.fetch.bible/bibles/eng_asv/")
350 )
351 );
352 assert!(!upstream.converted);
353 let page = [("jhn".to_string(), b"<!DOCTYPE html>".to_vec())];
355 let error = import(entry, &page, &MemoryStorage::new()).unwrap_err();
356 assert!(error.to_string().contains("isn't USX"), "{error}");
357 }
358}