#[cfg(feature = "read")]
use crate::manifest::FormatEntry;
use crate::{
error::{LogoxideError, Result},
manifest::{MANIFEST, Manifest, Package as Info, PackageKind},
storage::Storage,
};
#[cfg(feature = "read")]
pub use logoxide_text::metadata::{FileMetadata, epub_metadata, opf_metadata};
#[derive(Clone, Debug, Default)]
pub struct NewPackage {
pub id: Option<String>,
pub name: Option<String>,
pub abbreviation: Option<String>,
pub authors: Vec<String>,
pub description: Option<String>,
pub version: Option<semver::Version>,
pub kind: Option<PackageKind>,
pub language: Option<String>,
pub license: Option<String>,
pub publisher: Option<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub keywords: Vec<String>,
pub categories: Vec<String>,
pub about_books: Vec<String>,
}
impl NewPackage {
fn about_book_codes(&self) -> Result<Vec<String>> {
(self.about_books.iter())
.map(|name| {
crate::manifest::book_code(name).ok_or_else(|| {
LogoxideError::Manifest(format!(
"about-books: `{name}` is no book of the Bible"
))
})
})
.collect()
}
}
pub fn file_format(name: &str, bytes: &[u8]) -> Option<(&'static str, PackageKind)> {
let lower = name.to_ascii_lowercase();
let extension = lower.rsplit_once('.').map(|(_, e)| e)?;
Some(match extension {
"epub" => ("epub", PackageKind::Book),
"usx" => ("usx", PackageKind::Bible),
"usfm" | "sfm" => ("usfm", PackageKind::Bible),
"usj" => ("usj", PackageKind::Bible),
"osis" => ("osis", PackageKind::Bible),
"thml" => ("thml", PackageKind::Bible),
"xml" => {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]).to_lowercase();
match () {
_ if head.contains("<osis") => ("osis", PackageKind::Bible),
_ if head.contains("<thml") => ("thml", PackageKind::Bible),
_ if head.contains("<usx") => ("usx", PackageKind::Bible),
_ => return None,
}
}
"json" => {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]).to_lowercase();
let compact: String = head.split_whitespace().collect();
match compact.contains("\"type\":\"usj\"") {
true => ("usj", PackageKind::Bible),
false => return None,
}
}
_ => return None,
})
}
pub fn id_from(name: &str) -> String {
let mut id = String::new();
for c in name.chars().filter(|c| *c != '\'' && *c != '’') {
if c.is_ascii_alphanumeric() {
id.push(c.to_ascii_lowercase());
} else if !id.is_empty() && !id.ends_with('-') {
id.push('-');
}
}
id.trim_end_matches('-').to_string()
}
pub fn abbreviation_from(name: &str) -> String {
(name.split_whitespace())
.filter_map(|word| word.chars().find(|c| c.is_alphanumeric()))
.flat_map(char::to_uppercase)
.collect()
}
#[cfg(feature = "read")]
pub fn new_from_file(
file_name: &str,
bytes: &[u8],
given: &NewPackage,
dest: &dyn Storage,
) -> Result<Manifest> {
let (format, kind) = file_format(file_name, bytes).ok_or_else(|| {
LogoxideError::Import(format!(
"{file_name}: not a format logoxide packages (.epub, .usx, .usfm, .usj, .osis, .thml, or their .xml or .json)"
))
})?;
let metadata = match format {
"epub" => epub_metadata(bytes)?,
_ => FileMetadata::default(),
};
let name = (given.name.clone())
.or(metadata.title.clone())
.ok_or_else(|| missing(file_name, "a name (--name)"))?;
let language = (given.language.clone())
.or(metadata.language.clone())
.ok_or_else(|| missing(file_name, "a language (--language, BCP 47: en)"))?;
let kind = given.kind.unwrap_or(kind);
let info = Info {
id: given.id.clone().unwrap_or_else(|| id_from(&name)),
abbreviation: (given.abbreviation.clone())
.or_else(|| kind.needs_abbreviation().then(|| abbreviation_from(&name))),
name,
authors: match given.authors.is_empty() {
true => metadata.authors.clone(),
false => given.authors.clone(),
},
description: given.description.clone().or(metadata.subtitle.clone()),
aliases: vec![],
version: (given.version.clone()).unwrap_or_else(|| semver::Version::new(0, 1, 0)),
kind,
language,
language_name: None,
script: None,
direction: None,
license: given.license.clone(),
rights: None,
license_file: None,
publisher: given.publisher.clone().or(metadata.publisher.clone()),
homepage: given.homepage.clone(),
repository: given.repository.clone(),
documentation: None,
readme: None,
keywords: given.keywords.clone(),
categories: given.categories.clone(),
about_books: given.about_book_codes()?,
work: None,
edition: None,
edition_year: None,
upstream: None,
versification: String::from("own"),
maps_to: None,
keys: None,
include: vec![],
};
let manifest = Manifest {
package: Some(info),
formats: [(
format.to_string(),
FormatEntry {
path: format!("{format}/"),
feature: None,
},
)]
.into(),
..Manifest::default()
};
let base = file_name.rsplit(['/', '\\']).next().unwrap_or(file_name);
write(
&manifest,
&format!("# Made by logoxide from {base}\n"),
dest,
)?;
dest.write(&format!("{format}/{base}"), bytes)?;
Ok(manifest)
}
pub fn new_collection(given: &NewPackage, dest: &dyn Storage) -> Result<Manifest> {
let name = (given.name.clone()).ok_or_else(|| missing("a collection", "a name (--name)"))?;
let language = given.language.clone().unwrap_or_default();
let info = Info {
id: given.id.clone().unwrap_or_else(|| id_from(&name)),
abbreviation: given.abbreviation.clone(),
name,
authors: given.authors.clone(),
description: given.description.clone(),
aliases: vec![],
version: (given.version.clone()).unwrap_or_else(|| semver::Version::new(0, 1, 0)),
kind: PackageKind::Collection,
language,
language_name: None,
script: None,
direction: None,
license: given.license.clone(),
rights: None,
license_file: None,
publisher: given.publisher.clone(),
homepage: given.homepage.clone(),
repository: given.repository.clone(),
documentation: None,
readme: None,
keywords: given.keywords.clone(),
categories: given.categories.clone(),
about_books: given.about_book_codes()?,
work: None,
edition: None,
edition_year: None,
upstream: None,
versification: String::from("own"),
maps_to: None,
keys: None,
include: vec![],
};
let manifest = Manifest {
package: Some(info),
..Manifest::default()
};
write(
&manifest,
"# A collection: its packages are its dependencies (lx add --project <this folder>)\n",
dest,
)?;
Ok(manifest)
}
fn missing(what: &str, needs: &str) -> LogoxideError {
LogoxideError::Import(format!("{what}: give {needs}; the file doesn't say"))
}
fn write(manifest: &Manifest, header: &str, dest: &dyn Storage) -> Result<()> {
manifest.validate()?;
let text = toml::to_string(manifest).map_err(|e| LogoxideError::Manifest(e.to_string()))?;
let mut doc: toml_edit::DocumentMut =
(text.parse()).map_err(|e: toml_edit::TomlError| LogoxideError::Manifest(e.to_string()))?;
if let Some(formats) = doc.get_mut("formats").and_then(|f| f.as_table_mut()) {
let inline: Vec<(String, toml_edit::InlineTable)> = (formats.iter())
.filter_map(|(name, f)| Some((name.to_string(), f.as_table()?.clone())))
.map(|(name, table)| (name, table.into_inline_table()))
.collect();
formats.clear();
for (name, table) in inline {
formats.insert(&name, toml_edit::value(table));
}
}
dest.write(MANIFEST, format!("{header}{doc}").as_bytes())
}
#[cfg(all(test, feature = "read"))]
mod tests {
use super::*;
#[test]
fn names_from_titles() {
assert_eq!(id_from("God's Word Is Not Bound"), "gods-word-is-not-bound");
assert_eq!(id_from(" Hello, World! 2 "), "hello-world-2");
assert_eq!(abbreviation_from("The Dorean Principle"), "TDP");
assert_eq!(abbreviation_from("God's Word Is Not Bound"), "GWINB");
}
#[test]
fn makes_packages() {
let dest = crate::MemoryStorage::new();
let given = NewPackage {
name: Some("Notes".into()),
language: Some("en".into()),
..NewPackage::default()
};
let made = new_from_file("dir/JHN.usfm", b"\\id JHN", &given, &dest).unwrap();
let info = made.package.unwrap();
assert_eq!((info.id.as_str(), info.kind), ("notes", PackageKind::Bible));
assert!(dest.read("usfm/JHN.usfm").is_ok());
let no_language = NewPackage {
name: Some("Notes".into()),
..NewPackage::default()
};
let error = new_from_file("JHN.usfm", b"", &no_language, &dest).unwrap_err();
assert!(error.to_string().contains("--language"), "{error}");
assert!(new_from_file("notes.txt", b"", &given, &dest).is_err());
let usj = b"{\n \"type\": \"USJ\",\n \"version\": \"3.1\"";
assert_eq!(
file_format("JHN.json", usj),
Some(("usj", PackageKind::Bible))
);
assert_eq!(
file_format("JHN.usj", b""),
Some(("usj", PackageKind::Bible))
);
assert_eq!(file_format("data.json", b"{\"type\": \"x\"}"), None);
let collection = crate::MemoryStorage::new();
let made = new_collection(&given, &collection).unwrap();
let info = made.package.unwrap();
assert_eq!(info.kind, PackageKind::Collection);
assert!(made.formats.is_empty());
let unnamed = NewPackage {
name: Some("My Library".into()),
..NewPackage::default()
};
let made = new_collection(&unnamed, &collection).unwrap();
let info = made.package.unwrap();
assert_eq!(
(info.abbreviation.as_deref(), info.language.as_str()),
(None, "")
);
let written = collection.read_string(MANIFEST).unwrap();
assert!(
!written.contains("language") && !written.contains("abbreviation"),
"{written}"
);
let read = Manifest::parse(&written).unwrap().package.unwrap();
assert_eq!(
(read.abbreviation.as_deref(), read.language.as_str()),
(None, "und")
);
}
}