1use logoxide::{
14 LogoxideError, Result,
15 catalog::{Catalog, CatalogEntry, Download},
16 language,
17 manifest::{Direction, PackageKind},
18 storage::{MemoryStorage, Storage},
19};
20
21use crate::{Made, external, finish, import_id, package_id};
22
23pub fn url(id: &str) -> String {
26 match id.contains('/') {
27 true => format!("https://ccel.org/ccel/{id}.xml"),
28 false => format!("https://ccel.org/ccel/bible/{id}.xml"),
29 }
30}
31
32fn homepage(id: &str) -> String {
34 match id.contains('/') {
35 true => format!("https://ccel.org/ccel/{id}"),
36 false => format!("https://ccel.org/ccel/bible/{id}"),
37 }
38}
39
40fn is_path_name(name: &str) -> bool {
43 !name.is_empty()
44 && (name.bytes())
45 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-.".contains(&b))
46 && !name.starts_with('.')
47}
48
49fn entry(id: &str, title: String, kind: PackageKind, description: Option<String>) -> CatalogEntry {
57 let work = id.rsplit('/').next().unwrap_or(id);
58 let downloads = vec![Download {
59 name: String::from("thml"),
60 urls: vec![url(id)],
61 }];
62 CatalogEntry {
63 id: format!("ccel-{}", package_id(&id.replace('/', "-"))),
64 name: title,
65 abbreviation: Some(work.to_uppercase()),
66 aliases: vec![],
67 language: String::from("und"),
68 language_name: None,
69 script: None,
70 description,
71 license: None,
72 rights: Default::default(),
73 copyright: None,
74 version: None,
75 kind,
76 direction: Direction::Ltr,
77 source: external("ccel", id, false, downloads),
78 path: None,
79 releases: vec![],
80 keywords: vec![],
81 categories: vec![],
82 about_books: vec![],
83 work: None,
84 edition: None,
85 edition_year: None,
86 keys: None,
87 }
88}
89
90pub fn catalog(text: &str) -> Result<Catalog> {
98 let mut entries: Vec<CatalogEntry> = vec![];
99 for heading in text.split("<h2").skip(1) {
100 let Some((heading, _)) = heading.split_once("</h2>") else {
101 continue;
102 };
103 let Some(href) = (heading.split_once("href=\""))
104 .and_then(|(_, rest)| rest.split_once('"'))
105 .map(|(href, _)| href)
106 else {
107 continue;
108 };
109 let mut parts = href.trim_end_matches('/').rsplit('/');
111 let (Some(_), Some(work), Some("bible")) = (parts.next(), parts.next(), parts.next())
112 else {
113 continue;
114 };
115 let title = link_text(heading).unwrap_or_else(|| work.to_string());
116 if (entries.iter()).any(|e| e.source.import_id() == Some(work)) {
117 continue;
118 }
119 entries.push(entry(work, title, PackageKind::Bible, None));
120 }
121 if entries.is_empty() {
122 return Err(LogoxideError::Catalog(String::from(
123 "CCEL's page lists no Bibles (has it changed?)",
124 )));
125 }
126 entries.sort_by(|a, b| a.id.cmp(&b.id));
127 logoxide::catalog::known_works(&mut entries);
128 Ok(Catalog {
129 name: String::from("CCEL"),
130 entries,
131 works: vec![],
132 warnings: vec![],
133 })
134}
135
136fn link_text(html: &str) -> Option<String> {
139 let (_, rest) = html.split_once("<a")?;
140 let (_, rest) = rest.split_once('>')?;
141 let (title, _) = rest.split_once("</a>")?;
142 let title = decode_html_entities(title);
143 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
144 (!title.is_empty()).then_some(title)
145}
146
147pub fn genre_kind(genre: &str) -> PackageKind {
150 match genre.to_ascii_uppercase().as_str() {
151 "GEN-COM" => PackageKind::Commentary,
152 "GEN-BIB" => PackageKind::Bible,
153 _ => PackageKind::Book,
154 }
155}
156
157pub fn genre_of(location: &str) -> Option<&str> {
159 let path = location.split(['?', '#']).next().unwrap_or(location);
160 let lower = path.to_ascii_lowercase();
161 let at = lower.find("ccel.org/index/genre/")? + "ccel.org/index/genre/".len();
162 let genre = path.get(at..)?.trim_end_matches('/');
163 (!genre.is_empty() && !genre.contains('/')).then_some(genre)
164}
165
166pub fn genre_catalog(text: &str, genre: &str) -> Result<Catalog> {
175 let kind = genre_kind(genre);
176 let mut entries: Vec<CatalogEntry> = vec![];
177 for item in text.split("<p class=\"List2\">").skip(1) {
178 let item = item.split_once("</p>").map_or(item, |(item, _)| item);
179 let Some(href) = (item.split_once("href=\""))
180 .and_then(|(_, rest)| rest.split_once('"'))
181 .map(|(href, _)| href)
182 else {
183 continue;
184 };
185 let Some(path) = (href.strip_prefix("https://ccel.org").or(Some(href)))
186 .and_then(|h| h.strip_prefix("/ccel/"))
187 .and_then(|h| h.strip_suffix(".html"))
188 else {
189 continue;
190 };
191 let Some((author, work)) = path.split_once('/') else {
192 continue;
193 };
194 if !is_path_name(author) || !is_path_name(work) {
195 continue;
196 }
197 let id = match author {
198 "bible" => work.to_string(),
199 _ => format!("{author}/{work}"),
200 };
201 if (entries.iter()).any(|e| e.source.import_id() == Some(id.as_str())) {
202 continue;
203 }
204 let title = link_text(item).unwrap_or_else(|| work.to_string());
205 let author =
206 (item.split_once("class=\"authorName\"")).and_then(|(_, rest)| link_text(rest));
207 entries.push(entry(&id, title, kind, author));
208 }
209 if entries.is_empty() {
210 return Err(LogoxideError::Catalog(format!(
211 "CCEL's genre {genre} lists no works (has its page changed?)"
212 )));
213 }
214 entries.sort_by(|a, b| a.id.cmp(&b.id));
215 logoxide::catalog::known_works(&mut entries);
216 Ok(Catalog {
217 name: format!("CCEL {genre}"),
218 entries,
219 works: vec![],
220 warnings: vec![],
221 })
222}
223
224pub fn named_catalog(import: &str) -> Option<Catalog> {
230 let id = import.strip_prefix("ccel:")?;
231 let (author, work) = id.split_once('/')?;
232 if !is_path_name(author) || !is_path_name(work) {
233 return None;
234 }
235 let entry = match author {
236 "bible" => entry(work, work.to_string(), PackageKind::Bible, None),
237 _ => entry(id, work.to_string(), PackageKind::Book, None),
238 };
239 Some(Catalog {
240 name: String::from("CCEL"),
241 entries: vec![entry],
242 works: vec![],
243 warnings: vec![],
244 })
245}
246
247fn decode_html_entities(text: &str) -> String {
250 let mut out = String::with_capacity(text.len());
251 let mut rest = text;
252 while let Some(amp) = rest.find('&') {
253 out.push_str(&rest[..amp]);
254 let after = &rest[amp..];
255 let Some(semi) = after.find(';').filter(|&i| i <= 10) else {
256 out.push('&');
257 rest = &after[1..];
258 continue;
259 };
260 let name = &after[1..semi];
261 let decoded = match name {
262 "amp" => Some('&'),
263 "lt" => Some('<'),
264 "gt" => Some('>'),
265 "quot" => Some('"'),
266 "apos" => Some('\''),
267 "nbsp" => Some('\u{a0}'),
268 _ => (name.strip_prefix("#x").or_else(|| name.strip_prefix("#X")))
269 .and_then(|hex| u32::from_str_radix(hex, 16).ok())
270 .or_else(|| name.strip_prefix('#').and_then(|dec| dec.parse().ok()))
271 .and_then(char::from_u32),
272 };
273 match decoded {
274 Some(c) => out.push(c),
275 None => out.push_str(&after[..=semi]),
276 }
277 rest = &after[semi + 1..];
278 }
279 out.push_str(rest);
280 out
281}
282
283#[derive(Clone, Debug, Default, PartialEq, Eq)]
285pub struct ThmlHead {
286 pub title: Option<String>,
288 pub language: Option<String>,
290 pub rights: Option<String>,
292 pub version: Option<String>,
294 pub date: Option<String>,
296 pub description: Option<String>,
298 pub dc_type: Option<String>,
300}
301
302impl ThmlHead {
303 pub fn kind(&self) -> Option<PackageKind> {
306 let dc_type = self.dc_type.as_deref()?.to_ascii_lowercase();
307 Some(match dc_type.as_str() {
308 "text.bible" => PackageKind::Bible,
309 "text.commentary" => PackageKind::Commentary,
310 _ => PackageKind::Book,
311 })
312 }
313}
314
315pub fn thml_head(xml: &str) -> ThmlHead {
317 let end = xml.find("</ThML.head>").unwrap_or(xml.len().min(64 * 1024));
318 let head = xml.get(..end).unwrap_or_default();
319 let found = logoxide_text::metadata::elements(head);
320 let text = |name: &str, plain: bool| {
321 (found.iter())
322 .filter(|e| e.name == name && (!plain || e.attr("sub").is_none()))
323 .map(|e| e.text.split_whitespace().collect::<Vec<_>>().join(" "))
324 .find(|t| !t.is_empty())
325 };
326 ThmlHead {
327 title: text("DC.Title", true),
328 language: text("DC.Language", false),
329 rights: text("DC.Rights", false),
330 version: text("version", false),
331 date: text("DC.Date", false),
332 description: inner_text(head, "description"),
333 dc_type: text("DC.Type", false),
334 }
335}
336
337fn inner_text(xml: &str, name: &str) -> Option<String> {
340 let (_, rest) = xml.split_once(&format!("<{name}>"))?;
341 let (inside, _) = rest.split_once(&format!("</{name}>"))?;
342 let mut text = String::new();
343 let mut in_tag = false;
344 for c in inside.chars() {
345 match c {
346 '<' => in_tag = true,
347 '>' => in_tag = false,
348 _ if !in_tag => text.push(c),
349 _ => {}
350 }
351 }
352 let text = logoxide_text::markup::decode_entities(&text);
353 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
354 (!text.is_empty()).then_some(text)
355}
356
357pub fn import(entry: &CatalogEntry, xml: &[u8], dest: &dyn Storage) -> Result<()> {
368 let id = import_id(entry, "ccel", "CCEL")?;
369 let text = String::from_utf8_lossy(xml);
370 let start = text.get(..text.len().min(4096)).unwrap_or_default();
371 if !start.contains("<ThML") {
372 return Err(LogoxideError::Import(format!(
373 "{id}: {} isn't ThML (CCEL may need a login for it)",
374 url(id)
375 )));
376 }
377 let head = thml_head(&text);
378 let mut entry = entry.clone();
379 if let Some(kind) = head.kind() {
380 entry.kind = kind;
381 }
382 if let Some(title) = head.title
383 && title != entry.name
384 {
385 entry
386 .aliases
387 .push(std::mem::replace(&mut entry.name, title));
388 }
389 if let Some(language) = head.language {
390 let (tag, raw) = language::normalize_language(&language);
391 entry.language = tag;
392 entry.language_name = raw.or(entry.language_name);
393 }
394 if head.description.is_some() {
395 entry.description = head.description;
396 }
397 let body = (text.find("</ThML.head>")).map_or(&*text, |at| &text[at..]);
400 let script = logoxide_text::script::dominant_script(body);
401 entry.script = script.map(String::from);
402 entry.direction = language::script_direction(script);
403 let public = (head.rights.as_deref()).is_some_and(crate::license::says_public_domain);
404 entry.license = Some(String::from(match public {
405 true => "LicenseRef-PublicDomain",
406 false => "LicenseRef-CCEL",
407 }));
408 entry.version = head.version.or(head.date).or(entry.version);
409 let staged = MemoryStorage::new();
410 let work = id.rsplit('/').next().unwrap_or(id);
411 staged.write(&format!("thml/{work}.xml"), xml)?;
412 let rights = head
413 .rights
414 .as_deref()
415 .unwrap_or("No rights statement given");
416 let notice = format!(
417 "{rights}\n\nFrom the Christian Classics Ethereal Library: {}\n",
418 url(id)
419 );
420 staged.write("COPYRIGHT.txt", notice.as_bytes())?;
421 let made = Made {
422 format: "thml",
423 license_file: Some(String::from("COPYRIGHT.txt")),
424 publisher: "CCEL",
425 publisher_name: None,
426 homepage: homepage(id),
427 versification: None,
428 keys: None,
429 note: None,
430 };
431 finish(&entry, made, staged, dest)
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use logoxide::{
438 catalog::CCEL_CATALOG,
439 package::{Features, Package},
440 };
441
442 #[test]
443 fn imports_a_ccel_bible() {
444 let page = r#"<div id="worksBy_44" class="worksEntry">
445 <h2>
446 <a href="https://ccel.org/ccel/bible/heot/heot">
447 Holy Bible: Hebrew & Old Testament</a>
448 </h2></div>
449 <h2><a href="https://ccel.org/ccel/calvin/institutes">Not a Bible</a></h2>"#;
450 let catalog = crate::catalog::parse_text(CCEL_CATALOG, page).unwrap();
451 assert_eq!(catalog.entries.len(), 1);
452 let entry = catalog.find_import("ccel:heot").unwrap();
453 assert_eq!(
454 (entry.name.as_str(), entry.short_name()),
455 ("Holy Bible: Hebrew & Old Testament", "HEOT")
456 );
457 assert_eq!(
458 entry.source.downloads(),
459 [Download {
460 name: "thml".into(),
461 urls: vec!["https://ccel.org/ccel/bible/heot.xml".into()]
462 }]
463 );
464 assert!(!entry.source.versions_track_updates());
465 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
466<!DOCTYPE ThML PUBLIC "-//CCEL/DTD Theological Markup Language//EN" "http://www.ccel.org/dtd/ThML10.dtd">
467<ThML><ThML.head><generalInfo><description>
468 The Hebrew
469 Old Testament (see <a href="https://ccel.org">ccel.org</a>).</description></generalInfo>
470<electronicEdInfo><version>1.1</version><DC>
471<DC.Title>The Holy Bible: Hebrew Old Testament</DC.Title><DC.Title sub="short">Hebrew Bible</DC.Title>
472<DC.Date sub="Created">2002-12-31</DC.Date><DC.Language scheme="ISO639-3">heb</DC.Language>
473<DC.Rights>Public Domain</DC.Rights></DC></electronicEdInfo></ThML.head>
474<ThML.body><div1 title="Obadiah"><p><scripture passage="Obad 1.1" parsed="|Obad|1|1|0|0" osisRef="Bible:Obad.1.1" />
475חֲזוֹן עֹבַדְיָה כֹּה־אָמַר אֲדֹנָי יְהוִה לֶאֱדוֹם</p></div1></ThML.body></ThML>"#;
476 let dest = MemoryStorage::new();
477 crate::tests::build_twice(entry, &[("thml".into(), xml.as_bytes().to_vec())], &dest);
478 assert_eq!(
479 dest.walk("").unwrap(),
480 ["COPYRIGHT.txt", "logoxide.toml", "thml/heot.xml"]
481 );
482 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
483 let info = package.info();
484 assert_eq!(info.name, "The Holy Bible: Hebrew Old Testament");
485 assert_eq!(info.aliases, ["Holy Bible: Hebrew & Old Testament"]);
486 assert_eq!(
487 (info.language.as_str(), info.version.to_string()),
488 ("he", "1.0.0".to_string())
489 );
490 let upstream = info.upstream.as_ref().unwrap();
491 assert_eq!(
492 (
493 upstream.import.as_deref(),
494 upstream.url.as_deref(),
495 upstream.version.as_deref()
496 ),
497 (
498 Some("ccel:heot"),
499 Some("https://ccel.org/ccel/bible/heot.xml"),
500 Some("1.1")
501 )
502 );
503 assert_eq!(info.license.as_deref(), Some("LicenseRef-PublicDomain"));
504 assert_eq!(
505 info.description.as_deref(),
506 Some("The Hebrew Old Testament (see ccel.org).")
507 );
508 assert_eq!(info.script.as_deref(), Some("Hebr"));
510 assert_eq!(info.direction, None);
511 assert_eq!(info.text_direction(), Direction::Rtl);
512 assert_eq!(package.books().len(), 1);
513
514 let dated = xml
516 .replace("<version>1.1</version>", "")
517 .replace("Public Domain", "Copyright 2010 Someone");
518 let dest = MemoryStorage::new();
519 import(entry, dated.as_bytes(), &dest).unwrap();
520 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
521 let upstream = package.info().upstream.clone().unwrap();
522 assert_eq!(upstream.version.as_deref(), Some("2002-12-31"));
523 assert_eq!(package.info().license.as_deref(), Some("LicenseRef-CCEL"));
524 let page = b"<!DOCTYPE html><html><body>Log in</body></html>";
526 let error = import(entry, page, &MemoryStorage::new()).unwrap_err();
527 assert!(error.to_string().contains("isn't ThML"), "{error}");
528 }
529
530 #[test]
531 fn titles_entities_are_decoded() {
532 assert_eq!(
533 decode_html_entities("A & B & C & x"),
534 "A & B & C & \u{a0}x"
535 );
536 assert_eq!(
537 decode_html_entities("A & B &unknown; C"),
538 "A & B &unknown; C"
539 );
540 }
541
542 const GEN_COM: &str = include_str!("../tests/fixtures/ccel/gen-COM.html");
544 const GEN_BIB: &str = include_str!("../tests/fixtures/ccel/gen-BIB.html");
545 const HENRY: &str = include_str!("../../logoxide-text/tests/fixtures/ccel/mhc1-gen1-2.xml");
547
548 #[test]
549 fn genre_indexes() {
550 let location = "https://ccel.org/index/genre/gen-COM";
551 let catalog = crate::catalog::parse_text(location, GEN_COM).unwrap();
552 assert_eq!(catalog.name, "CCEL gen-COM");
553 assert!(catalog.entries.len() > 200, "{}", catalog.entries.len());
556 assert!(
557 catalog
558 .entries
559 .iter()
560 .all(|e| e.kind == PackageKind::Commentary)
561 );
562 let henry = catalog.find_import("ccel:henry/mhc1").unwrap();
563 assert_eq!(henry.id, "ccel-henry-mhc1");
564 assert_eq!(henry.abbreviation.as_deref(), Some("MHC1"));
565 assert_eq!(henry.description.as_deref(), Some("Matthew Henry"));
566 assert_eq!(
567 henry.source.downloads()[0].urls,
568 ["https://ccel.org/ccel/henry/mhc1.xml"]
569 );
570 let barnes = catalog.find_import("ccel:barnes/ntnotes").unwrap();
571 assert_eq!(barnes.name, "Barnes' New Testament Notes");
572 assert!(catalog.find_import("ccel:brenton/lxx").is_none());
573 assert!(catalog.find_import("ccel:anonymous/hebrewot").is_none());
574 let catalog =
576 crate::catalog::parse_text("https://ccel.org/index/genre/gen-BIB", GEN_BIB).unwrap();
577 let kjv = catalog.find_import("ccel:kjv").unwrap();
578 assert_eq!(
579 (kjv.id.as_str(), kjv.kind),
580 ("ccel-kjv", PackageKind::Bible)
581 );
582 assert_eq!(
583 kjv.source.downloads()[0].urls,
584 ["https://ccel.org/ccel/bible/kjv.xml"]
585 );
586 assert_eq!(genre_kind("gen-THE"), PackageKind::Book);
587 }
588
589 #[test]
590 fn imports_a_commentary_by_name() {
591 let catalog = named_catalog("ccel:henry/mhc1").unwrap();
592 let entry = catalog.find_import("ccel:henry/mhc1").unwrap();
593 assert_eq!(entry.kind, PackageKind::Book);
594 assert!(named_catalog("ccel:kjv").is_none());
595 assert!(named_catalog("ccel:../x").is_none());
596 let dest = MemoryStorage::new();
597 crate::tests::build_twice(entry, &[("thml".into(), HENRY.as_bytes().to_vec())], &dest);
598 assert_eq!(
599 dest.walk("").unwrap(),
600 ["COPYRIGHT.txt", "logoxide.toml", "thml/mhc1.xml"]
601 );
602 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
603 let info = package.info();
604 assert_eq!(info.kind, PackageKind::Commentary);
606 assert_eq!(info.id, "ccel-henry-mhc1");
607 assert_eq!(
608 info.homepage.as_deref(),
609 Some("https://ccel.org/ccel/henry/mhc1")
610 );
611 let genesis = logoxide_text::bible_data()
612 .books()
613 .search_usfm("GEN")
614 .unwrap();
615 assert_eq!(package.books(), [genesis]);
616 let passage = (logoxide_text::bible_data().books())
617 .parse_osis("Gen.1.1")
618 .unwrap();
619 let notes = package.source().notes(&passage).unwrap();
620 assert_eq!(
621 notes.iter().map(|n| (n.verse, n.last)).collect::<Vec<_>>(),
622 [(1, 2)]
623 );
624 let scan = HENRY.replace("scripCom", "span");
626 let error = import(entry, scan.as_bytes(), &MemoryStorage::new()).unwrap_err();
627 assert!(error.to_string().contains("no books"), "{error}");
628 }
629
630 #[test]
631 fn imports_a_book() {
632 let catalog = named_catalog("ccel:calvin/institutes").unwrap();
633 let entry = catalog.find_import("ccel:calvin/institutes").unwrap();
634 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
635<ThML><ThML.head><electronicEdInfo><DC>
636<DC.Title>The Institutes of the Christian Religion</DC.Title>
637<DC.Type>Text.Monograph</DC.Type><DC.Language scheme="ISO639-3">eng</DC.Language>
638<DC.Rights>Public Domain</DC.Rights></DC></electronicEdInfo></ThML.head>
639<ThML.body><div1 title="Book First"><div2 title="Chapter 1.">
640<p>Without vision the people perish (<scripRef passage="Prov. 29:18" parsed="|Prov|29|18|0|0">Prov. 29:18</scripRef>).</p>
641</div2></div1></ThML.body></ThML>"#;
642 let dest = MemoryStorage::new();
643 crate::tests::build_twice(entry, &[("thml".into(), xml.as_bytes().to_vec())], &dest);
644 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
645 assert_eq!(package.info().kind, PackageKind::Book);
646 assert_eq!(
647 package.info().name,
648 "The Institutes of the Christian Religion"
649 );
650 assert!(package.books().is_empty() && package.text_format().is_none());
652 assert_eq!(
653 logoxide::references::book_files(&package).unwrap(),
654 ["thml/institutes.xml"]
655 );
656 let found = logoxide::references::find(&package).unwrap();
657 assert_eq!(found.len(), 1);
658 assert_eq!(
659 (found[0].passage.as_str(), found[0].chapter.as_deref()),
660 ("Prov.29.18", Some("Chapter 1."))
661 );
662 let titles = logoxide::titles::titles(&package).unwrap();
663 assert_eq!(
664 titles[0].title.as_deref(),
665 Some("The Institutes of the Christian Religion")
666 );
667 }
668}