Skip to main content

logoxide_import/
ccel.rs

1/*!
2CCEL (the Christian Classics Ethereal Library): its page of Bibles ([`catalog`]), its genre
3indexes ([`genre_catalog`]: commentaries, Bibles, and any other genre's books), and a work's ThML
4([`import`]), the whole work in one file ([`url`]), described by its own head (title, language,
5rights, version, and whether it's a Bible, a commentary, or a book: [`thml_head`]), with its
6rights in `COPYRIGHT.txt`. Its lists give no versions: an update is found by downloading.
7
8A Bible is imported as `ccel:<id>` (`ccel:kjv`, its file `ccel/bible/kjv.xml`); any other work as
9`ccel:<author>/<work>` (`ccel:henry/mhc1`, `ccel/henry/mhc1.xml`), which needn't be in a catalog
10([`named_catalog`]): its head says what it is
11*/
12
13use 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
23/// Where CCEL serves a work's ThML: a Bible's by its id (`kjv`), another work's by its author and
24/// name (`henry/mhc1`), as its import names it
25pub 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
32/// A work's page on CCEL, from its import id (as [`url`])
33fn 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
40/// Whether a name is one CCEL's paths use for an author or a work (`henry`, `mhc1`,
41/// `daily_light`)
42fn 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
49/**
50The entry for a work's import: `id` is a Bible's (`kjv`, CCEL's `bible` author left out) or
51`<author>/<work>`; the package's id is `ccel-<id>` with `/` as `-` (`ccel-henry-mhc1`), prefixed
52because CCEL's ids (`kjv`, `web`) are other catalogs' too, and the first of an id wins when
53catalogs merge. Its language is `und` and its license unknown until its file's head says
54([`import`])
55*/
56fn 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
90/**
91CCEL's page of its Bibles ([`logoxide::catalog::CCEL_CATALOG`]): each work's id and title, from
92the links in its headings (`<h2><a href="https://ccel.org/ccel/bible/kjv/kjv">King James Version
93of the Holy Bible</a></h2>`), each a `ccel:<id>` import of its ThML. The page doesn't say a
94work's language or rights (its file's head does, [`import`]): the language is `und` until then,
95and the license unknown
96*/
97pub 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        // `…/ccel/bible/<work>/<work>`: a work of the author `bible`
110        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
136/// The text of the first link in `html` (`<a href="…">Title</a>`), its entities decoded and its
137/// whitespace collapsed
138fn 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
147/// The kind of package a CCEL genre's works are (`gen-COM` commentaries, `gen-BIB` Bibles), by
148/// its code; every other genre's are books
149pub 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
157/// The genre a CCEL genre index's URL names (`gen-COM` of `https://ccel.org/index/genre/gen-COM`)
158pub 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
166/**
167A CCEL genre index (`https://ccel.org/index/genre/gen-COM`, "All Works in Genre
168'Commentaries'"): each `<p class="List2"><a href="/ccel/<author>/<work>.html">Title</a>` a
169`ccel:<author>/<work>` import (a Bible's, `/ccel/bible/<id>.html`, is `ccel:<id>`, as
170[`catalog`] lists it) of the kind the genre's works are ([`genre_kind`]); the author's name, when
171the page gives it, is its description. Links of other shapes (a work's folder, a page of its
172own) are passed over
173*/
174pub 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
224/**
225A catalog of the one work a `ccel:<author>/<work>` import names (`ccel:henry/mhc1`), for one no
226catalog lists: CCEL serves every work's ThML the same way, so the name says where it is. It's a
227book until its file's head says otherwise ([`import`]); `None` for any other name
228*/
229pub 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
247/// The entities a title on CCEL's page may hold (the named ones HTML gives a Bible's name, and
248/// numeric ones), decoded
249fn 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/// What a ThML file's head (`<ThML.head>`) says about it
284#[derive(Clone, Debug, Default, PartialEq, Eq)]
285pub struct ThmlHead {
286    /// `DC.Title` (the first, which isn't a short form)
287    pub title: Option<String>,
288    /// `DC.Language`, as ISO 639-3 (`eng`; the package says `en`)
289    pub language: Option<String>,
290    /// `DC.Rights`: `Public Domain`, or a copyright statement
291    pub rights: Option<String>,
292    /// The edition's own version (`<version>1.0</version>`)
293    pub version: Option<String>,
294    /// `DC.Date`, when it was made (`2002-11-30`)
295    pub date: Option<String>,
296    /// `<description>`, its lines joined
297    pub description: Option<String>,
298    /// `DC.Type`: what the work is (`Text.Bible`, `Text.Commentary`, `Text.Monograph`)
299    pub dc_type: Option<String>,
300}
301
302impl ThmlHead {
303    /// The kind of package its `DC.Type` says it is: a Bible, a commentary, or (any other type) a
304    /// book; none when the head doesn't say
305    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
315/// The head of a ThML file (only the part before `</ThML.head>` is read)
316pub 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
337/// The text inside the first `<name>…</name>` of `xml`, with any elements in it left out (a
338/// description can hold links), its whitespace collapsed
339fn 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
357/**
358A work CCEL publishes, from its ThML (the bytes at [`url`]), as a package in `dest`:
359`logoxide.toml`, `thml/<work>.xml`, and `COPYRIGHT.txt` (its rights, the license file). The file's
360head names it and gives its language and rights (CCEL's list doesn't), its kind (`DC.Type`: a
361Bible, a commentary, else a book; else the entry's), and the upstream version: the edition's own
362(`1.0`), else its date (`2002-11-30`). Public domain is `LicenseRef-PublicDomain`; anything else
363is `LicenseRef-CCEL`, with the statement in the license file. A page in place of the file (CCEL
364answers with one for works it restricts) is an error, and so is a Bible or commentary with no
365verse markers (CCEL's scans, "Images Only", have none)
366*/
367pub 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    // CCEL doesn't say the script, and its Hebrew transliteration is tagged as Hebrew too, so
398    // the language can't tell: the letters do
399    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 &amp; 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        // Its letters are Hebrew: the script says it runs right to left, and only it is written
509        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        // Without an edition version, its date; a copyright isn't public domain
515        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        // CCEL's page for a work it restricts
525        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 &amp; B &#x26; C &#38; &nbsp;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    /// CCEL's genre indexes, saved 2026-10-10 (`tests/fixtures/ccel/`)
543    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    /// Matthew Henry's Genesis 1–2, cut short (logoxide-text's fixture)
546    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        // 213 links; a work's folder (`hebrewot/home.html`) and one with no `.html` aren't
554        // imports
555        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        // A Bible keeps its import name in the genre of Bibles
575        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        // Its head says it's a commentary
605        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        // A scan (no verse markers) isn't a commentary
625        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        // Not read as verses: its references are found in it, as an EPUB's are
651        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}