logoxide-import 0.1.1

Packages from other publishers' downloads: eBible.org, fetch.bible, CCEL, Door43, SWORD repositories, Scripture Burritos, and DBL bundles (Bibles, commentaries, dictionaries, and books), as logoxide catalogs and imports
Documentation
/*!
eBible.org: its list of translations ([`catalog`], `translations.csv`) and a translation's
`<id>_usfm.zip` ([`import`]), one USFM file per book, with its copyright in `copr.htm` (the
package's license file). Its versions are update dates, which follow its updates
*/

use logoxide::{
    LogoxideError, Result,
    catalog::{Catalog, CatalogEntry, Download},
    language,
    license::Rights,
    manifest::{Direction, PackageKind},
    storage::{MemoryStorage, Storage},
};

use crate::{Made, external, finish, import_id, package_id, unzip};

/// Where eBible.org serves a translation's USFM
pub fn zip_url(id: &str) -> String {
    format!("https://ebible.org/Scriptures/{id}_usfm.zip")
}

/// eBible.org's `translations.csv`: the translations it lets anyone download, each an
/// `ebible:<id>` import of its zip
pub fn catalog(text: &str) -> Result<Catalog> {
    let rows = csv(text.trim_start_matches('\u{feff}'));
    let mut rows = rows.into_iter();
    let header = rows
        .next()
        .ok_or_else(|| LogoxideError::Catalog(String::from("an empty translations.csv")))?;
    let column = |name: &str| {
        header
            .iter()
            .position(|h| h == name)
            .ok_or_else(|| LogoxideError::Catalog(format!("translations.csv has no {name} column")))
    };
    let [
        language,
        id,
        language_name,
        title,
        description,
        copyright,
        short_title,
    ] = [
        "languageCode",
        "translationId",
        "languageNameInEnglish",
        "title",
        "description",
        "Copyright",
        "shortTitle",
    ]
    .map(column);
    let [
        language,
        id,
        language_name,
        title,
        description,
        copyright,
        short_title,
    ] = [
        language?,
        id?,
        language_name?,
        title?,
        description?,
        copyright?,
        short_title?,
    ];
    let [redistributable, downloadable, direction, updated] = [
        "Redistributable",
        "downloadable",
        "textDirection",
        "UpdateDate",
    ]
    .map(column);
    let [redistributable, downloadable, direction, updated] =
        [redistributable?, downloadable?, direction?, updated?];
    // Its script by name (`Latin`), in newer lists
    let script = column("script").ok();
    let get = |row: &[String], i: usize| row.get(i).map(|s| s.trim().to_string());
    let yes = |row: &[String], i: usize| get(row, i).is_some_and(|v| v == "True");
    let mut entries = vec![];
    for row in rows {
        if !yes(&row, downloadable) || !yes(&row, redistributable) {
            continue;
        }
        let (Some(code), Some(ebible_id)) = (get(&row, language), get(&row, id)) else {
            continue;
        };
        let (tag, raw) = language::normalize_language(&code);
        let copyright = get(&row, copyright).filter(|c| !c.is_empty());
        // The list gives only a statement (the license is in the download's copr.htm, which
        // its import reads), and lists only what may be redistributed
        let license = (copyright.as_deref())
            .and_then(crate::license::license_from_text)
            .unwrap_or_else(|| String::from("LicenseRef-eBible"));
        let name = get(&row, title)
            .filter(|t| !t.is_empty())
            .or_else(|| get(&row, short_title))
            .unwrap_or_else(|| ebible_id.clone());
        let downloads = vec![Download {
            name: String::from("zip"),
            urls: vec![zip_url(&ebible_id)],
        }];
        entries.push(CatalogEntry {
            // Prefixed: eBible.org's ids (`eng-web`) could be other catalogs' too, and the
            // first of an id wins when catalogs merge
            id: format!("ebible-{}", package_id(&ebible_id)),
            abbreviation: Some(abbreviation(&ebible_id, &code)),
            name,
            aliases: vec![],
            language_name: get(&row, language_name).filter(|n| !n.is_empty()).or(raw),
            script: (script.and_then(|i| get(&row, i)))
                .and_then(|s| language::script_code(&s))
                .or_else(|| language::script_of_tag(&tag)),
            language: tag,
            description: get(&row, description).filter(|d| !d.is_empty()),
            license: Some(license),
            rights: Rights {
                redistributable: Some(true),
                ..Default::default()
            },
            copyright,
            version: get(&row, updated).filter(|d| !d.is_empty()),
            kind: PackageKind::Bible,
            direction: match get(&row, direction).as_deref() {
                Some("rtl") => Direction::Rtl,
                _ => Direction::Ltr,
            },
            source: external("ebible", &ebible_id, true, downloads),
            path: None,
            releases: vec![],
            keywords: vec![],
            categories: vec![],
            about_books: vec![],
            work: None,
            edition: None,
            edition_year: None,
            keys: None,
        });
    }
    logoxide::catalog::known_works(&mut entries);
    Ok(Catalog {
        name: String::from("eBible.org"),
        entries,
        works: vec![],
        warnings: vec![],
    })
}

/**
An eBible.org translation's `<id>_usfm.zip` (from [`zip_url`]) as a package in `dest`:
`logoxide.toml`, `usfm/` (every book's file), and `copr.htm` (its copyright, the license file). The
package's id, name, and language come from `entry`, and eBible.org's update date is the upstream
version. Its license is read from `copr.htm` (a Creative Commons license by its link or name, or
the public domain; [`crate::license::license_from_text`]), else the entry's. Checked by opening
it: a zip without books is an error
*/
pub fn import(entry: &CatalogEntry, zip: &[u8], dest: &dyn Storage) -> Result<()> {
    let id = import_id(entry, "ebible", "eBible.org")?;
    let files = unzip(zip)?;
    let staged = MemoryStorage::new();
    let mut license_file = None;
    for (path, data) in files {
        let name = path.rsplit('/').next().unwrap_or(&path);
        let lower = name.to_ascii_lowercase();
        if lower.ends_with(".usfm") || lower.ends_with(".sfm") {
            staged.write(&format!("usfm/{name}"), &data)?;
        } else if lower == "copr.htm" {
            staged.write(name, &data)?;
            license_file = Some(name.to_string());
        }
    }
    let mut entry = entry.clone();
    if let Some(license) = (license_file.as_deref())
        .and_then(|file| staged.read_string(file).ok())
        .and_then(|text| crate::license::license_from_text(&text))
    {
        entry.license = Some(license);
    }
    let made = Made {
        format: "usfm",
        license_file,
        publisher: "eBible.org",
        publisher_name: None,
        homepage: format!("https://ebible.org/{id}/"),
        versification: None,
        keys: None,
        note: None,
    };
    finish(&entry, made, staged, dest)
}

/// What references could name an eBible.org translation by: its id without the language code
/// (`eng-asv` is `ASV`, `engwebp` is `WEBP`)
fn abbreviation(id: &str, language: &str) -> String {
    let rest = id
        .strip_prefix(language)
        .map(|r| r.trim_start_matches(['-', '_']))
        .filter(|r| !r.is_empty())
        .unwrap_or(id);
    rest.to_uppercase()
}

/// Rows of a CSV file: fields split by commas, quoted fields with `""` for a quote (and
/// commas or line breaks inside)
fn csv(text: &str) -> Vec<Vec<String>> {
    let mut rows = vec![];
    let mut row = vec![];
    let mut field = String::new();
    let mut quoted = false;
    let mut chars = text.chars().peekable();
    while let Some(c) = chars.next() {
        match (c, quoted) {
            ('"', true) if chars.peek() == Some(&'"') => {
                field.push('"');
                chars.next();
            }
            ('"', true) => quoted = false,
            ('"', false) if field.is_empty() => quoted = true,
            (',', false) => row.push(std::mem::take(&mut field)),
            ('\r', false) => {}
            ('\n', false) => {
                row.push(std::mem::take(&mut field));
                rows.push(std::mem::take(&mut row));
            }
            _ => field.push(c),
        }
    }
    if !field.is_empty() || !row.is_empty() {
        row.push(field);
        rows.push(row);
    }
    rows
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::tests::zip;
    use logoxide::{
        catalog::{CatalogSource, EBIBLE_CATALOG},
        package::{Features, Package},
    };

    pub(crate) const EBIBLE: &str = "\u{feff}\"languageCode\",\"translationId\",\"languageName\",\"languageNameInEnglish\",\"dialect\",\"homeDomain\",\"title\",\"description\",\"Redistributable\",\"Copyright\",\"UpdateDate\",\"publicationURL\",\"OTbooks\",\"OTchapters\",\"OTverses\",\"NTbooks\",\"NTchapters\",\"NTverses\",\"DCbooks\",\"DCchapters\",\"DCverses\",\"FCBHID\",\"Certified\",\"inScript\",\"swordName\",\"rodCode\",\"textDirection\",\"downloadable\",\"font\",\"shortTitle\",\"PODISBN\",\"script\",\"sourceDate\"
\"eng\",\"eng-asv\",\"English\",\"English\",\"Archaic American\",\"ebible.org\",\"American Standard Version (1901)\",\"The American Standard Version of the Holy Bible, first published in 1901.\",\"True\",\"public domain\",\"2026-10-08\",\"http://ebible.org/eng-asv/\",\"39\",\"929\",\"23145\",\"27\",\"260\",\"7957\",\"0\",\"0\",\"0\",\"ENGASV\",\"True\",\"\",\"engASV1901eb\",\"00025\",\"ltr\",\"True\",\"Gentium\",\"American Standard Version (1901)\",\"\",\"Latin\",\"2026-10-08\"
\"eng\",\"engamp\",\"English\",\"English\",\"American\",\"eBible.org\",\"The Amplified Bible (2015)\",\"The Amplified Bible, 2015\",\"False\",\"Copyright © 2015 The Lockman Foundation\",\"2024-07-27\",\"\",\"39\",\"929\",\"23145\",\"27\",\"260\",\"7958\",\"0\",\"0\",\"0\",\"\",\"False\",\"\",\"\",\"\",\"ltr\",\"False\",\"\",\"Amplified\",\"\",\"Latin\",\"2024-07-27\"
\"heb\",\"hebwlc\",\"Hebrew\",\"Hebrew, Ancient\",\"\",\"ebible.org\",\"Westminster Leningrad Codex\",\"The \"\"WLC\"\", with vowels, and a comma, here\",\"True\",\"Copyright © 2020 J. Alan Groves Center\",\"2020-01-01\",\"\",\"39\",\"929\",\"23213\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"\",\"True\",\"\",\"\",\"\",\"rtl\",\"True\",\"\",\"WLC\",\"\",\"Hebrew\",\"2020-01-01\"
";

    #[test]
    fn reads_ebibles_list() {
        let catalog = crate::catalog::parse_text(EBIBLE_CATALOG, EBIBLE).unwrap();
        // The Amplified can't be downloaded
        assert_eq!(catalog.entries.len(), 2);
        let asv = &catalog.entries[0];
        assert_eq!(
            (asv.id.as_str(), asv.short_name(), asv.language.as_str()),
            ("ebible-eng-asv", "ASV", "en")
        );
        assert_eq!(asv.license.as_deref(), Some("LicenseRef-PublicDomain"));
        assert_eq!(
            asv.source,
            CatalogSource::External {
                import: "ebible:eng-asv".into(),
                upstream: Some("https://ebible.org/Scriptures/eng-asv_usfm.zip".into()),
                tracks_versions: true,
                downloads: vec![Download {
                    name: "zip".into(),
                    urls: vec!["https://ebible.org/Scriptures/eng-asv_usfm.zip".into()]
                }],
            }
        );
        assert_eq!(asv.version.as_deref(), Some("2026-10-08"));
        // The work logoxide's table knows the import to be
        assert_eq!(asv.work.as_deref(), Some("asv"));
        let wlc = &catalog.entries[1];
        assert_eq!(wlc.abbreviation.as_deref(), Some("WLC"));
        assert_eq!(wlc.direction, Direction::Rtl);
        // The list's script, by name
        assert_eq!(
            (asv.script.as_deref(), wlc.script.as_deref()),
            (Some("Latn"), Some("Hebr"))
        );
        assert_eq!(wlc.license.as_deref(), Some("LicenseRef-eBible"));
        // The list has only what may be redistributed
        assert_eq!(wlc.rights.redistributable, Some(true));
        assert_eq!(
            wlc.description.as_deref(),
            Some("The \"WLC\", with vowels, and a comma, here")
        );
        // Found by its import name, and by the prefix alone nothing else
        assert_eq!(catalog.find_import("ebible:eng-asv"), Some(asv));
        assert!(catalog.find_import("fetch:eng-asv").is_none());
        assert!(catalog.find_import("eng-asv").is_none());
    }

    #[test]
    fn imports_an_ebible_zip() {
        let csv = "\"languageCode\",\"translationId\",\"languageNameInEnglish\",\"title\",\"description\",\"Redistributable\",\"Copyright\",\"UpdateDate\",\"textDirection\",\"downloadable\",\"shortTitle\"\n\"eng\",\"eng-asv\",\"English\",\"American Standard Version (1901)\",\"\",\"True\",\"public domain\",\"2026-10-08\",\"ltr\",\"True\",\"ASV\"\n";
        let catalog = catalog(csv).unwrap();
        let entry = &catalog.entries[0];
        let bytes = zip(&[
            ("00-FRTeng-asv.usfm", "\\id FRT\n\\mt Front"),
            (
                "73-JHNeng-asv.usfm",
                "\\id JHN\n\\c 3\n\\p\n\\v 16 For God so loved the world.\n",
            ),
            ("copr.htm", "<p>public domain</p>"),
            ("latin.css", "body {}"),
        ]);
        assert_eq!(
            crate::downloads(entry).unwrap(),
            [Download {
                name: "zip".into(),
                urls: vec![zip_url("eng-asv")]
            }]
        );
        let dest = MemoryStorage::new();
        crate::tests::build_twice(entry, &[("zip".into(), bytes)], &dest);
        assert_eq!(
            dest.walk("").unwrap(),
            [
                "copr.htm",
                "logoxide.toml",
                "usfm/00-FRTeng-asv.usfm",
                "usfm/73-JHNeng-asv.usfm"
            ]
        );
        let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
        let info = package.info();
        assert_eq!(
            (
                info.id.as_str(),
                info.short_name(),
                info.version.to_string()
            ),
            ("ebible-eng-asv", "ASV", "1.0.0".to_string())
        );
        let upstream = info.upstream.as_ref().unwrap();
        assert_eq!(
            (
                upstream.url.as_deref(),
                upstream.import.as_deref(),
                upstream.version.as_deref()
            ),
            (
                Some("https://ebible.org/Scriptures/eng-asv_usfm.zip"),
                Some("ebible:eng-asv"),
                Some("2026-10-08")
            )
        );
        assert_eq!(info.license_file.as_deref(), Some("copr.htm"));
        assert_eq!(info.license.as_deref(), Some("LicenseRef-PublicDomain"));
        // The work logoxide's table knows ebible:eng-asv to be
        assert_eq!(info.work.as_deref(), Some("asv"));
        assert_eq!(package.books().len(), 1);
        assert!(import(entry, &zip(&[("copr.htm", "")]), &MemoryStorage::new()).is_err());
        // The license is read from copr.htm, as its link gives it; eBible.org's list says it may
        // be redistributed, and the license says the rest
        let licensed = MemoryStorage::new();
        let copr = "<p>Copyright © 2009 Someone. This translation is made available to you under the terms of the <a href='http://creativecommons.org/licenses/by-nc-nd/4.0/'>Creative Commons Attribution-Noncommercial-No Derivatives license 4.0.</a></p>";
        let book = "\\id JHN\n\\c 3\n\\p\n\\v 16 For God so loved the world.\n";
        import(
            entry,
            &zip(&[("73-JHNeng-asv.usfm", book), ("copr.htm", copr)]),
            &licensed,
        )
        .unwrap();
        let package = Package::open(std::sync::Arc::new(licensed), Features::default()).unwrap();
        let info = package.info();
        assert_eq!(info.license.as_deref(), Some("CC-BY-NC-ND-4.0"));
        let rights = info.text_rights();
        assert_eq!(
            (
                rights.redistributable,
                rights.commercial,
                rights.derivatives
            ),
            (
                Some(true),
                Some(false),
                Some(logoxide::license::Derivatives::None)
            )
        );
        // Not converted: eBible.org's USFM is kept as it is
        assert!(!info.upstream.as_ref().unwrap().converted);
        assert!(import(entry, b"not a zip", &MemoryStorage::new()).is_err());
        // Another publisher's entry isn't eBible.org's
        let mut other = entry.clone();
        other.source = external("ccel", "asv", false, vec![]);
        let error = import(&other, &zip(&[]), &MemoryStorage::new()).unwrap_err();
        assert!(
            error.to_string().contains("isn't from eBible.org"),
            "{error}"
        );
    }
}